@btravstack/outbox
The transactional outbox relay for
@btravstack/core: a poll loop that publishes committed facts in outbox order, a per-tenant claim so replicas take turns rather than race for the same rows, and a Prisma 8 store.
📖 Documentation · API Reference
pnpm add @btravstack/outbox @btravstack/core @btravstack/config @btravstack/di unthrown temporal-polyfill
@prisma/orm-postgres is an optional peer, needed only if you compose the
Prisma store from @btravstack/outbox/prisma. Node >=22.12.
Occurrence timestamps are Temporal.Instant, preserving database microseconds
through reads and publication. Serialize them with toString(); use
epochMilliseconds where a millisecond-only integration requires it.
A worked example
The application writes its outbox row in the same transaction as the row it describes — that stays its own. What it provides the relay is where the table is, and what publishing a row means:
import { OutboxPublisher, OutboxStore, outbox } from "@btravstack/outbox";
import { prismaOutboxStore } from "@btravstack/outbox/prisma";
const store = Provider(OutboxStore)({
inject: { db: OrderDatabase },
sync: ({ db }) => prismaOutboxStore(db, { schema: "orders" }),
});
const publisher = Provider(OutboxPublisher)({
inject: { broker: Broker },
sync: ({ broker }) => ({
publish: (message) =>
broker.send(`${message.kind}.changed`, {
eventId: message.id,
tenantId: message.tenantId,
id: message.subjectId,
payload: message.payload === null ? null : JSON.parse(message.payload),
}),
}),
});
export const Relay = Module("Relay")({
imports: [OrderPersistenceModule, BrokerModule, outbox()],
provides: [store, publisher],
exports: [],
});
A standalone relay process
Use outboxRuntime() in place of outbox() and export OutboxRuntime and
HealthChecks from the root passed to runMain. It starts polling at runtime
startup, keeps the process alive, and stops new claims during SIGTERM drain.
Drain waits for current claims up to the kernel's deadline; scope teardown waits
for them before releasing database and publisher resources, bounded by the
kernel's stop deadline. The same options and outbox lag check apply.
The standalone example
shows the complete root. Keep outbox() when embedding a relay in an HTTP or
worker process.
Options
| Option | Where | What it is |
|---|---|---|
OUTBOX_TENANTS | environment, or tenants | the tenants this relay serves, comma-separated — required |
OUTBOX_POLL_MS | environment, or pollMs | the idle sleep between sweeps (default 200) |
OUTBOX_MAX_LAG_MS | environment, or maxLagMs | the oldest pending age the outbox health check tolerates (default 60 s) |
OUTBOX_RETAIN_MS | environment, or retainMs | how long a published row is kept before the relay deletes it (default: kept) |
OUTBOX_CONCURRENCY | environment, or concurrency | how many tenants claim or prune, each on a pooled connection, at once (4) |
clock | outbox({ clock }) | what the poll sleeps on and the lag is measured against (default: systemClock) |
schema, table | prismaOutboxStore(db, {…}) | where the model lives (default public, outboxMessage) |
columns | prismaOutboxStore(db, {…}) | the physical column names, for a model mapped with @map |
tenantSetting | prismaOutboxStore(db, {…}) | the setting each tenant's reads are pinned to, for @@rls (app.tenant_id) |
The full table — defaults, semantics, the table's PSL and the reasoning — lives on the reference page, which is this list's one detailed home.
What it decides, and what it does not
It decides that delivery is at-least-once, that one relay publishes a
tenant at a time while its claiming session lives — so replicas take turns
rather than race for a tenant's rows, and a tenant's committed facts go out in
outbox order (which is not commit order: the reference page says what that
means for a subject); a session lost mid-batch, like a crash between a publish
and its mark, re-publishes — that a refused
publish stops its tenant's batch and backs that tenant off, and that a tenant
falling behind is unhealthy on /healthz. A subscriber deduplicates on the
outbox id, which the publisher puts on the wire.
It does not decide what a publish is, which transport it rides or how the
payload is encoded — that is your OutboxPublisher — nor the transaction that
writes the row, which is your adapter's. There is no exactly-once and no
second store; the reasons are in AGENTS.md.
License
MIT