Projections
이 콘텐츠는 아직 번역되지 않았습니다.
A PersistentActor writes events. A projection consumes
them, building a read-side view tailored for queries:
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 { match } from 'ts-pattern';import { ProjectionActor, ByTagProjectionOptions } from 'actor-ts';import { SqliteJournal, SqliteJournalOptions, SqliteQuery, InMemoryOffsetStore } from 'actor-ts';
type AccountEvent = | { kind: 'deposited'; amount: number } | { kind: 'withdrawn'; amount: number };
// The read side queries the SAME journal your PersistentActors write to —// SqliteQuery wraps a SqliteJournal instance (not a path).const eventsJournal = new SqliteJournal(SqliteJournalOptions.create().withPath('/var/lib/events.db'));
const byTagProjectionOptions = ByTagProjectionOptions.create<AccountEvent>() .withName('account-balance-view') .withTag('account') .withQuery(new SqliteQuery(eventsJournal)) .withOffsetStore(new InMemoryOffsetStore()) .withHandle(async (event) => { await match(event.event) .with({ kind: 'deposited' }, (e) => viewDb.execute( 'UPDATE balances SET balance = balance + ? WHERE pid = ?', [e.amount, event.persistenceId], )) .otherwise(() => Promise.resolve()); });const projection = ProjectionActor.byTag<AccountEvent>(system, byTagProjectionOptions);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, DurableStateOffsetStore } from 'actor-ts';
// Default (lost on restart):new InMemoryOffsetStore();
// Durable — persist the cursor in any DurableStateStore (Postgres,// MariaDB, libSQL, SQL Server, MongoDB, DynamoDB, object-storage), so a restart resumes:new DurableStateOffsetStore(durableStateStore);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).DurableStateOffsetStore— wraps anyDurableStateStore(Postgres, MariaDB, libSQL, SQL Server, MongoDB, DynamoDB, object-storage) so offsets survive restarts.- Custom — implement
OffsetStoreagainst your own store (Redis, 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”const byTagProjectionOptions = ByTagProjectionOptions.create() .withName('...') .withTag('...') .withLiveOptions({ pollIntervalMs: 500, // default 1000 ms });ProjectionActor.byTag(system, byTagProjectionOptions /* .withQuery(...).withOffsetStore(...).withHandle(...) */);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 byPidProjectionOptions = ByPersistenceIdProjectionOptions.create<AccountEvent>() .withName('account-42-view') .withPersistenceId('account-42') .withQuery(query) .withOffsetStore(offsetStore) .withHandle(async (event) => { // ... handle just this account's events });const projection = ProjectionActor.byPersistenceId<AccountEvent>(system, byPidProjectionOptions);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.
Fan-out: starting one as each entity appears
Section titled “Fan-out: starting one as each entity appears”A per-pid projection has to know its pid up front, which is fine
for a fixed set and useless for a growing one. allPersistenceIds
closes that gap — it is a live stream of every id the journal has
seen, plus each new one as it first appears:
for await (const persistenceId of query.allPersistenceIds()) { const options = ByPersistenceIdProjectionOptions.create<AccountEvent>() .withName(`view-${persistenceId}`) .withPersistenceId(persistenceId) .withQuery(query) .withOffsetStore(offsetStore) .withHandle(async (event) => { /* ... */ }); ProjectionActor.byPersistenceId<AccountEvent>(system, options);}Two caveats, and they are the same ones the per-pid shape already had, just made visible:
- The loop never ends, so run it in its own task and break out on shutdown.
- One actor per entity is still one actor per entity. Fan-out is for hundreds or thousands of long-lived entities, not for millions of short-lived ones — there, a tag projection is the answer.
For a one-shot sweep instead of a live one — a backfill over
the entities that exist right now — use
currentPersistenceIdsPaginated, which completes when the journal
is exhausted and holds one page at a time rather than the whole id
list. See Persistence query.
Multiple projections, same journal
Section titled “Multiple projections, same journal”// Three projections, three offset cursors, three independent views:const byTagProjectionOptions = ByTagProjectionOptions.create<E>().withName('balance');ProjectionActor.byTag<E>(system, byTagProjectionOptions /* .withTag(...).withQuery(...).withHandle(...) */);const byTagProjection2Options = ByTagProjectionOptions.create<E>().withName('audit-log');ProjectionActor.byTag<E>(system, byTagProjection2Options /* .withTag(...).withQuery(...).withHandle(...) */);const byTagProjection3Options = ByTagProjectionOptions.create<E>().withName('monthly-stats');ProjectionActor.byTag<E>(system, byTagProjection3Options /* .withTag(...).withQuery(...).withHandle(...) */);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.
