Перейти к содержимому
Русский

Projections

Это содержимое пока не доступно на вашем языке.

A PersistentActor writes events. A projection consumes them, building a read-side view tailored for queries:

read side

write side

PersistentActor

commands in

Journal

events

ProjectionActor

handler

read-model

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).

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:

  1. Loads its offset cursor from the offset store on preStart.
  2. Polls the query layer for events matching tag from the cursor onwards.
  3. Calls handle for each event.
  4. Persists the new cursor.
  5. Repeats.
FactoryCursor typeUse
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).

// 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 sequenceNr as 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.

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 any DurableStateStore (Postgres, MariaDB, libSQL, SQL Server, MongoDB, DynamoDB, object-storage) so offsets survive restarts.
  • Custom — implement OffsetStore against 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.

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.

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.

// 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.

The ProjectionActor API reference covers all settings.