Projections
A PersistentActor writes events. A projection consumes
them, building a read-side view tailored for queries:
write side read side ────────── ───────── PersistentActor → journal → ProjectionActor → read-model (commands) (events) (handler) (SQL/Redis/etc)The journal is append-only and authoritative. Projections are derived — they can be rebuilt from scratch by replaying the journal. This decouples write throughput (durable, append-only) from read throughput (denormalized, query-optimized).
A minimal example
Section titled “A minimal example”import { ProjectionActor, type ByTagSettings } from 'actor-ts';import { SqliteQuery } from 'actor-ts';
type AccountEvent = | { kind: 'deposited'; amount: number } | { kind: 'withdrawn'; amount: number };
const projection = ProjectionActor.byTag<AccountEvent>({ name: 'account-balance-view', tag: 'account', query: new SqliteQuery({ path: '/var/lib/events.db' }), offsetStore: new SqliteOffsetStore({ path: '/var/lib/offsets.db' }), async handle(event) { if (event.event.kind === 'deposited') { await viewDb.execute( 'UPDATE balances SET balance = balance + ? WHERE pid = ?', [event.event.amount, event.persistenceId], ); } },});
system.actorOf(Props.create(() => projection), 'balance-projection');The actor:
- Loads its offset cursor from the offset store on
preStart. - Polls the query layer for events matching
tagfrom the cursor onwards. - Calls
handlefor each event. - Persists the new cursor.
- Repeats.
Two query shapes
Section titled “Two query shapes”| Factory | Cursor type | Use |
|---|---|---|
ProjectionActor.byPersistenceId(...) | sequenceNr (per pid) | Read one entity’s full history. |
ProjectionActor.byTag(...) | Offset (timestamp + tiebreaker) | Read everything tagged <tag> across the journal. |
Tag-based is the common case — the PersistentActor calls
tagsFor(event) to label events; the projection subscribes to
the tag. Per-pid is useful for narrow views (one user’s
activity).
At-least-once delivery
Section titled “At-least-once delivery”// Crash recovery sequence:// 1. Save offset cursor at value N.// 2. Handler processes event N+1.// 3. Crash before saving cursor.// 4. On restart: cursor is still N → handler re-receives event N+1.If the handler runs but the cursor isn’t persisted, the projection re-processes the same event on restart. This is at-least-once delivery — the framework guarantees no event is missed, but duplicates are possible.
Handlers must be idempotent:
- UPSERT into the read model (not blind
INSERT). - Track processed event IDs in the read model itself for dedup.
- Use the event’s
sequenceNras a per-pid dedup key — never decreases, monotonic per persistenceId.
If you can’t make the handler idempotent, the projection has to participate in a 2-phase commit with the offset save — much more complex, not provided out of the box.
OffsetStore
Section titled “OffsetStore”import { InMemoryOffsetStore, SqliteOffsetStore } from 'actor-ts';
// Default (lost on restart):new InMemoryOffsetStore();
// Production (single-node):new SqliteOffsetStore({ path: '/var/lib/offsets.db' });The cursor is just a number (or compound offset) — stored per projection name + scope. Implementations:
InMemoryOffsetStore— fine for tests, useless in production (every restart re-processes from the beginning).SqliteOffsetStore— single-file durable offsets.- Custom — implement
OffsetStoreagainst your own store (Redis, Postgres, whatever your read-model uses).
For real production setups, co-locate the offset store with the read model so they crash together — that minimizes the re-processing window.
Polling
Section titled “Polling”new ProjectionActor.byTag({ name: '...', tag: '...', liveOptions: { pollIntervalMs: 500, // default 1000 ms }, ...});The projection polls. At idle, polling cost is one journal query
per pollIntervalMs. Tuning:
- Lower (250-500 ms) → faster end-to-end propagation, more database load.
- Higher (5-10 s) → slow visibility but cheap.
For very-low-latency read-model updates, see Push-based query which gets sub-poll-interval delivery via the in-process event bus.
Per-pid projections
Section titled “Per-pid projections”const projection = ProjectionActor.byPersistenceId<AccountEvent>({ name: 'account-42-view', persistenceId: 'account-42', query, offsetStore, async handle(event) { // ... handle just this account's events },});One actor per persistenceId. Useful for per-entity views:
- A “user activity timeline” projection per user.
- A “per-order audit trail” projection per order.
For large numbers of pids, this is not how to scale — spawning a projection per pid doesn’t scale to millions of users. For that, use a single tag-based projection that hashes by pid.
Multiple projections, same journal
Section titled “Multiple projections, same journal”// Three projections, three offset cursors, three independent views:system.actorOf(Props.create(() => ProjectionActor.byTag<E>({ name: 'balance', ... })));system.actorOf(Props.create(() => ProjectionActor.byTag<E>({ name: 'audit-log', ... })));system.actorOf(Props.create(() => ProjectionActor.byTag<E>({ name: 'monthly-stats', ... })));Each has its own offset cursor; they read independently from the journal. This is the strength of event sourcing — one event stream, many derived views, none coupled to the other.
Where to next
Section titled “Where to next”- Persistence overview — the bigger picture.
- PersistentActor — what produces the events.
- Persistence query — the read-side API the projection uses.
- Push-based query — sub-poll-interval delivery via the event bus.
The ProjectionActor API
reference covers all settings.