Level 2, Events & projections
At Level 1, “the invoice was approved” would be
update({ status: "approved" }), so the fact is flattened into a field.
Level 2 stores the fact itself. Two concepts define this model:
- The event log is the source of truth: an append-only record of every
fact (
kumiko_eventsin Postgres, no Kafka, no extra infrastructure). - Your tables are projections: read models derived from those facts, disposable and rebuildable.
Level 1 already used both concepts: r.crud appends
task.created/task.updated events and maintains the entity table as a
projection. Level 2 lets you do the same with your own domain vocabulary.
All snippets on this page come from the
event-sourcing recipe, an invoice
domain with approve and pay verbs.
Define events
Section titled “Define events”An event has a name and a Zod-validated payload shape:
// Two domain events. "approved" is versioned — v1 stored `amount` as // string, v2 uses `amountCents` integer. The DECLARATIVE migration form // covers rename/default/map without an imperative function; the chain // walks the upcast on read. const approved = r.defineEvent( "invoice-approved", z.object({ amountCents: z.number().int(), approvedBy: z.string() }), { piiFields: "none", version: 2, migrations: [ { fromVersion: 1, toVersion: 2, transform: {(Ignore version and r.eventMigration for now, they are
Level 3. A first event needs
neither.)
Append events in write handlers
Section titled “Append events in write handlers”A write handler validates the command, then records the fact with
ctx.appendEvent:
});
const invoiceExecutor = createEventStoreExecutor(invoiceTable, invoiceEntity, { entityName: "showcase-invoice", });
// --- Write handlers ---
r.writeHandler( "invoice:create", z.object({ customer: z.string() }), async (event, ctx) => invoiceExecutor.create( { customer: event.payload.customer, status: "draft" }, event.user, ctx.db, ), { access: { roles: ["Admin"] } }, );
r.writeHandler( "invoice:approve", z.object({ id: z.uuid(), amountCents: z.number().int(), approvedBy: z.string(),Note what the handler does not do: it doesn’t update any table. Recording
the fact is the write; everything downstream reacts to it. The optional
headers carry ops metadata (region, A/B bucket) without polluting the
domain payload.
Derive a read model
Section titled “Derive a read model”A projection listens to events and maintains a table. This one keeps a per-invoice detail row and runs inline, in the same transaction as the event append, so the read model is never behind:
version: 2, migrations: [ { fromVersion: 1, toVersion: 2, transform: async (payload, ctx) => { const p = payload as { approverId: string }; const row = await fetchOne<{ displayName: string }>(ctx.db, approverDirectoryTable, { approverId: p.approverId, }); return { approverId: p.approverId, approverDisplayName: row?.displayName ?? `unknown:${p.approverId}`, }; }, }, ], }, );
// Single-stream projection: one row per invoice, reacts to the auto // CRUD event + both domain events. Runs INLINE in the write TX. r.projection({ name: "invoice-detail", source: "showcase-invoice", table: invoiceDetailTable, apply: { "showcase-invoice.created": async (event, tx) => { const p = event.payload as { customer: string; status: string }; await insertOne(tx, invoiceDetailTable, { invoiceId: event.aggregateId, tenantId: event.tenantId, customer: p.customer, status: p.status, amountCents: 0, }); },📄 On GitHub: samples/recipes/event-sourcing/src/feature.ts
Each apply entry is plain SQL against the projection table. If you later
want a different table shape, change the apply functions and rebuild, the
events replay, the new table appears. No data migration.
Read it back
Section titled “Read it back”Two ways to query:
ctx.queryProjection/defineProjectionQueryHandler, read the projection table like any other table.ctx.loadAggregate(id), load the raw events of one aggregate and reduce them in memory, when you need the full story rather than the current row.
What this buys you
Section titled “What this buys you”The moment “approved” is an event instead of a field update, you get: the amount and approver of every approval ever (not just the last), projections you can reshape after the fact, and other features reacting to the fact (notifications, counters) without the write handler knowing about them.
When to climb to Level 3
Section titled “When to climb to Level 3”Level 2 is where most features live. Climb when time starts to hurt: your event payloads need to change shape without breaking two years of history, or aggregates accumulate thousands of events and loading them gets slow. That’s Level 3.