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:
[approved.name]: async (event, tx) => { const p = typedPayload(event, approved); await updateMany( tx, invoiceDetailTable, { status: "approved", amountCents: p.amountCents }, { invoiceId: event.aggregateId }, ); }, [paid.name]: async (event, tx) => { await updateMany( tx, invoiceDetailTable, { status: "paid" }, { invoiceId: event.aggregateId }, ); }, }, });
// 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.📄 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:
const next: InvoiceStateRecord = { ...state }; reduceInvoice(next as unknown as InvoiceState, evt); return next; }, { ...initialInvoiceState } as InvoiceStateRecord, // Auto-snapshot policy: once 100+ delta events pile up past the last // snapshot, the read path persists a fresh one (best-effort). Bump // snapshotVersion whenever the reducer's state SHAPE changes — a // stale-shape snapshot is then ignored and rebuilt from the log.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:
await ctx.appendEvent({ aggregateId: event.payload.id, aggregateType: "showcase-invoice", type: paid.name, payload: { amountCents: event.payload.amountCents }, }); return { isSuccess: true as const, data: { id: event.payload.id } }; }, {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.