Skip to content
Ironflow is pre-1.0: APIs can change between releases. Pin a version. What is ready →

Projections

Projections build read models from the event stream. A projection subscribes to a set of events, and a handler either maintains derived state (managed mode) or performs side effects (external mode). This document covers the two modes, then focuses on the contract every managed reducer must honor.

A natural-but-incorrect intuition is “a projection reads from an entity stream, so entity snapshots should speed it up.” The actual model is different: projections subscribe to the global ordered event log (the events table in nats_seq order), usually filtered by event name, and fold across many entity streams into a cross-cutting view. Entity-stream snapshots are the wrong input artifact for projection rebuild — they answer a different question.

Events land on per-entity streams AND the global log

Section titled “Events land on per-entity streams AND the global log”

Each write goes to two places: its entity stream (for aggregate rehydration) and the global events table (ordered by nats_seq).

Entity streams (write side) Global event log (events table)
─────────────────────────── ──────────────────────────────
nats_seq │ entity │ event
Order-123 stream: ─────────┼─────────────┼──────────────
v1 OrderPlaced 1 │ Order-123 │ OrderPlaced
v2 ItemAdded 2 │ User-42 │ UserSignedUp
v3 OrderShipped 3 │ Order-123 │ ItemAdded
4 │ Order-124 │ OrderPlaced
Order-124 stream: 5 │ Order-123 │ OrderShipped
v1 OrderPlaced 6 │ Order-124 │ OrderShipped
v2 OrderShipped 7 │ User-42 │ EmailChanged
8 │ Order-125 │ OrderPlaced
User-42 stream: ...
v1 UserSignedUp
v2 EmailChanged

Entity-stream view = vertical slice per entity. Global log view = horizontal timeline of everything.

Aggregate load uses the entity stream (+ its snapshot)

Section titled “Aggregate load uses the entity stream (+ its snapshot)”
Load Order-123 state:
┌──────────────────────────────┐
│ entity_stream_snapshots │ snapshot at v2 → { status:"open", items:[x] }
│ Order-123 @ v2 │ ──┐
└──────────────────────────────┘ │
▼
replay tail: v3 OrderShipped
│
▼
{ status:"shipped", items:[x] }

One entity. Its snapshot. Its events. Clean.

Projections read the global log, not one stream

Section titled “Projections read the global log, not one stream”

A projection like OrdersByDay doesn’t know about Order-123 specifically. It subscribes to the global log, filtered by event name:

Global log in nats_seq order:
1 OrderPlaced (Order-123) ──┐
2 UserSignedUp (User-42) │ skipped (filter: OrderPlaced only)
3 ItemAdded (Order-123) │ skipped
4 OrderPlaced (Order-124) ──┤
5 OrderShipped (Order-123) │ skipped
6 OrderShipped (Order-124) │ skipped
7 EmailChanged (User-42) │ skipped
8 OrderPlaced (Order-125) ──┤
│
▼
┌─────────────────────────────┐
│ Projection: OrdersByDay │
│ reducer(state, OrderPlaced) │
│ → state[day]++ │
└─────────────────────────────┘
│
▼
Map {
"2026-04-20": 2, ← Order-123, Order-124
"2026-04-21": 1 ← Order-125
}

The projection crossed THREE entity streams (Order-123, Order-124, Order-125) to produce one cross-cutting view. Its state is NOT any entity’s state.

Why entity snapshots don’t help projections

Section titled “Why entity snapshots don’t help projections”
Order-123 snapshot: Projection state:
{ status:"shipped", Map<date, count>
items:[x], "2026-04-20": 2
total:100 } "2026-04-21": 1
▲ ▲
│ │
└──────── DIFFERENT ─────────────┘
SHAPE
DIFFERENT REDUCER
DIFFERENT QUESTION ANSWERED

No arithmetic on the snapshot produces the projection’s map. The snapshot doesn’t remember “which day this order was placed on” — it only remembers current status. The projection needs the raw OrderPlaced events, in order, across all orders.

Single-entity projections still need the log

Section titled “Single-entity projections still need the log”

Even when a projection happens to scope to one entity (e.g. OrderHistoryView for Order-123):

Order-123 snapshot: { status:"shipped", items:[x], total:100 }
Projection "OrderHistoryView" wants:
[
{ at: "2026-04-20T10:00", what: "OrderPlaced" },
{ at: "2026-04-20T10:05", what: "ItemAdded" },
{ at: "2026-04-20T10:30", what: "OrderShipped"}
]
Snapshot has no history. It has one moment. Projection needs the timeline.
  • Entity streams answer “what is the state of THIS entity right now” → use entity snapshots.
  • Projections answer “what is the derived/aggregated view across MANY entities over TIME” → must read the event log. Entity snapshots are the wrong input artifact.
  • This matches the canonical CQRS/ES pattern (EventStoreDB $all, Axon TrackingEventProcessor, Marten, Kafka/Flink materialized views): commands → entity streams → global ordered log → projections subscribe + fold.

Managed mode — the handler is a pure reducer: (state, event) -> newState. Ironflow stores the state, routes reads (getProjection, subscribeToProjection), and owns the cursor. Use managed mode for anything a UI or API reads.

External mode — the handler performs side effects (send email, call webhook, write to an external DB). Ironflow tracks the cursor but stores no state. Return value is void. Use external mode whenever the effect of processing an event is not “update some state I want Ironflow to hold.”

Mode is auto-detected from whether an initialState factory is provided, or set explicitly via mode: "managed" | "external".

Ironflow applies managed reducers under at-least-once delivery. The engine never sends an event at or below the runner’s delivery position, so a rebuild replay (reading from the events table) that overlaps the live NATS tail does not apply an event twice. A runner that fails before it saves a batch still gets that batch again, and so does the next lease holder after a crash. Correctness depends on the reducer being deterministic and idempotent. No runtime enforcement catches a violation; it surfaces as read models that disagree between nodes, or that diverge from the event stream after a rebuild.

A managed reducer must satisfy all four rules below.

Same (state, event) must produce the same newState, every invocation. Ironflow may call your reducer twice with identical arguments and expect identical output.

Banned inside a managed handler:

  • Clocks — time.Now(), Date.now(), new Date() with no args, performance.now(). The event carries event.timestamp; use that.
  • Randomness — Math.random(), crypto.randomUUID(), uuid() in Go. If you need an ID, derive it from the event (event.id, event.data.orderId, etc.).
  • Environment reads — os.Getenv, process.env.*, reading from a file, reading from a DB.
// WRONG — non-deterministic: rebuild produces different state than live
handler: (state, event) => ({ ...state, lastSeen: Date.now() })
// RIGHT — deterministic: derive from the event
handler: (state, event) => ({ ...state, lastSeen: event.timestamp })

No network calls, no DB writes, no console.log side effects. If you need a side effect, split the projection: a managed projection for the state, an external projection for the effect.

// WRONG — side effect in a managed handler
handler: async (state, event) => {
await sendSlackNotification(event);
return { ...state, count: state.count + 1 };
}
// RIGHT — split
const stats = createProjection({ /* managed, pure */ });
const notifier = createProjection({ mode: "external", handler: async (e) => sendSlackNotification(e) });

Return a fresh state object. Do not rely on the caller’s copy being isolated from anything you retain. The Go SDK performs a JSON deep-copy of state before each invocation (#486 I3) — this neutralizes the sharpest aliasing hazards, but it does not make in-place mutation safe as a pattern, and it does not apply to the JS SDK.

// WRONG — mutates and returns the argument; leaks if runner ever retains the ref
handler: (state, event) => { state.count += 1; return state; }
// RIGHT — return a new object
handler: (state, event) => ({ ...state, count: state.count + 1 })

The same event may be applied multiple times: during the PG-rebuild vs. live-tail overlap window (#486), after a NATS re-delivery, after a node failover. Handler output must be invariant under replay.

  • Counters: prefer state.ordersByID[id] = order (set-like) over state.count += 1 (accumulator). The set-like form survives replay; the accumulator over-counts.
  • If you must accumulate, key the accumulation to the event: if (!state.seen[event.id]) { state.count += 1; state.seen[event.id] = true }.
// WRONG — double-counts on replay
handler: (state, event) => ({ ...state, total: state.total + event.data.amount })
// RIGHT — idempotent via event ID
handler: (state, event) => {
if (state.applied[event.id]) return state;
return {
...state,
applied: { ...state.applied, [event.id]: true },
total: state.total + event.data.amount,
};
}

For most business cases the cleaner fix is a keyed map (state.orders[event.data.orderId] = ...) — replay then overwrites with the same value and is naturally idempotent.

Multi-node deployments running the same projection on overlapping partitions can lose updates on projection_state without a single writer. Ironflow does not guard those writes with optimistic concurrency (CAS on a per-row version counter); the UPSERT only enforces a monotonic last_event_seq. Two nodes reading the same cursor and writing different events race like this:

node-A reads state(cursor=9)
node-B reads state(cursor=9)
node-A applies E10, writes state(cursor=10)
node-B applies E11, writes state(cursor=11) ← E10's state contribution lost

Rule 4 (idempotency) does not prevent this race. Rule 4 keeps replay of the same event safe; the race above loses a different event (E10) by advancing last_event_seq past it, so E10 is never re-applied. Both reducer shapes are affected — projection_state is written as a full blob, so the final UPSERT overwrites everything the other node contributed (whichever event the final writer didn’t see is lost, keyed-map or accumulator alike).

Rule 4 is still required; it’s orthogonal to this race and covers replay and redelivery.

The runner lease prevents this race (ADR 0120). Each SDK projection runner must hold the lease of its projection. One replica holds it and the other replicas wait as standbys. The server refuses a pull, a save or an ack without the lease (failed_precondition, “projection lease required”), and from a runner whose lease it fenced (aborted, “projection lease lost”). The Go, Node and Python runners get the lease for you. A runner on a server older than v0.44.0 stops with SERVER_TOO_OLD, because that server has no lease RPCs. The runner stays stopped until its process restarts, and a proxy that answers 404 for the lease RPC causes the same stop.

To upgrade, first upgrade every runner replica to an SDK at v0.44.0 or later, then upgrade the server. A runner on an older SDK sends no lease, so a server at v0.51.0 or later refuses it.

External handlers have side effects; they cannot be “pure” in the reducer sense. However, they still run under at-least-once delivery, so the side effect itself must be idempotent. Use idempotency keys on the external API (see Non-idempotent step code in the pitfalls page), or look up the external resource before writing.

Rebuilds ship with metrics, trace spans, and environment-variable knobs so operators can watch progress and shed database load (issue #495).

Metrics (emitted only while a rebuild is in flight):

  • ironflow_projection_rebuild_active{projection, environment_name, environment_id} — gauge, 1 per projection currently rebuilding. Sourced from a scrape-time collector against projection_registry, so the gauge stays consistent across process crashes and cluster-wide handoffs.
  • ironflow_projection_rebuild_events_applied_total{projection, environment_id, phase} — counter incremented after each successful PG-source batch. Use rate() to compute events/sec.
  • ironflow_projection_rebuild_duration_seconds{projection, environment_id, result} — histogram observed on terminal transition, labelled with result ∈ {completed, cancelled, failed}.

A fourth metric, ironflow_projection_rebuild_collector_errors_total, is a counter without labels. It counts cumulative scrape-time failures of the query behind the _active gauge. An increase records a failed query, which omits the gauge from that scrape. The counter stays non-zero after recovery.

The first three carry environment_id. Group and join on that ID: environment names are unique only within a project. The scrape-time _active gauge also exposes the actual environment_name from the environment table. The counter and histogram omit names because deriving a suffix from an immutable ID does not reliably identify the current name. projection_registry is keyed by (name, environment_id), so equal projection names in different environments remain distinct.

Tunables (read once at startup — restart to change):

VariableSQLite defaultPostgres defaultMeaning
IRONFLOW_REBUILD_BATCH_SIZE_SQLITE500—Events per PG pull during rebuild
IRONFLOW_REBUILD_BATCH_SIZE_POSTGRES—1000Events per PG pull during rebuild
IRONFLOW_REBUILD_BATCH_PAUSE_MS00Inter-batch sleep; only applied to full batches

During rebuild, the server overrides the SDK-supplied batch_size with the tunable so throughput stays deterministic (batch_size / pause_ms) regardless of SDK configuration. Outside rebuild, the SDK value is used as before.

On an unrecoverable PG error during a rebuild (e.g. ListEventsByNatsSeq fails), the rebuild scan calls FailRebuild — rebuild markers cleared, status flipped to error, error_message populated. No retry loop; reads stay refused until the operator re-triggers it once the underlying issue is fixed.

A rebuild reads nothing while its environment has an events outbox row from before the rebuild start that is still pending, or an events dead letter whose event has no nats_seq. Such an event may already be in NATS at or below the rebuild target, and reading past it would drop it from the read model. If the gate stays closed for 15 minutes, the rebuild fails with an outbox error. Check NATS and the outbox worker, requeue the dead letter (ironflow outbox dlq requeue <event-id>), then rebuild again.

Discarding such a dead letter does not open the gate. The discard fails every rebuild in flight in that environment whose start could see the row: each goes to error with a message that names the event and says to run the rebuild again, the same way a gate timeout ends it. The discard itself succeeds, and the event keeps no nats_seq, so no later rebuild reads it either, even when live consumers saw it. Requeue an events dead letter you want in your read models; discard it only when every projection can do without that event.

Rebuild has a horizon when event retention is on

Section titled “Rebuild has a horizon when event retention is on”

IRONFLOW_EVENT_RETENTION_DAYS deletes non-entity events, which is exactly the row set a projection filtered by event name replays. A deleted row leaves nothing behind, so the prune records each deleted name with its highest event sequence. A rebuild whose replay range reaches the record of a name its filter matches is refused — including --dry-run — rather than replaying a history with a missing prefix and reporting success. Changing the window does not lift the refusal, because the events are still gone. A filter that has lost no event rebuilds at any age. A rebuild with no --from starts at the beginning of history, so it is refused once its filter has lost an event. The error names an event to rebuild from when one exists, and --from <event-id> with it proceeds against the shortened history.

A projection whose filter matches only entity events is not affected. A filter that matches a recorded name gets a full rebuild refused even when entity events survive. Deletes from before that record existed left no trace, so a filter that lost events only then is not refused, and its rebuild can report success with them missing. A prune that takes an event a running rebuild has not read fails that rebuild.

When the record shows a loss and the environment holds no published event, --from has nothing to name, so the question becomes what the rebuild would destroy. A projection with state, a cursor above 0, a committed event or a recorded error is refused, because the rebuild would discard its read model and report success. The error names the recovery: delete the projection and register it again. That is not a clean restart, because it reads the PUBSUB stream from its first retained event and can still miss older history. A projection with none of those has nothing to lose and completes with no work, whether the environment never had an event or the projection was registered after the prune.

The engine drives SQL projections. No worker polls for them. Every node keeps one consumer loop for each SQL projection, and the loop keeps a long-poll pull open on the projection’s durable consumer. A row appears soon after the event reaches the stream.

Ownership. SQL consumers use a NATS pinned-client group. NATS sends events to one loop only, and it moves the pin when that loop stops pulling. The consumer allows one unacknowledged message, so events apply in stream order. The transactional outbox keeps the order of one entity’s events on one node only. Plain Emit events, and events from different entities, can reach the stream in a different order than you emitted them. Write handler SQL that gives the same result in either order: let each event upsert only its own columns, for example paid_at for order.paid. An upsert that sets a shared column, such as a status, from the event still depends on arrival order.

Fence. A loop claims a new owner epoch in projection_registry after it confirms its pin. Each event applies in one transaction: epoch check, skip if the sequence is at or below the cursor, handler SQL, cursor update. A loop that lost its pin cannot write.

Failover. The pin lasts 15 s without a pull. An unacknowledged message returns after 30 s. A loop extends a held message for at most 2 minutes. Then it stops pulling, so another node can take over.

Stream binding. A SQL projection records the PUBSUB stream it was created against. When that stream is recreated, the new stream restarts its seqs at 1, and new events reuse seqs at or below the projection’s cursor. The driver therefore compares the recorded stream with the current one before it applies a message or a rebuild page, before it completes a rebuild, and on every reconcile tick, so it notices within 30 seconds even when no message arrives. On a mismatch the projection stops: the status becomes error, the message names the recovery, and nothing more is applied. A rebuild cannot recover this, and is refused. Run ironflow projection delete <name>, then ironflow projection create with the same definition. The new read model holds the current stream’s retained events only. A SQL projection created before this binding existed has no recorded stream and stops the same way, because nothing tells which stream its cursor counts.

Rebuild. A rebuild clears the table and claims a rebuild: token in one transaction. A loop on any node then reads the events from PostgreSQL in batches. A cancelled rebuild stops at its next batch and sets status error, because the read model is partial. Any node can cancel it, not only the node that started it. A rebuild that failed or was cancelled stops the projection: no live event applies, and queries answer failed_precondition with the failure until a new rebuild starts or the projection is deleted and created again. While a rebuild runs, queries answer from the table as it fills. The next rebuild scans at least as far as the one that ended, because its consumer has acked those events and NATS will not send them again. A projection marked error by a poison event keeps reading and applying. One node drains at a time, under a lease on the registry row that it renews from a heartbeat and with each batch; when that node is lost and no transaction blocks the registry row, the lease and reconcile timing make its drain eligible for takeover in about 90 seconds, plus any rebuild batch pause. Lease expiry does not release the row lock of an unfinished batch transaction. Recovery of that transaction depends on database or pooler lost-client detection, configured timeouts, or operator termination. A claim skips a locked row so it does not hold a slot needed by another projection. A failed read from PostgreSQL is retried. Pause, resume and cancel never rewrite the cursor. A rebuild that completes during a pause stays paused. If a handler failed during the rebuild, the status is error.

Metrics. Four counters track the driver. ironflow_sql_projection_events_applied_total counts applied events. ironflow_sql_projection_apply_errors_total{kind} counts errors by handler, parse, db or nats. ironflow_sql_projection_fenced_total counts writes that the fence rejected. ironflow_sql_projection_ownership_claims_total counts epoch claims. A fast climb of the last counter means the pin is flapping.

To create one, see SQL projections. See also ADR 0119.

The SDK projection runner prefers the StreamProjectionEvents RPC (long-lived SSE/ConnectRPC server stream) over PollProjectionEvents. Streams can sit idle for minutes in quiet environments, which used to cause intermediaries (proxies, LBs, or Node’s default fetch socket idle timer) to close the connection — producing spurious TypeError: fetch failed reconnect logs every ~60–90s (issue #550).

When the client sets accept_heartbeats = true on the request (every SDK runner does), the server emits ProjectionEvent frames with kind = PROJECTION_EVENT_KIND_HEARTBEAT every ~15 seconds. They keep the socket warm, and their seq carries the stream’s watermark (see Delivery position). Clients that don’t opt in see no heartbeats.

Every poll and stream request on a managed or external projection carries the runner’s delivery position (ADR 0164). Every event of the request’s scope at or below it is accounted for under the current lease: the runner saved or acknowledged it, or the server reported it as consumed without delivery (another partition, or a malformed message). A request without a position is failed_precondition.

The engine never sends an event at or below max(position, cursor), nor one it already sent or consumed in the same stream. For a partitioned managed projection, it also skips an event at or below its partition’s saved seq, which keeps a partition rebuild from replaying the other partitions. It never writes the cursor on the runner’s behalf: the cursor moves only by a Save or an Ack, and a rebuild start lowers it.

The runner rule is short:

  • Send 0 on a new lease.
  • After a whole poll response is committed, raise the position to its last_event_seq. An empty page still raises it.
  • On a stream, the first frame is PROJECTION_EVENT_KIND_OPEN. Raise the position to each event’s seq, and to a heartbeat’s seq, only after every frame received before it is committed.
  • Never lower it. A failed batch leaves it where it was, so the next request gets the batch again.

A poll response without position, or a stream that does not open with an OPEN frame, comes from a server older than 0.55.0. The SDK runners then stop with SERVER_TOO_OLD before they apply anything.

Rebuilds. While a rebuild runs, the engine reads the events table above the session’s floor. It completes the rebuild only when that scan is empty, the outbox gate is open, and the runner has committed every event the session sent. A stream sends each rebuild event once and does not wait for a save between pages; completion waits instead. When a rebuild completes or is cancelled, the engine moves the durable to the floor once, and NATS delivers the rest of the rebuild range. A rebuild that fails refuses reads instead. A cancel skips nothing the PUBSUB stream still holds; events older than its retention are not replayed, so the read model is then partial and needs a new rebuild. The cursor ends at the last event the runner committed, which can be below the rebuild target.

Partitions. A partition filter works on managed projections only. An external projection has one cursor and no per-partition state, so a partition-filtered poll or stream on one is failed_precondition, and so is a partition rebuild.

Durable moves. At the start of every poll and stream, the engine recreates the durable at the floor when it acknowledged events the runner does not hold, when it has unacknowledged deliveries, or when it is far behind the floor. A recreate that fails fails the request with unavailable, and the runner retries.

A recreated stream. A NATS stream recreated beside a surviving database, as after a wiped embedded-NATS data dir, restarts its seqs at 1, and its new events reuse seqs that the database already counts. Seqs alone cannot show this: a cursor of 1000 on a stream at 2000 means “deliver from 1001” if this stream committed 1 to 1000, and “nothing here is safe” if a stream that no longer exists did.

  • Each SDK projection is bound to one stream, the PUBSUB stream’s creation time when its registry row was inserted. Registering again, a rebuild and a restored durable never change it. NATS keeps the creation time across a restart on the same data dir and sets a new one on a recreate.
  • Every page is checked after it is read: a projection bound to another stream is refused with failed_precondition, whichever session pulled the page. Managed and external projections are refused alike. An external projection’s cursor is the only record of what its side effects covered, and the old stream’s undelivered events are gone, so reading on would hide them. The engine delivers and saves nothing, and the SDK runners stop.
  • A projection whose floor or a page partition’s saved seq is above its own stream’s last seq is refused the same way, as after a restore from an older snapshot.
  • A rebuild is refused before it deletes the state. The events table keeps the old stream’s seqs beside the new stream’s, so its scan would mix them. Run ironflow projection delete <name> and restart the runner, which registers the projection again, bound to the current stream. Its read model then holds the current stream’s events only.
  • A projection registered before the engine recorded the stream has no binding and is refused until it is deleted and registered again.

Rebuilds after a recovery. The events table still holds the old stream’s rows after that recovery, and rows carry no stream identity. A rebuild, of an SDK or a SQL projection, is refused before it deletes the state when two of the environment’s events hold one seq, or an event holds a seq above the stream’s last seq: one stream gives each event its own seq, so either shows another stream’s rows. The projection keeps its read model and reads on from the current stream. To replace the read model, delete the projection and restart its runner again, or create the SQL projection again; the new read model holds the current stream’s retained events only. An event whose outbox mark is still pending has no seq yet, so the start cannot see the seq it will share. The rebuild’s scan waits for that mark and then checks the rows it reads, so a shared seq fails the rebuild instead of completing it: the status becomes error, and by then the start has deleted the state, so do not trust the read model. A rebuild is not refused when an old row sits at a seq that the current stream gave to a message with no row in this environment, such as another environment’s event or a pub/sub message. That rebuild reads the old event as the current stream’s.

A failed rebuild refuses reads. Whatever fails an SDK projection’s rebuild, its start has already deleted the state, and what it failed on can sit behind the cursor, where no later read reaches it. So every Poll and Stream answers failed_precondition with the reason, across restarts, until a rebuild succeeds or the projection is deleted, and the runners stop. A client’s read of a managed projection’s state and every catch-up wait answer the same until a new rebuild starts. The reason says which: a stuck outbox needs the outbox fixed and another rebuild, and rows of two streams need the recovery above. To rebuild, start the rebuild and then restart the runner, because a rebuild runs through the runner’s reads. A rebuild with --to or --partition is refused, and cancelling a rebuild brings the refusal back. Other errors keep their behavior: a cancel with no failure behind it, or any error status that no failed rebuild set, keeps reading. A SQL projection differs on the cancel: its consumer’s acked events stay acked, so a cancel leaves a partial table and refuses queries like a failure.

A restore of the NATS data alone is unsupported. Back up and restore the database and the NATS data together, from one point in time with the engine stopped. A restore of the NATS data alone keeps the stream’s creation time, so the binding still matches, and new events take the seqs of the events the restore lost. The engine records the highest event each runner committed, and on every page that holds events it checks that the stream still holds that event at that seq. A projection whose runner committed an event the restore lost is refused the same way, and recovers the same way. A SQL projection has no runner, so it records no such event and the engine cannot see the restore. A projection whose runner committed nothing past the backup is not refused, and the events the restore lost are never sent to it: they stay in the events table and are missing from the stream. The only recovery for those events is to restore the database and the NATS data together from one backup.

External projections stay at-least-once to their side effects: a crash between the side effect and the Ack repeats the effect, so the handler must be idempotent.

When registration changes an existing projection definition, Ironflow archives the persisted old definition in the same database transaction as the update. Identical registration and initial creation add no revision. Actor and optional change reason identify the mutation that displaced the old definition.

Terminal window
ironflow projection history orders_by_customer --json
ironflow projection history orders_by_customer --revision 1 --json

Revisions start at 1 and lists return newest first. Each environment and projection name has an independent history. Snapshots contain the stored version, events, partition key, mode, type, SQL DDL, SQL handlers, and description. Runtime status, cursors, and rebuild fields are excluded. SQL history preserves the stored text; it does not restore physical tables or capture SDK reducer source code.

History survives projection deletion and ordinary event retention. Deletion itself does not archive another definition. There is no backfill for earlier changes or a rollback command. See the CLI reference for pagination and exact revision reads.