Skip to content

Level 4 — Scale & operations

Levels 1–3 stay inside a single aggregate. Level 4 is where read models span many aggregates — and where you operate the whole thing in production.

A multi-stream projection (MSP) folds events from many aggregates into one table — here, revenue per customer across all invoices:

// Multi-stream projection: one row per customer. Fires ASYNC via the
// event-dispatcher (runOnce in tests, NOTIFY/LISTEN in production).
r.multiStreamProjection({
name: "customer-revenue",
table: customerRevenueTable,
apply: {
[paid.name]: async (event, tx) => {
// Resolve the customer: pull it from the invoice-detail projection
// that the inline projection just populated. Cross-projection reads
// are fine inside apply() — we're at the read model layer.
const detail = await fetchOne<{ customer: string }>(tx, invoiceDetailTable, {
invoiceId: event.aggregateId,
});
if (!detail) return;
const p = typedPayload(event, paid);
await incrementCounter(
tx,
customerRevenueTable,
{
customer: detail.customer,
tenantId: event.tenantId,
paidInvoices: 1,
totalCents: p.amountCents,
},
{ paidInvoices: 1, totalCents: p.amountCents },
{ conflictKeys: ["customer"] },
);
},
},
});

📄 On GitHub: samples/recipes/event-sourcing/src/feature.ts

Two things distinguish an MSP from the inline projection of Level 2:

  • It runs async via the event dispatcher (Postgres NOTIFY/LISTEN in production, runOnce() in tests) — the write transaction doesn’t wait for it. Expect eventual consistency: the read model trails the write by milliseconds under normal load.
  • Its apply functions must be idempotent — delivery is at-least-once, so an apply may run twice for the same event. Upserts and incrementCounter-style conflict handling are the pattern; blind INSERTs are not.

The full wiring — consumer cursors, poison-event handling, lag metrics — is covered in the dedicated multi-stream projection guide.

Reading an MSP table is one line:

// Read-model query via ctx.queryProjection — auto-tenant-scoped. The
// helper collapses the zero-argument "return this projection" pattern
// into a single call; returns { rows } so the response shape stays
// consistent with other list handlers.
r.queryHandler(
defineProjectionQueryHandler("revenue:list", "showcase:projection:customer-revenue", {
access: { openToAll: true },
}),
);

Any projection can be dropped and re-derived from the log:

Terminal window
bun kumiko project rebuild

The rebuild replays into a shadow table and swaps under a fence, so live reads never see a half-built state. This is the payoff of the whole model: changing a read model’s shape is a rebuild, not a data migration. Since framework#973 this includes sensitive/PII columns — event payloads carry the table ciphertext, so a rebuild reproduces every column byte-identically. The only intended divergence is crypto-shredding: after a subject is forgotten, its values are unreadable everywhere at once.

Before you rely on it, know the one rule that keeps rebuilds safe: never write an entity table directly. A row without an event cannot be replayed and would be silently dropped — CI guards and a runtime ghost-row check enforce this, and truly non-event-sourced tables (caches, cursors) belong in r.unmanagedTable instead.

Closed aggregates (a cancelled subscription, a completed order from 2022) can be archived: the stream stops replaying and its projections stop carrying the row, while the events stay in the log:

r.writeHandler(
"invoice:archive",
z.object({ id: z.uuid() }),
async (event, ctx) => {
await ctx.archiveStream(event.payload.id, { aggregateType: "showcase-invoice" });
return { isSuccess: true as const, data: { id: event.payload.id } };
},
{ access: { roles: ["Admin"] } },
);

Archived streams read as empty (loadAggregate returns []) — ops tooling passes { includeArchived: true } when it needs to look inside.

  • Consumers: an MSP consumer that hits a poison event stops after max attempts. kumiko consumer restart | skip | disable are the levers; monitor lag via getAllProjectionProgress.
  • Retention: event retention is destructive and interacts with consumer lag — a consumer that hasn’t caught up pins retention.
  • Idempotent side effects: async handlers may re-run. Anything that sends email or calls external APIs needs its own dedup.
  • Big sweeps: streamAllEventsByType iterates the log memory-bounded — use it for exports and audits instead of loading everything.
  • Sensitive data: sensitive: true fields must be ciphertext-at-rest (pii/encrypted — boot-validated). See how events work and crypto-shredding.

That’s the whole ladder: r.crud for nouns, events and projections for verbs, upcasters and snapshots for time, MSPs and rebuilds for scale. Each level stands on the ones below it — and the log underneath never forgot a thing.