Noted 2026-10-29 (STA-1475, batch stackra-333, gap cdc11 of the data-layer analysis): this ADR is
the event-sourcing rejection record the gap asked for, so no separate ADR is added (data-layer
decision 12 puts it here). Decision 6 is read with what already stands in for an event store: the
AppendOnly, Versioned and HasCorrections traits of @stackra/model keep history and
corrections as rows; a Projection of @stackra/database keeps a read model of another service’s
data from its integration events, readable as of a past moment; and
ChangeFeedService.replay(consumer, from) of @stackra/outbox replays the change feed from a
point in time into one consumer. The revival trigger below holds only when none of these can
express the aggregate, and the product’s ask names that aggregate and why each of them falls
short.Reserved number: 0471, assigned to batch stackra-381 (STA-1481) for the data-layer wave 1 round of
2026-10-28.
Context
Products need denormalised read tables: an order grid that joins orders, customers and lines; dashboard rollups; a search document that changes when a child row changes. Stackra already has the parts of Magento’s indexers: watched row changes are written with the transaction and delivered after commit toChangeConsumers (@stackra/outbox,
registerChangeConsumers), and @stackra/search and @stackra/segments use them, under five seconds of lag
(0221 decision 5). What is missing is a
kind that declares a target table and its sources, maps a source change to the target rows it affects, keeps index
state and rebuilds. A Projection (0163 decision 1) is
versioned JSONB in one shared table, so it cannot be filtered, sorted or grouped fast. Postgres cannot apply row-level
security to a materialised view, and refreshing one rebuilds it whole. Event sourcing was asked about as the general
answer and weighed against the outbox, projections and the append-only traits that exist. The owner answered on
2026-10-28 (data-layer decisions 14 and 15).
Decision
- One
Indexerkind builds derived tables.class OrderGrid extends Indexer<OrderGridRow>({ key, target, sources, mode })lives in a module’smodels/group beside its target, kind base in@stackra/database, runtime in@stackra/outbox.rows(ids)builds target rows from the sources in one query;affected(change)maps a source row change to the target ids it touches.- The target is an ordinary
Modelwith theDerivedtrait, which refuses writes from anything but its indexer. It carries the scope columns and the standard row-level security policy, so aQuery,CrudService,Report(0470) or@Searchablereads it like any model. mode: "on_save"updates in the relay, within the 0221 lag;mode: "scheduled"appends ids to a backlog that a scheduler job drains in batches.- Index state (valid, invalid, working; definition hash; last drained position) is kept per indexer; a changed
definition marks it invalid. A full rebuild fills a shadow table in keyset batches and swaps it in one
transaction; a partial rebuild takes ids.
stackra index:status|rebuild|invalidateoperate them. - Sources are models of the same service. A read model of another service’s data stays a
Projection(0260).
- Rollups are indexers. A dashboard aggregate or summary table is an
Indexerinscheduledmode; there is no separateRollupkind. - No materialised view over scoped data.
CREATE MATERIALIZED VIEWover a table that carries scope columns or row-level security is refused. A materialised view is allowed only over platform-wide tables with no scope data, refreshed by a scheduler job, and its SQL needs the raw-SQL reason of 0160. - pg_ivm and pg_cron are not approved. Incremental refresh is the indexer’s job and scheduling is
@stackra/scheduler’s. - Hot read paths across modules use an indexer or the search index. Decision 4 of
0104 is widened: an index read model may be an
Indexertarget as well as the@stackra/searchindex, and a@Searchablepath through a relation is reindexed through the indexer’saffectedmapping. - Event sourcing is rejected as a platform default. State lives in tables written by actions; events are the outbox’s integration events and change feed, not the source of truth. Reasons: replay and snapshot cost, painful event migrations, erasure conflicts that would make crypto-shredding mandatory, and a poor fit with configuration over code and with row-level security on tables. The append-only, versioned and correction traits and the ledger cover the audit-grade cases. No separate command and query buses or handler classes are added either: actions are commands, queries are reads, and the pipeline is the bus.
Consequences
- Hand-written denormalisation and trigger tables go away; one kind covers grids, rollups and related-row search.
- An index table trails its sources by the relay lag (on save) or the drain interval (scheduled); a screen that must show its own write needs the consistency token planned in stackra-414.
- A changed indexer definition needs a rebuild before its table is valid again, which gen and the CLI report.
- A product that truly needs an event-sourced aggregate must ask for the opt-in in the revival trigger, not build its own store.
Enforcement
planned — gen refuses anIndexer whose sources span services or whose target lacks the Derived trait, and the
Derived trait refuses a write outside the indexer (stackra-399); a migration check refuses MATERIALIZED VIEW over a
table with scope columns and any CREATE EXTENSION pg_ivm or pg_cron. Nothing checks this today.
Revival trigger
For event sourcing only (decision 6): a product needs an aggregate whose full history is the business record and which the append-only and correction traits or the ledger cannot express. It then comes as a per-aggregate opt-in, aModel.with(EventSourced) trait over an append-only stream table with snapshots, through a new ADR.
Supersedes and sources
Supersedes: none. Amends 0104 (decision 4) and 0163 (decision 1: read models inside one service may beIndexer targets); each carries a note dated 2026-10-28. Sources: owner answers to the data-layer questionnaire,
2026-10-28 (decisions 14 and 15); notes handover/gap-analysis/data-layer/index-tables-cqrs.md (answers A and B, gaps
1, 7 and 10); Linear STA-1481.