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

ironflow projection

Manage projections: create, list, inspect, pause, resume, delete, rebuild, and wait for catch-up.

Terminal window
ironflow projection <subcommand> [flags]

List all registered projections with their mode, status, and event count.

Terminal window
ironflow projection list [flags]

Flags:

FlagShortTypeDefaultDescription
--server-sstringServer URL override
--jsonboolfalseOutput as JSON

Output:

NAME MODE STATUS EVENTS LAST SEQ UPDATED
orders-by-customer managed active 3 1523 2m ago
inventory-tracker external rebuilding 2 0 15s ago

Inspect definitions archived before a registration changes them. Revision 1 is the first preserved old definition. Initial creation and identical registration create no revision. Runtime status, cursors, and rebuild progress are excluded.

Terminal window
ironflow projection history <name> [flags]
FlagTypeDefaultDescription
--revisionint64Retrieve one positive revision as full JSON
--limitint3250Page size, maximum 200
--before-revisionint64Exclusive cursor for older revisions
--jsonboolfalseFull response as JSON
--server, -sstringServer URL override

Lists run newest first and show the next cursor when another page exists. JSON includes entries, has_more, and next_before_revision. Do not combine --revision with either pagination flag. A missing exact revision is an error; an empty list means no definitions have been archived.

The text output is a table with the columns REVISION, VERSION, RECORDED AT, CHANGE, ACTOR and REASON. With no archived definition it prints No definition history found.. When another page exists, the last line is Next page: --before-revision <n>.

Terminal window
ironflow projection history orders_by_customer --limit 10
ironflow projection history orders_by_customer --before-revision 12 --json
ironflow projection history orders_by_customer --revision 3 --json

Existing history remains readable after deletion, but deleting a projection does not capture another revision. Changes made before this feature cannot be recovered. SQL snapshots preserve stored DDL and handlers; reading history does not restore physical tables or SDK reducer code. History uses the selected environment and requires projections:read permission.

Show detailed status for a projection, including rebuild progress if one is running.

Terminal window
ironflow projection status <name> [flags]

Arguments:

ArgumentRequiredDescription
nameYesThe projection name

Flags:

FlagShortTypeDefaultDescription
--server-sstringServer URL override
--jsonboolfalseOutput as JSON

For a SQL projection, while the status is active, Lag 0 means that no event is waiting for the engine.

Output (active projection):

Name: orders-by-customer
Status: active
Mode: managed
Last Event: 1523
Lag: 0
Updated: 2m ago

Output (rebuilding projection):

Name: orders-by-customer
Status: rebuilding
Mode: managed
Last Event: 0
Lag: 1200
Updated: 15s ago
Rebuild Job:
Job ID: rebuild-abc123
Status: running
Progress: 65.0%
Processed: 65000 / 100000 events
Started: 2m30s ago
ETA: 1m15s

Start a rebuild of a projection. Resets state and replays events from the event log.

Terminal window
ironflow projection rebuild <name> [flags]

Arguments:

ArgumentRequiredDescription
nameYesThe projection to rebuild

Flags:

FlagShortTypeDefaultDescription
--fromstringStart replay from this event ID. Also the escape hatch when event retention is on — see below. Refused for SQL projections.
--tostringStop replay at this event ID. Refused for SQL projections.
--partitionstringOnly rebuild this partition. Refused for SQL and external projections: neither keeps state per partition.
--dry-runboolfalseEstimate work without starting rebuild. Changes nothing, but can still FAIL — see below
--server-sstringServer URL override
--jsonboolfalseOutput as JSON

Output:

Rebuild started
Job ID: rebuild-abc123
Projection: orders-by-customer
Status: running
Total Events: 100000

Output (—dry-run):

Dry run — rebuild not started
Job ID: rebuild-abc123
Projection: orders-by-customer
Status: dry_run
Total Events: 100000

Event retention bounds the rebuild. A projection filtered by event name replays exactly the rows IRONFLOW_EVENT_RETENTION_DAYS prunes. A rebuild whose replay range reaches an event that prune has deleted from its filter is refused — by --dry-run as well as by a real rebuild — rather than replaying a truncated history and reporting success. Changing the window does not lift the refusal. Because a rebuild with no --from starts at the beginning of history, that includes every full rebuild of a projection whose filter has lost an event. Pass --from <event-id> with the event the refusal names to accept the shortened history. A --from older than the deleted event is refused too. A SQL projection cannot use --from, and a filter with no event that new leaves nothing to name; both recover as the next paragraph describes. A projection whose filter matches only entity events is unaffected. A prune that takes an event a running rebuild has not read fails that rebuild, and the failure names --dry-run as the next command. A prune of events it already read, or older than its --from, does not stop it. See configuration for the full rule.

A rebuild with no event left to replay. When retention has recorded a loss for the filter and the environment holds no published event, a rebuild replays nothing, and it would only discard the read model and report success. It is refused, by --dry-run as well, if the projection has state, a cursor above 0, a committed event or a recorded error. Nothing changes: a healthy projection keeps its read model, and one whose last rebuild failed stays refused. To replace the read model, run ironflow projection delete <name>, then restart its runner. For a SQL projection, save its definition first, because the delete removes it, and run ironflow projection create <name> with it. For an external projection, clear your own store first. Either way it then reads the PUBSUB stream from its first retained event, so the result can still miss older history. A projection with none of those has nothing to lose, so its rebuild completes with no work.

A rebuild cannot rebind a projection to a new stream. A projection records the PUBSUB stream it was registered or created against. Once that stream is recreated, a rebuild of an SDK or SQL projection is refused with failed_precondition before it deletes anything. A SQL projection in this state has status error, and its message names the recovery: run ironflow projection delete <name>, then ironflow projection create <name> with the same definition. An SDK projection recovers by ironflow projection delete <name> and a runner restart. A SQL projection created before the engine recorded its stream is refused the same way.

A recreated or restored PUBSUB stream refuses the rebuild. The events table keeps an older PUBSUB stream’s events beside the current stream’s, at the same seqs, after the stream is recreated or its NATS data is restored. A rebuild of an SDK or SQL projection, with or without --from, --to or --partition, is then refused with failed_precondition before it deletes anything, when two of the environment’s events share a seq or an event holds a seq above the stream’s last seq. The projection keeps its read model and reads on, unless an earlier rebuild failed, which refuses reads as described below. An event whose outbox mark is still pending cannot show this at the start, so the scan checks again once the mark commits. A shared seq then fails the rebuild, and the start has already deleted the state. To replace the read model, run ironflow projection delete <name> and restart its runner, or create a SQL projection again; either reads the current stream from its first retained event. Restoring the NATS data without the database is unsupported (ADR 0164).

A failed rebuild refuses reads. After any failed rebuild of an SDK projection, Poll and Stream answer failed_precondition with the reason, across runner restarts, and the runners stop, until a rebuild succeeds or the projection is deleted. A rebuild with --to or --partition cannot clear this and is refused. A shared seq between two streams cannot be rebuilt either, so its way out is the recovery above. For anything else, fix the cause, run the rebuild, then restart the runner, because a rebuild runs through the runner’s reads. Cancelling that rebuild brings the refusal back.

After a failed or cancelled rebuild of a SQL projection, no live event applies and queries answer failed_precondition with the reason, until a new rebuild starts or the projection is deleted and created again.

ironflow projection get on a managed projection and every catch-up wait answer the same after a failed rebuild, until a new rebuild starts. ironflow projection status still shows the failure.

Examples:

Terminal window
# Full rebuild
ironflow projection rebuild orders-by-customer
# Partial rebuild from a specific event
ironflow projection rebuild orders-by-customer --from evt_123
# Rebuild a specific partition
ironflow projection rebuild orders-by-customer --partition customer-123
# Preview what a rebuild would do
ironflow projection rebuild orders-by-customer --dry-run
# Rebuild with event range
ironflow projection rebuild orders-by-customer --from evt_123 --to evt_456

A projection’s event filter, partition key and mode cannot change during a rebuild. A registration that changes one gets failed_precondition. A rebuild with events to scan gets the same refusal when the filter changes before its start commits. A registration with the same definition still works, so a runner can restart. To deploy a change, wait for the rebuild to end, or cancel it and register again. The next rebuild then checks the new filter against the event retention window.

Cancel an in-progress rebuild. Any server node can cancel it, also a rebuild that another node started or that ran before a restart. A SQL projection goes to status error, because its table is partial: it applies no live event and refuses queries until you run the rebuild again. A handler-based projection goes back to active, unless an earlier rebuild failed: then it goes back to error, and reads stay refused. A new rebuild can start at once.

Terminal window
ironflow projection rebuild cancel <name> [flags]

Arguments:

ArgumentRequiredDescription
nameYesThe projection to cancel rebuild for

Flags:

FlagShortTypeDefaultDescription
--server-sstringServer URL override

Output:

Rebuild cancelled for projection "orders-by-customer" (status: cancelled)

Get the current state and metadata of a projection.

After a failed rebuild of a managed projection this answers failed_precondition with the reason, until a new rebuild starts. Use ironflow projection status to see the failure.

Terminal window
ironflow projection get <name> [flags]

Arguments:

ArgumentRequiredDescription
nameYesThe projection name

Flags:

FlagShortTypeDefaultDescription
--partitionstringPartition key
--jsonboolfalseOutput as JSON
--server-sstringServer URL override

Output:

Name: order-totals
Mode: managed
Version: 15
Last Event ID: evt_01HN8K3X5Y
Last Event At: 2m ago
State: {
"total_orders": 42,
"total_revenue": 12500.00
}

Examples:

Terminal window
ironflow projection get order-totals
ironflow projection get order-totals --partition tenant-123
ironflow projection get order-totals --json

Pause a running projection. It will stop processing events until resumed.

Terminal window
ironflow projection pause <name> [flags]

Arguments:

ArgumentRequiredDescription
nameYesThe projection name

Flags:

FlagShortTypeDefaultDescription
--server-sstringServer URL override

Output:

Paused projection "order-totals" (status: paused)

Resume a paused projection. It will continue processing events from where it left off.

Terminal window
ironflow projection resume <name> [flags]

Arguments:

ArgumentRequiredDescription
nameYesThe projection name

Flags:

FlagShortTypeDefaultDescription
--server-sstringServer URL override

Output:

Resumed projection "order-totals" (status: active)

Unregister and delete a projection. Does not delete data written to external stores.

Terminal window
ironflow projection delete <name> [flags]

Arguments:

ArgumentRequiredDescription
nameYesThe projection name

Flags:

FlagShortTypeDefaultDescription
--server-sstringServer URL override

Output:

Deleted projection "old-projection"

Create a new SQL projection that builds a read model from event streams. The projection is bound to the current PUBSUB stream and reads it from its first retained event. If that stream is later recreated, the projection stops with status error; delete it and create it again (see rebuild).

Terminal window
ironflow projection create <name> [flags]

Arguments:

ArgumentRequiredDescription
nameYesThe projection name

Flags:

FlagShortTypeDefaultDescription
--sqlstringDDL for the projection (CREATE TABLE ..., plus optional CREATE INDEX / CREATE EXTENSION) (required unless --sql-file)
--sql-filestringPath to a file containing that DDL (required unless --sql)
--eventstring[]Event name to subscribe to (repeatable) (required)
--event-handlerstring[]Per-event SQL handler in event_name=SQL format (repeatable) (required)
--descriptionstringProjection description
--jsonboolfalseOutput as JSON
--server-sstringServer URL override

One of --sql or --sql-file is required. Cannot use both. At least one --event and one --event-handler are also required.

The projection name becomes the table name, so it must be spellable as an unquoted SQL identifier: lowercase letters, digits and underscores, not starting with a digit, and at most 45 characters. order-totals is rejected at create time — it would have produced proj_order-totals, which no unquoted CREATE TABLE can name.

Uppercase is refused because PostgreSQL folds an unquoted identifier: Foo and foo are two projections but one table, proj_foo, whose rows they would then share. The 45-character bound is the same collision by another route — the environment-scoped table proj_<envToken>_<name> is proj_ + 12 + _ + 45, which is exactly PostgreSQL’s 63-byte identifier limit, past which it truncates rather than failing. Both apply on SQLite too, so a projection created there survives a move to PostgreSQL.

The name you write is the name you keep using — in the DDL, in every event handler, and in ironflow projection get. The table the rows actually live in carries an extra environment segment, proj_{envToken}_{name}, so two environments that each own a projection called orders do not share one table (#1676). Ironflow rewrites the identifier for you; the only time the physical name matters is when you query the database directly instead of through the API, and projection_registry is what maps it back to an environment.

The DDL is a script, not a single statement. It must contain exactly one CREATE TABLE for the projection’s own proj_-prefixed table, and may add:

  • CREATE EXTENSION IF NOT EXISTS <name> before it — vector and pg_trgm only, and PostgreSQL only.
  • CREATE INDEX / CREATE UNIQUE INDEX after it, on that same table. The index name must start with the table name, because index names are schema-global.

Every other statement shape is rejected, including CREATE TABLE ... AS SELECT and any statement naming a second table. A projection reads its own table and nothing else. That includes a foreign key: any REFERENCES clause is rejected, even one pointing at the projection’s own table. A foreign key to another table would block deletes there until the projection is dropped, and a self-referencing one would break a rebuild — events replay in stream order, so a child row can arrive before its parent and fail the constraint. A table-element LIKE is rejected for the same reason — CREATE TABLE proj_x (LIKE other) copies another table’s column definitions and reads its catalog. The LIKE operator is unaffected: CHECK (name LIKE 'a%') is accepted.

Two characters are refused anywhere in the script, comments and string literals included: $ (dollar-quoting) and ` (SQLite’s backtick identifier quote). Each opens a quoting mode the validator does not model, which would let it disagree with the server about where a statement ends or what a statement says. Quote an identifier with double quotes instead — "select" TEXT is accepted.

A block comment must not contain a nested /*. PostgreSQL nests them and SQLite does not, so a nested comment is the one construct where the validator and the server can disagree about where a comment — and therefore a statement — ends. Ordinary /* ... */ comments are fine.

Output:

Created SQL projection "order_totals" (status: active)

Examples:

Terminal window
ironflow projection create order_totals \
--sql "CREATE TABLE proj_order_totals (order_id TEXT PRIMARY KEY, amount NUMERIC)" \
--event "order.placed" \
--event-handler "order.placed=INSERT INTO proj_order_totals (order_id, amount) VALUES (:entity_id, :data.total)"
ironflow projection create order_totals \
--sql-file projection.sql \
--event "order.placed" \
--event-handler "order.placed=INSERT INTO proj_order_totals (order_id, amount) VALUES (:entity_id, :data.total)" \
--description "Order totals"
ironflow projection create board \
--sql "CREATE TABLE proj_board (id TEXT PRIMARY KEY, title TEXT, status TEXT)" \
--event "issue.created" --event "issue.status_changed" \
--event-handler "issue.created=INSERT INTO proj_board (id, title, status) VALUES (:entity_id, :data.title, 'OPEN')" \
--event-handler "issue.status_changed=UPDATE proj_board SET status = :data.to WHERE id = :entity_id" \
--json

Watch real-time updates for a projection via WebSocket.

Terminal window
ironflow projection watch <name> [flags]

Arguments:

ArgumentRequiredDescription
nameYesThe projection name to watch

Flags:

FlagShortTypeDefaultDescription
--server-sstringws://localhost:9123/ws (derived from IRONFLOW_SERVER_URL if set)WebSocket server URL
--replay-rint0Number of historical events to replay
--json-jboolfalseOutput events as JSON
--metadata-mboolfalseInclude event metadata

--replay prints nothing for a projection whose last rebuild failed, until a rebuild succeeds. Run ironflow projection status <name> to see the failure.

Examples:

Terminal window
ironflow projection watch order-totals
ironflow projection watch order-totals --replay 10
ironflow projection watch order-totals --json

Block until a projection has processed events up to --min-seq (NATS sequence on the PUBSUB stream). Used for read-your-writes after a write returned a sequence in AppendResult.

Terminal window
ironflow projection wait <name> --min-seq <n> [flags]

Arguments:

ArgumentRequiredDescription
nameYesThe projection name

Flags:

FlagShortTypeDefaultDescription
--server-sstringIRONFLOW_SERVER_URLServer URL
--min-sequint64—Target NATS sequence the projection must reach (required)
--timeoutduration30sMaximum wait duration. Server caps at 60s unary, 300s with --stream.
--partitionstring""Scope wait to a single partition (managed projections with partitioned state)
--streamboolfalseUse streaming RPC (#476) — raises the cap to 300s and emits progress + heartbeat frames so long waits survive proxies/LBs with short idle timeouts.
--jsonboolfalseOutput as JSON

Output:

caught up (seq 1234 of 1234) in 120ms

On a timeout it prints timed out after <elapsed> (seq <current> of <target>, behind by <n>).

Exit codes:

CodeMeaning
0Projection caught up within timeout
1Timed out
2Error (not found, paused/rebuilding, failed rebuild, bad input, network)

--partition is rejected (exit 2) on an external projection, and on a projection that declares no partition key and holds no state row under that partition. On a partitioned projection a partition that no event has reached yet is accepted, so append-then-wait works for a brand-new key; the server instead caps concurrent waits at 64 distinct partitions per projection. See Partition on the wait methods.

Examples:

Terminal window
ironflow projection wait order-view --min-seq 1234
ironflow projection wait order-view --min-seq 1234 --timeout 10s
ironflow projection wait order-view --min-seq 1234 --partition tenant-1 --json
# Streaming — progress lines print as the cursor advances:
ironflow projection wait order-view --min-seq 1234 --stream --timeout 5m

Wait until the given projection has processed the event identified by <event-id>. The server resolves the event ID to its NATS sequence via events.nats_seq and waits on that.

Terminal window
ironflow projection wait-for-event <event-id> --projection <name> [flags]

Arguments:

ArgumentRequiredDescription
event-idYesThe event ID

Flags:

FlagShortTypeDefaultDescription
--server-sstringIRONFLOW_SERVER_URLServer URL
--projectionstring—Projection name to wait on (required)
--timeoutduration30sMaximum wait duration. Server caps at 60s.
--partitionstring""Scope wait to a single partition (managed projections with partitioned state)
--jsonboolfalseOutput as JSON

Exit codes: same as projection wait. Events written before migration 010 may have a NULL nats_seq and return exit code 2; in that case fall back to projection wait --min-seq using a sequence from a fresh write.

Examples:

Terminal window
ironflow projection wait-for-event evt_abc --projection order-view
ironflow projection wait-for-event evt_abc --projection order-view --timeout 10s --json

Wait on multiple projections concurrently. All items share a single timeout and a single atomic capacity reservation on the server. Max 16 items per batch.

Terminal window
ironflow projection wait-batch --file <path> [flags]

The input file is a JSON array of items:

[
{"name": "order-view", "minSeq": 42, "partition": "tenant-1"},
{"name": "inventory", "minSeq": 42}
]

Use --file - to read from stdin.

Flags:

FlagShortTypeDefaultDescription
--server-sstringIRONFLOW_SERVER_URLServer URL
--filestring—Path to JSON items file, or - for stdin (required)
--timeoutduration30sMaximum wait duration. Server caps at 60s.
--jsonboolfalseOutput as JSON

Exit codes:

CodeMeaning
0All items caught up within timeout
1At least one item timed out (none errored)
2At least one item errored, or bad input / network

Examples:

Terminal window
ironflow projection wait-batch --file items.json
jq '...' | ironflow projection wait-batch --file - --timeout 10s --json

Reclaim the projection JetStream durables an upgrade past ADR 0050 (environment-ID-scoped pub/sub wire subjects) or ADR 0163 (durables named per projection incarnation) leaves behind on the PUBSUB stream.

Projection durables are named proj-<environmentID>--<name>--<incarnation>. A deployment upgraded from an earlier version keeps its old, differently-named durable for every projection — one per projection, forever. They are inert (PUBSUB uses limits retention, so a stalled consumer pins no messages), but they persist and make nats consumer ls PUBSUB misleading during triage.

Pre-ADR-0050 candidates are selected by their filter subject, never by their name. A durable whose filters begin with public.<environmentID>.events. for the environment ID its own name is keyed on has current filters; it is a candidate only when its name has no incarnation segment. Only a subject that fails that test is checked for the legacy public.<project>.<env>.events.… layout. A durable whose generation cannot be determined is skipped rather than guessed at, and the skipped ones are logged so you can inspect them by hand.

This talks to NATS directly, not to the Ironflow server.

By default it lists what it would delete and stops. Pass --delete to remove them.

Terminal window
ironflow projection durables prune [flags]

Flags:

FlagTypeDefaultDescription
--nats-urlstringNATS_URL or nats://localhost:4222NATS URL
--nats-credsstringNATS_CREDS_FILENATS .creds file for JWT/NKey auth
--deleteboolfalseDelete the listed durables instead of listing

Examples:

Terminal window
# List what would be reclaimed
ironflow projection durables prune
# Reclaim them
ironflow projection durables prune --delete
# Against an external NATS cluster
ironflow projection durables prune --nats-url nats://nats:4222 --delete

Run this after an upgrade has settled, not during it: a node still on the older version pulls through the durable this deletes. Rolling back to a pre-ADR-0050 binary after pruning means the old durable no longer exists and gets recreated under DeliverAllPolicy, replaying the stream’s whole retention window.