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.
Multi-stream projections
Section titled “Multi-stream projections”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
applyfunctions must be idempotent — delivery is at-least-once, so an apply may run twice for the same event. Upserts andincrementCounter-style conflict handling are the pattern; blindINSERTs 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 }, }), );Rebuilds
Section titled “Rebuilds”Any projection can be dropped and re-derived from the log:
bun kumiko project rebuildThe 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.
Archived streams
Section titled “Archived streams”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.
Production checklist
Section titled “Production checklist”- Consumers: an MSP consumer that hits a poison event stops after max
attempts.
kumiko consumer restart | skip | disableare the levers; monitor lag viagetAllProjectionProgress. - 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:
streamAllEventsByTypeiterates the log memory-bounded — use it for exports and audits instead of loading everything. - Sensitive data:
sensitive: truefields must be ciphertext-at-rest (pii/encrypted— boot-validated). See how events work and crypto-shredding.
You made it
Section titled “You made it”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.