Events and projections
In plain words
Section titled “In plain words”Kumiko records every accepted change as an event, like a permanent entry on a bank statement. The tables used by lists and detail screens are read models built from those events. If a read model is wrong or its schema changes, Kumiko can rebuild it from the history.
The event log also supports point-in-time reads and prevents silent concurrent overwrites through row versions. Because events are permanent, personal and sensitive values need explicit encryption rules. Some read models update in the write transaction; cross-entity summaries may catch up after commit.
Why it exists
Section titled “Why it exists”Kumiko writes every state change as an event in an append-only log before it updates any read model. The event log is the source of truth; the entity tables and custom projections are derived from it. You never read the event log directly. You read the projections, but the log is what survives. Drop a projection table, replay the events, and the data is back.
This is the same model Marten follows on .NET: a single Postgres database, events written in the same transaction as their projections, and a clear separation between the single-stream “this is the row” projections and the multi-stream “this is a view across many things” projections.
What gets written on a write
Section titled “What gets written on a write”When a write handler runs and ends successfully, the CrudExecutor writes two things in the same transaction:
- An event row on the entity’s aggregate stream (
<tenantId>:<entity>:<id>). - The corresponding update to the entity table (and any projections that subscribe).
Either both succeed or both roll back. There is no window in which the event log shows a change that did not happen, and no window in which the entity table shows a change that no event records.
const incidentResolved = r.defineEvent( "incident-resolved", z.object({ resolution: z.string(), resolvedAt: z.iso.datetime(), }), { piiFields: "none" },);
const resolveIncident = defineWriteHandler({ name: "resolve", schema: z.object({ id: z.string(), resolution: z.string() }), access: { roles: ["Admin", "OnCall"] }, handler: async (event, ctx) => { const resolvedAt = Temporal.Now.instant().toString(); await emitEvent(ctx, incidentResolved, { aggregateId: event.payload.id, aggregateType: "incident", payload: { resolution: event.payload.resolution, resolvedAt, }, }); return { isSuccess: true, data: { resolvedAt } }; },});
r.writeHandler(resolveIncident);emitEvent(ctx, eventDef, { aggregateId, aggregateType, payload, headers? })
appends a domain event and checks its payload against the EventDef that
r.defineEvent returned, so no code generation step is needed. The default CRUD handlers append event
types from entityEventName(entity, verb), currently <entity>.created,
.updated, .deleted, .restored, and .forgotten. Domain events for actions
such as resolving an incident are declared with
r.defineEvent(name, schema, options). The
piiFields option explicitly states "none" or maps personal fields to their
subject.
What an event carries
Section titled “What an event carries”Every event has a typed payload validated by its registered schema plus a fixed
envelope: tenant ID, aggregate ID and type, event type and version, actor
metadata, and time. Generated entity update and delete events also carry
previous, the state before the change.
Cross-aggregate projections need it. A counter that tracks “units per property”
needs to decrement the old property and increment the new one when a unit is
moved. Without previous in the event, that’s an extra database read in every
projection apply, and it can race. Kumiko pays the storage cost so that
projections remain a pure function of the event stream.
Sensitive fields and the immutable log
Section titled “Sensitive fields and the immutable log”Append-only is useful for audit and replay, but personal data and reusable secrets need an explicit storage decision. A password hash is a one-way credential value and may stay plaintext-at-storage behind privileged access; reusable tokens or personal fields need the appropriate encryption path.
Mark the field sensitive: true and Kumiko stores it as ciphertext in
every event payload: the same encrypted bytes the entity table holds. Boot
validation enforces that a sensitive field also declares ciphertext-at-rest
(pii / userOwned / tenantOwned for a per-subject key, or
encrypted: true for the master key); plaintext never reaches the immutable
log. Three consequences follow:
- Replay reproduces sensitive columns byte-identically: rebuilds lose nothing.
- Erasure still works: forgetting a subject destroys its key (crypto-shredding), and every historical ciphertext for that subject becomes unreadable: in the log and in the table.
- Write responses still exclude sensitive fields from the event echo, so API clients never see the value through that channel.
This gives replay the stored ciphertext while keeping caller-facing responses redacted. When the field uses a subject key, forgetting that subject makes the historical ciphertext unreadable too. One-way credential hashes use their own privileged-access policy and are not crypto-shredded automatically.
Two kinds of projection
Section titled “Two kinds of projection”A projection is a read model fed from events. Kumiko distinguishes two varieties because they have different transactional contracts.
Single-stream projections (r.projection(def)) run in the same transaction
as the event append. Their event row,
the entity row, and the projection row all live or die together. The
projection’s apply function gets the event and the transaction-scoped
database connection, and writes are typically INSERT … ON CONFLICT DO UPDATE
so two concurrent events for the same key cannot race.
r.projection({ name: "units-per-property", source: "unit", table: unitsPerPropertyTable, apply: { [entityEventName("unit", "created")]: async (event, tx) => { const propertyId = event.payload.propertyId; await tx.insert(unitsPerPropertyTable) .values({ propertyId, tenantId: event.tenantId, unitCount: 1 }) .onConflictDoUpdate({ target: unitsPerPropertyTable.propertyId, set: { unitCount: sql`${unitsPerPropertyTable.unitCount} + 1` }, }); }, },});If apply throws, the transaction rolls back. The event is not in the log;
the entity update did not happen; the projection table is unchanged. The
write call returns the error to the user. There is no inconsistent half-state
to clean up.
Multi-stream projections (r.multiStreamProjection(def)) run after the
transaction commits, asynchronously, driven by a database cursor. They listen
to event types from many entities and aggregate them in a view that crosses
aggregate boundaries: “revenue per customer across all invoices and refunds”,
“open incidents per service across multiple component streams”. Delivery may
retry an event after a crash before cursor advancement, so apply() must be
idempotent.
The trade-off is explicit: single-stream projections are consistent and synchronous (zero lag), multi-stream projections cost a network roundtrip and can be a few hundred milliseconds behind the write. Pick based on whether your read needs to be consistent with the write that triggered it, or whether eventual consistency is fine for the use case.
Two-phase hooks
Section titled “Two-phase hooks”The same dual contract appears in lifecycle hooks. A postSave hook runs in
one of two phases:
inTransaction: runs before commit, in the transaction. Throwing here rolls everything back. Use this for derived database writes that must stay atomic with the change: audit rows, denormalised counters, dependent entity updates.afterCommit(the default): runs after commit, outside the transaction. Throwing here is logged but does not roll back. Use this for external side effects: sending email, pushing SSE updates, enqueueing jobs, posting webhooks.
The reasoning is the dual-write problem: an email cannot be unsent, an SSE event cannot be un-broadcast, a Bull queue job already scheduled cannot be un-scheduled. If those run inside the transaction and the transaction rolls back, you have a side effect that should not have happened. Kumiko forces you to pick the phase explicitly so that the question “is this rollback-safe?” is answered at write time, not after the first incident.
Optimistic locking, automatically
Section titled “Optimistic locking, automatically”Every entity carries a version column. Updates run with
WHERE version = expectedVersion; if the row’s version moved on between read
and write, the write fails with version_conflict. The handler does nothing
special: the framework writes the predicate, and the renderer shows a default
“this record changed, reload?” dialog when the error reaches the browser.
Two parallel edits with the same starting version are impossible. The first write wins, the second sees the conflict and is told to reload. There is no silent overwrite.
Replay and rebuild
Section titled “Replay and rebuild”Because the event log is the source of truth, a projection is disposable.
bun kumiko project rebuild <name> replays every event in order through
apply(). The rebuild path uses a shadow table and swap so readers keep the
previous projection until the new one is ready. If replay fails, the previous
projection remains available.
This matters more than it sounds:
- A projection schema change (add a column, change a derivation) becomes a rebuild, not a migration.
- A projection bug becomes a fix-and-rebuild, not a data archaeology project.
- A new projection added six months after the events landed gets the historical data for free.
The events are the contract; the projections are caches. As long as the events
remain stable (and r.defineEvent(..., { migrations }) covers the cases where
they cannot),
you can rebuild any read model from any point in history.
What this gives you
Section titled “What this gives you”The append-only-log choice is not “for audit purposes”: audit is a side effect. The real outcomes are:
- Atomic dual writes: event and projection commit together, no out-of-sync windows.
- Disposable read models: projection schema is not a migration target; it is a function from events.
- Time travel: replay events up to a timestamp and see the world as it was. Useful for debugging, mandatory for some compliance regimes.
- No silent overwrites: optimistic locking is the default, not an opt-in.
The cost is bookkeeping: events have envelopes, generated update and delete payloads include previous,
sensitive fields need flagging, and writes are slightly more expensive than a
plain UPDATE. For an app you intend to live with for years, that is the right
side of the trade.
Live example
Section titled “Live example”A single-stream projection: one row per invoice, fed by the auto CRUD event plus two declared domain events, all in the same write transaction:
// AsyncOnlyEventUpcaster pattern — DB enrichment without rewriting the // event log. const acknowledged = r.defineEvent( "invoice-acknowledged", z.object({ approverId: z.string(), approverDisplayName: z.string() }), { piiFields: { approverDisplayName: { subjectField: "approverId" } }, 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",Full source: samples/recipes/event-sourcing: also covers
multi-stream projections, custom event types, and rebuild.
See also
Section titled “See also”- Commands and queries: how a write handler ends up appending an event in the first place.
- Lifecycle and hooks: boot-time validation
and the contract for
inTransactionvsafterCommithooks.