Drizzle transactional outbox
When a handler writes a row and emits a cross-service event, both must commit in the same database transaction. The transactional outbox pattern queues events locally; a flusher publishes them after commit.
1. Declare the outbox
ts
import { defineOutbox } from "@nwire/forge";
import { OrderWasPlaced } from "./orders.events";
export const ordersOutbox = defineOutbox({
name: "orders",
description: "Miri's checkout row and the OrderWasPlaced fact share one commit.",
publishes: [OrderWasPlaced],
flushIntervalMs: 500,
maxBatch: 50,
});Register it on the forge plugin:
ts
forgePlugins({
// ... actors, projections, workflows ...
outboxes: [ordersOutbox],
});2. Outbox table (Drizzle)
ts
import { pgTable, text, timestamp, jsonb, uuid } from "drizzle-orm/pg-core";
export const outboxEntries = pgTable("outbox_entries", {
id: uuid("id").primaryKey().defaultRandom(),
outboxName: text("outbox_name").notNull(),
eventName: text("event_name").notNull(),
payload: jsonb("payload").notNull(),
envelope: jsonb("envelope").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
flushedAt: timestamp("flushed_at", { withTimezone: true }),
});3. Handler — one transaction
ts
import { defineAction } from "@nwire/forge";
import { PlaceOrderInput } from "./place-order.action";
import { orders } from "./orders.schema";
export const placeOrder = defineAction({
name: "orders.place-order",
input: PlaceOrderInput,
handler: async (ctx) => {
const { input } = ctx;
const db = ctx.resolve("db");
await db.transaction(async (tx) => {
await tx.insert(orders).values({
id: input.orderId,
tenantId: ctx.tenant,
total: input.total,
});
await tx.insert(outboxEntries).values({
outboxName: ordersOutbox.name,
eventName: "orders.order-was-placed",
payload: { orderId: input.orderId, total: input.total },
envelope: ctx.envelope,
});
});
return { orderId: input.orderId };
},
});If the transaction rolls back, neither the row nor the outbox entry survives — no ghost events.
4. Flusher (wire boundary)
The flusher runs at deployment time, not in domain code:
ts
// outbox-flusher.ts
import { createCronWire } from "@nwire/cron";
// Poll pending rows, publish to @nwire/nats (or bus-amqp), mark flushedAt.
// Pair with defineInbox on consumers for dedup.Production options:
@nwire/cronpoller in the same service@nwire/bullmqrepeatable job- Dedicated sidecar reading
outbox_entries WHERE flushed_at IS NULL
5. Verify durability
Integration test checklist:
- Insert row + outbox row in one txn → both visible.
- Roll back txn → neither visible.
- Flusher publishes →
flushedAtset; bus consumer receives once. - Retry publish → consumer dedups via inbox.
See examples/station-mgmt-drizzle/ for Drizzle wiring and @nwire/trust-kit scenario C08 for the outbox primitive smoke.