Skip to content

Events and projections

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.

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.

When a write handler runs and ends successfully, the CrudExecutor writes two things in the same transaction:

  1. An event row on the entity’s aggregate stream (<tenantId>:<entity>:<id>).
  2. 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.

Write handler runs Event appended on the entity's aggregate stream Entity table updated and any subscribed projections Same transaction commits both succeed, or both roll back
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.

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.

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.

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.

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.
Write succeeds postSave hook fires inTransaction before commit — a throw rolls back afterCommit default — after commit, a throw only logs Audit rows, counters, dependent writes Email, SSE broadcasts, queued jobs

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.

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.

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.

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.

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.