- CLI Reference
- Commands
- ironflow projection
ironflow projection
Manage projections: create, list, inspect, pause, resume, delete, rebuild, and wait for catch-up.
ironflow projection <subcommand> [flags]ironflow projection list
Section titled “ironflow projection list”List all registered projections with their mode, status, and event count.
ironflow projection list [flags]Flags:
| Flag | Short | Type | Default | Description |
|---|---|---|---|---|
--server | -s | string | Server URL override | |
--json | bool | false | Output as JSON |
Output:
NAME MODE STATUS EVENTS LAST SEQ UPDATEDorders-by-customer managed active 3 1523 2m agoinventory-tracker external rebuilding 2 0 15s agoironflow projection history
Section titled “ironflow projection history”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.
ironflow projection history <name> [flags]| Flag | Type | Default | Description |
|---|---|---|---|
--revision | int64 | Retrieve one positive revision as full JSON | |
--limit | int32 | 50 | Page size, maximum 200 |
--before-revision | int64 | Exclusive cursor for older revisions | |
--json | bool | false | Full response as JSON |
--server, -s | string | Server 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>.
ironflow projection history orders_by_customer --limit 10ironflow projection history orders_by_customer --before-revision 12 --jsonironflow projection history orders_by_customer --revision 3 --jsonExisting 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.
ironflow projection status
Section titled “ironflow projection status”Show detailed status for a projection, including rebuild progress if one is running.
ironflow projection status <name> [flags]Arguments:
| Argument | Required | Description |
|---|---|---|
name | Yes | The projection name |
Flags:
| Flag | Short | Type | Default | Description |
|---|---|---|---|---|
--server | -s | string | Server URL override | |
--json | bool | false | Output 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-customerStatus: activeMode: managedLast Event: 1523Lag: 0Updated: 2m agoOutput (rebuilding projection):
Name: orders-by-customerStatus: rebuildingMode: managedLast Event: 0Lag: 1200Updated: 15s ago
Rebuild Job: Job ID: rebuild-abc123 Status: running Progress: 65.0% Processed: 65000 / 100000 events Started: 2m30s ago ETA: 1m15sironflow projection rebuild
Section titled “ironflow projection rebuild”Start a rebuild of a projection. Resets state and replays events from the event log.
ironflow projection rebuild <name> [flags]Arguments:
| Argument | Required | Description |
|---|---|---|
name | Yes | The projection to rebuild |
Flags:
| Flag | Short | Type | Default | Description |
|---|---|---|---|---|
--from | string | Start replay from this event ID. Also the escape hatch when event retention is on — see below. Refused for SQL projections. | ||
--to | string | Stop replay at this event ID. Refused for SQL projections. | ||
--partition | string | Only rebuild this partition. Refused for SQL and external projections: neither keeps state per partition. | ||
--dry-run | bool | false | Estimate work without starting rebuild. Changes nothing, but can still FAIL — see below | |
--server | -s | string | Server URL override | |
--json | bool | false | Output as JSON |
Output:
Rebuild startedJob ID: rebuild-abc123Projection: orders-by-customerStatus: runningTotal Events: 100000Output (—dry-run):
Dry run — rebuild not startedJob ID: rebuild-abc123Projection: orders-by-customerStatus: dry_runTotal Events: 100000Event 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:
# Full rebuildironflow projection rebuild orders-by-customer
# Partial rebuild from a specific eventironflow projection rebuild orders-by-customer --from evt_123
# Rebuild a specific partitionironflow projection rebuild orders-by-customer --partition customer-123
# Preview what a rebuild would doironflow projection rebuild orders-by-customer --dry-run
# Rebuild with event rangeironflow projection rebuild orders-by-customer --from evt_123 --to evt_456A 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.
ironflow projection rebuild cancel
Section titled “ironflow projection rebuild cancel”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.
ironflow projection rebuild cancel <name> [flags]Arguments:
| Argument | Required | Description |
|---|---|---|
name | Yes | The projection to cancel rebuild for |
Flags:
| Flag | Short | Type | Default | Description |
|---|---|---|---|---|
--server | -s | string | Server URL override |
Output:
Rebuild cancelled for projection "orders-by-customer" (status: cancelled)ironflow projection get
Section titled “ironflow projection get”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.
ironflow projection get <name> [flags]Arguments:
| Argument | Required | Description |
|---|---|---|
name | Yes | The projection name |
Flags:
| Flag | Short | Type | Default | Description |
|---|---|---|---|---|
--partition | string | Partition key | ||
--json | bool | false | Output as JSON | |
--server | -s | string | Server URL override |
Output:
Name: order-totalsMode: managedVersion: 15Last Event ID: evt_01HN8K3X5YLast Event At: 2m agoState: { "total_orders": 42, "total_revenue": 12500.00 }Examples:
ironflow projection get order-totalsironflow projection get order-totals --partition tenant-123ironflow projection get order-totals --jsonironflow projection pause
Section titled “ironflow projection pause”Pause a running projection. It will stop processing events until resumed.
ironflow projection pause <name> [flags]Arguments:
| Argument | Required | Description |
|---|---|---|
name | Yes | The projection name |
Flags:
| Flag | Short | Type | Default | Description |
|---|---|---|---|---|
--server | -s | string | Server URL override |
Output:
Paused projection "order-totals" (status: paused)ironflow projection resume
Section titled “ironflow projection resume”Resume a paused projection. It will continue processing events from where it left off.
ironflow projection resume <name> [flags]Arguments:
| Argument | Required | Description |
|---|---|---|
name | Yes | The projection name |
Flags:
| Flag | Short | Type | Default | Description |
|---|---|---|---|---|
--server | -s | string | Server URL override |
Output:
Resumed projection "order-totals" (status: active)ironflow projection delete
Section titled “ironflow projection delete”Unregister and delete a projection. Does not delete data written to external stores.
ironflow projection delete <name> [flags]Arguments:
| Argument | Required | Description |
|---|---|---|
name | Yes | The projection name |
Flags:
| Flag | Short | Type | Default | Description |
|---|---|---|---|---|
--server | -s | string | Server URL override |
Output:
Deleted projection "old-projection"ironflow projection create
Section titled “ironflow projection create”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).
ironflow projection create <name> [flags]Arguments:
| Argument | Required | Description |
|---|---|---|
name | Yes | The projection name |
Flags:
| Flag | Short | Type | Default | Description |
|---|---|---|---|---|
--sql | string | DDL for the projection (CREATE TABLE ..., plus optional CREATE INDEX / CREATE EXTENSION) (required unless --sql-file) | ||
--sql-file | string | Path to a file containing that DDL (required unless --sql) | ||
--event | string[] | Event name to subscribe to (repeatable) (required) | ||
--event-handler | string[] | Per-event SQL handler in event_name=SQL format (repeatable) (required) | ||
--description | string | Projection description | ||
--json | bool | false | Output as JSON | |
--server | -s | string | Server 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 —vectorandpg_trgmonly, and PostgreSQL only.CREATE INDEX/CREATE UNIQUE INDEXafter 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:
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" \ --jsonironflow projection watch
Section titled “ironflow projection watch”Watch real-time updates for a projection via WebSocket.
ironflow projection watch <name> [flags]Arguments:
| Argument | Required | Description |
|---|---|---|
name | Yes | The projection name to watch |
Flags:
| Flag | Short | Type | Default | Description |
|---|---|---|---|---|
--server | -s | string | ws://localhost:9123/ws (derived from IRONFLOW_SERVER_URL if set) | WebSocket server URL |
--replay | -r | int | 0 | Number of historical events to replay |
--json | -j | bool | false | Output events as JSON |
--metadata | -m | bool | false | Include 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:
ironflow projection watch order-totalsironflow projection watch order-totals --replay 10ironflow projection watch order-totals --jsonironflow projection wait
Section titled “ironflow projection wait”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.
ironflow projection wait <name> --min-seq <n> [flags]Arguments:
| Argument | Required | Description |
|---|---|---|
name | Yes | The projection name |
Flags:
| Flag | Short | Type | Default | Description |
|---|---|---|---|---|
--server | -s | string | IRONFLOW_SERVER_URL | Server URL |
--min-seq | uint64 | — | Target NATS sequence the projection must reach (required) | |
--timeout | duration | 30s | Maximum wait duration. Server caps at 60s unary, 300s with --stream. | |
--partition | string | "" | Scope wait to a single partition (managed projections with partitioned state) | |
--stream | bool | false | Use streaming RPC (#476) — raises the cap to 300s and emits progress + heartbeat frames so long waits survive proxies/LBs with short idle timeouts. | |
--json | bool | false | Output as JSON |
Output:
caught up (seq 1234 of 1234) in 120msOn a timeout it prints timed out after <elapsed> (seq <current> of <target>, behind by <n>).
Exit codes:
| Code | Meaning |
|---|---|
0 | Projection caught up within timeout |
1 | Timed out |
2 | Error (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:
ironflow projection wait order-view --min-seq 1234ironflow projection wait order-view --min-seq 1234 --timeout 10sironflow 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 5mironflow projection wait-for-event
Section titled “ironflow projection wait-for-event”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.
ironflow projection wait-for-event <event-id> --projection <name> [flags]Arguments:
| Argument | Required | Description |
|---|---|---|
event-id | Yes | The event ID |
Flags:
| Flag | Short | Type | Default | Description |
|---|---|---|---|---|
--server | -s | string | IRONFLOW_SERVER_URL | Server URL |
--projection | string | — | Projection name to wait on (required) | |
--timeout | duration | 30s | Maximum wait duration. Server caps at 60s. | |
--partition | string | "" | Scope wait to a single partition (managed projections with partitioned state) | |
--json | bool | false | Output 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:
ironflow projection wait-for-event evt_abc --projection order-viewironflow projection wait-for-event evt_abc --projection order-view --timeout 10s --jsonironflow projection wait-batch
Section titled “ironflow projection wait-batch”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.
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:
| Flag | Short | Type | Default | Description |
|---|---|---|---|---|
--server | -s | string | IRONFLOW_SERVER_URL | Server URL |
--file | string | — | Path to JSON items file, or - for stdin (required) | |
--timeout | duration | 30s | Maximum wait duration. Server caps at 60s. | |
--json | bool | false | Output as JSON |
Exit codes:
| Code | Meaning |
|---|---|
0 | All items caught up within timeout |
1 | At least one item timed out (none errored) |
2 | At least one item errored, or bad input / network |
Examples:
ironflow projection wait-batch --file items.jsonjq '...' | ironflow projection wait-batch --file - --timeout 10s --jsonironflow projection durables prune
Section titled “ironflow projection durables prune”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.
ironflow projection durables prune [flags]Flags:
| Flag | Type | Default | Description |
|---|---|---|---|
--nats-url | string | NATS_URL or nats://localhost:4222 | NATS URL |
--nats-creds | string | NATS_CREDS_FILE | NATS .creds file for JWT/NKey auth |
--delete | bool | false | Delete the listed durables instead of listing |
Examples:
# List what would be reclaimedironflow projection durables prune
# Reclaim themironflow projection durables prune --delete
# Against an external NATS clusterironflow projection durables prune --nats-url nats://nats:4222 --deleteRun 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.