Skip to content

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

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

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
--tostringStop replay at this event ID
--partitionstringOnly rebuild this partition
--dry-runboolfalseEstimate work without starting rebuild
--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

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

Cancel an in-progress rebuild. Previous state is preserved.

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.

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.

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

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-sequint64Target 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

Exit codes:

CodeMeaning
0Projection caught up within timeout
1Timed out
2Error (projection not found, paused/rebuilding, 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
--projectionstringProjection 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
--filestringPath 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) leaves behind on the PUBSUB stream.

Projection durables are keyed by environment ID. A deployment upgraded from an earlier version keeps its old, differently-keyed 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.

Candidates are selected by their filter subject, never by their name. A durable is spared when its filters begin with public.<environmentID>.events. for the environment ID its own name is keyed on; 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. 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.