Decision records
ADR 0049 — Projections fold on demand: event-type-gated lazy handler chunks
- Status: Accepted (implemented 2026-07-13) — compiler +
@loomwork/projection-runtime+@loomwork/app(PR #155). - Date: 2026-07-13
- Builds on: ADR 0044 (the compiler’s
lazy-chunk output contract for
handler$/instructions$), ADR 0011 (folder-convention discovery).
Context
projection$ produced a ProjectionDefinition that nothing consumed: the projection runtime
folded a different, incompatible type (Projection<TState>), and there was no adapter — so
projection$ was exported but dead, and shadowed by the working surfaces mechanism. We wanted to
make it real and give it the same on-demand code loading that handler$ enjoys (QwikJS-style): a
projection’s fold code should not be parsed/loaded until it is actually needed.
The design fork was when a projection’s import() chunk loads, given the runtime folds
synchronously in handleEvent:
- Observe-gated (truest on-demand): stay fully dormant until first
getState/subscribe, then rebuild state from the log. Closest analog tohandler$, but couples the storage-agnostic projection runtime to the event store and makes activation async. - Event-type-gated: record the handled event-type keys at compile time; load the chunk on the first matching event, buffering until ready. Keeps the runtime storage-agnostic and the fold hot path synchronous.
- Register-time code-split: load at boot. Bundle-splitting only, not per-use laziness.
Decision
Event-type-gated on-demand loading.
-
Compiler —
tryProcessProjectionextracts aprojection$’s inlinehandlersobject literal into its ownimport()chunk (exactly likehandler$/instructions$), records the handled event-type keys (read statically from the property names), and rewriteshandlersto aProjectionHandlersRef({ _loomProjectionHandlersRef, eventTypes, load }). The manifest gains aprojectionssection. Extraction only happens when every key is statically named (PropertyAssignment/method with an identifier/string name); a computed/shorthand/spread key leaves the projection eager (correct, not code-split) rather than emit incompleteeventTypesor a broken chunk. -
Runtime —
Projection<TState>gains alazy: { eventTypes, load }form.handleEventgates oneventTypeswithout importing the chunk; on the first matching event it imports once, buffers that + subsequent matching events, drains the buffer in arrival order, then folds synchronously thereafter. A projection whose event types never fire never loads its code. -
@loomwork/app—defineConfigadaptsProjectionDefinition → Projection(a ref becomes an event-type-gatedlazyprojection; an inline map stays eager) and addsprojections+projectionModulesconfig keys;loom gendiscoversapp/projections/*.ts.
Uncompiled (dev / no plugin), handlers stays inline and eager — the same dev-vs-compiled duality as
handler$.
Consequences
projection$is no longer dead code and is a genuine on-demand read-model: an app with many projections (e.g. a dashboard of widgets) only loads the fold code for projections whose events actually occur.ProjectionDefinition.handlersis now(state, event: EventEnvelope) => state(was(state, payload)), matching the runtime one-to-one — a safe change sinceprojection$had zero callers.- The projection runtime stays storage-agnostic and its fold hot path stays synchronous; no log-replay/async-activation was introduced. We accepted a small buffering window during the one-time load instead.
- Robustness: a permanently-failing chunk load abandons the projection (frees its buffer, warns once, stops retrying) rather than leak an unbounded buffer or re-import on every matching event. The read-model is rebuildable from the log.
- Not chosen: observe-gated. If after-the-fact/historical projections (observe a long-completed execution) become a first-class need, that path can be added later — it is strictly more machinery.
Source: docs/adr/0049-projection-on-demand-fold-code.md in the loom repository.