Extension points
Three pluggable contracts: Store, Cache, Logger. Each is exposed as a singleton port. A new adapter implements the contract; calling the port with the adapter installs it (first call wins).
This page covers each contract, its invariants, and the concrete adapters in this repo. Anyone writing a new adapter should be able to read this page plus the contract source and build something correct.
The port patternโ
Every infrastructure dependency in the framework is reached via a port โ a singleton getter that lazily initializes a default the first time it's called:
import { store, cache, log, dispose } from "@rotorsoft/act";
// Defaults install on first call
store(); // โ InMemoryStore
cache(); // โ InMemoryCache
log(); // โ ConsoleLogger
// Or inject before first read
import { PostgresStore } from "@rotorsoft/act-pg";
store(new PostgresStore({ /* ... */ })); // sets the singleton
const s = store(); // returns the PostgresStore
First call wins by design. Once an adapter is registered, subsequent calls with a different argument are ignored. This forces app initialization to be deterministic and prevents mid-run swaps that would corrupt state.
The dispose() port collects cleanup callbacks. Adapters' dispose() methods are wired into this so they release resources (DB pools, file handles) on shutdown. Order: registered disposers run in reverse, then port adapters in reverse registration order.
Store contractโ
The Store interface in libs/act/src/types/ports.ts. The framework needs the store to do these things:
interface Store extends Disposable, EventSource {
// EventSource gives us:
// query<E>(callback: (event: Committed<E>) => void, query?: Query): Promise<number>;
seed(): Promise<void>;
drop(): Promise<void>;
commit(stream, msgs, meta, expectedVersion?): Promise<Committed[]>;
claim(lagging, leading, by, millis, lane?): Promise<Lease[]>;
subscribe(streams): Promise<{ subscribed; watermark }>;
ack(leases): Promise<Lease[]>;
block(leases): Promise<BlockedLease[]>;
reset(input: string[] | StreamFilter): Promise<number>;
unblock(input: string[] | StreamFilter): Promise<number>;
prioritize(filter: StreamFilter, priority): Promise<number>;
truncate(targets): Promise<Map<stream, { deleted; committed }>>;
query_streams(callback, query?): Promise<QueryStreamsResult>;
query_stats(input, options?): Promise<Map<stream, StreamStats>>;
// Optional, capability-gated:
notify?(handler): NotifyDisposer | Promise<NotifyDisposer>;
restore?(driver: (callback: (event: Committed) => Promise<number>) => Promise<void>): Promise<void>;
forget_pii?(stream: string): Promise<number>;
}
reset, unblock, and prioritize share the same StreamFilter shape (stream / stream_exact / source / source_exact / blocked / lane). reset and unblock also accept a plain string[] for targeted operations. unblock always restricts to blocked streams regardless of what the filter passes โ there's no "unblock unblocked streams" use case. reset is for projection rebuilds (watermark โ -1); unblock is for poison-message recovery (watermark preserved).
claim takes an optional lane filter (ACT-1103). When set, only streams in the named lane are eligible; when omitted, the claim spans every lane โ preserving pre-1103 behavior. Adapters that haven't migrated yet can leave lane unread on the SQL side and still satisfy the contract until they opt in. subscribe's row shape gained an optional lane field for the same release; adapters UPSERT it on every call so a restarted Act with a new lane assignment moves streams without a manual migration.
query_stats is the per-stream-aggregate primitive (added in ACT-639). Default returns the head event per stream via an indexed path; opt-in count/tail/names trigger a full scan but share it. Input is string[] for an enumerated set or Pick<StreamFilter, "stream" | "stream_exact"> for pattern selection โ subscription-level filters (source, blocked) live on query_streams; compose the two for "stats for blocked subscriptions" workflows.
QueryStatsOptions also carries the keyset-pagination pair after/limit. Results are ordered by stream name ascending; after is an exclusive cursor (streams sorting strictly after it), and the next cursor is the last key of the returned map ([...result.keys()].at(-1)). limit defaults to unbounded โ omit it and query_stats returns every matching stream (unlike query_streams.limit, which defaults to 100).
query_streams carries source_matches, a best-effort reverse-match filter: return subscriptions whose stored source pattern matches at least one of the supplied names (name ~ source, the inverse of source's source ~ pattern). A subscription with an absent or empty source always qualifies. It is a hint, not an exact filter โ a store that can't express reverse-regex may return a superset, so callers re-verify in process. Capability-gated as source_matches in the TCK; PostgresStore and InMemoryStore honor it, SqliteStore returns a superset.
restore is the offline wipe-and-rebuild primitive (added in ACT-1124, reshaped into the current HOF driver pattern by ACT-1125). Capability-gated โ adapters that can't atomically wipe and reinsert in one transaction don't have to implement it. The adapter's job is narrow: open a transaction (PG BEGIN, SQLite BEGIN IMMEDIATE, InMemory snapshot-and-swap), wipe events + streams/subscriptions, hand the orchestrator a per-event insert callback by invoking driver(callback), then commit or roll back. RESTART IDENTITY (PG) / sqlite_sequence reset (SQLite) reseed dense ids from 1; InMemory uses 0..N-1. created is preserved verbatim from the source โ distinct from commit, which always stamps now(). Reactions re-subscribe via the orchestrator on the next settle cycle.
forget_pii โ the sensitive-data erasure primitiveโ
forget_pii(stream: string): Promise<number> is the physical-erasure half of the sensitive-data epic. The orchestrator calls it from app.forget(stream); the adapter wipes the PII column for every event on the stream and returns the row count. events.data and the rest of the row are never touched, so the append-only invariant the rest of the framework depends on stays intact.
const wiped = await store.forget_pii("user-42");
// wiped === 7 โ seven events had their pii column nulled.
The method is idempotent. A second call on an already-wiped stream returns 0 without error; a call on a stream that never carried PII also returns 0. Adapters implement this by issuing a single UPDATE events SET pii = NULL WHERE stream = ? (PG/SQLite) or the in-memory equivalent, with no per-event branching for "did this event have PII to begin with."
Capability-gated through StoreCapabilities.pii_isolation in @rotorsoft/act-tck. Adapters that can ship the column declare pii_isolation: true and implement the method; adapters that can't (Kafka, append-only object-storage logs that don't support row-level UPDATE) declare pii_isolation: false and omit it. When pii_isolation is true, the TCK runs the full PII suite โ commit-with-pii round trip, pii-less passthrough, forget_pii happy path, idempotency, and isolation across sibling streams. When false, the suite is skipped.
app.forget(stream) calls into Store.forget_pii directly. If the configured store omits the method, the orchestrator throws with a clear "your adapter cannot comply with sensitive-data erasure" message โ operators discover the misconfiguration in dev, not during a compliance audit. The framework also invalidates the cache entry for the stream and emits the forgotten lifecycle event after a successful wipe. For the user-facing flow, see the Handling sensitive data guide.
Disk reclamation is adapter-dependent and intentionally out of scope: PG autovacuum reclaims lazily; SQLite needs PRAGMA incremental_vacuum or VACUUM to release pages. The framework's job is isolation and erasure of the column; physical page reclamation is the operator's.
In-tree adapters that declare pii_isolation:
| Adapter | Capability | Notes |
|---|---|---|
InMemoryStore | true | Per-event pii field cleared on forget_pii; useful for tests of the orchestrator's gate/strip path |
PostgresStore | true | events.pii JSONB column, nulled with a single indexed UPDATE |
SqliteStore | true | events.pii TEXT column, same pattern |
EventSource / EventSink โ the transfer surfaceโ
Added by ACT-1128 / #788, the public types EventSource and EventSink (in libs/act/src/types/action.ts) split the read end and the write end of the restore pipeline into separate interfaces, so the same Act.restore driver can move events between any source and any sink:
interface EventSource extends Disposable {
query<E>(callback: (event: Committed<E>) => void, query?: Query): Promise<number>;
}
interface EventSink extends Disposable {
restore(driver: (callback: (event: Committed) => Promise<number>) => Promise<void>): Promise<void>;
}
Store extends EventSource โ every adapter is a source for free. The optional Store.restore method matches the EventSink.restore shape, so a restore-capable store is also a sink. The framework ships CsvFile (in libs/act/src/csv.ts) as the bundled non-store implementation: it implements both ends so a CSV file can be either side of a transfer (back up a store โ CSV, restore a CSV โ store, or pipe one CSV to another). Construct with new CsvFile({ path }) for an on-disk file or new CsvFile({ blob }) for a string already in memory.
The orchestrator now exposes:
app.restore(source: EventSource, opts?: ScanOptions, sink?: EventSink): Promise<ScanResult>
sink defaults to the singleton store (which must declare the restore capability); passing an explicit sink routes the transfer elsewhere without binding the singleton. This is how the inspector's unified transfer endpoint moves events between PG โ SQLite โ CSV without ever changing what's connected.
Backpressureโ
The EventSource.query callback is typed (event) => void, and adapters wrap each invocation in await Promise.resolve(callback(event)). TypeScript's "any return ignored when the type says void" rule lets the same call site accept both sync (e => arr.push(e), returns number) and async (async e => โฆ) callbacks. The orchestrator's scan (in libs/act/src/internal/event-sourcing.ts) puts its per-event work directly inside the source's callback, so the adapter's per-event await throttles the producer to the consumer's pace.
scan paginates the source. Each batch calls source.query with limit: ScanOptions.batch_size (default 500, caller-tunable per Act.restore invocation) and after: <last id seen>. Stores that respect limit (PostgresStore's pool.query honors it natively) hold one batch's worth of rows in memory per round trip โ adapter cost is O(batch_size) regardless of total result size. Sources that ignore the filter and stream everything in one call (CsvFile) signal the loop to exit by returning more events than the requested limit; they're memory-safe because they read line-by-line internally.
A million-event PG โ CSV transfer holds at most batch_size rows in the adapter, one event in flight through the source's callback, and whatever the consumer accumulates downstream โ independent of total source size. CsvFile, EventSource, and EventSink are the public surface the rest of the framework speaks.
scan, Act.restore, and the destructive pathโ
The orchestrator-side validator lives in scan (libs/act/src/internal/event-sourcing.ts, alongside load/action/snap/tombstone) and is exposed publicly only via Act.restore(source, opts, sink?). scan owns iteration over the EventSource, validates each event (negative version, malformed created), applies drop_snapshots, fires on_progress, and builds the per-call old โ new id map that rewrites meta.causation.event.id so causation chains survive the renumber. Tools that operate on a raw Store without app state (e.g., the inspector) wrap the store in an empty Act via the scoped-ports option and call app.restore โ the orchestrator path stays the only door in.
ScanOptions is interpreted by scan, not by adapters. It carries:
drop_snapshotsโ skip every__snapshot__event in the source so the next snap policy regenerates them with current state (ACT-1125)drop_closed_streamsโ compact streams that have a__tombstone__event (ACT-1126). Scan walks the source once upfront with a tombstone-name filter to collect closed-stream names, then the main pass drops every pre-close event whose stream is in the set. The tombstone is kept โ it's what makesapp.do()throwStreamClosedErrorin the rebuilt store, so dropping it would silently reopen the stream. Counted inScanResult.dropped.closed_streams.event_migrationsandstream_renameโ transfer-time schema migration (ACT-1126). Schema-guarded event rewrites + bulk stream rename for tenant relocation; see Concepts โ Migration overlay.on_progressโ one callback per event (caller throttles/debounces)dry_runโ validate the source without touching the store (same scan loop, no transaction, no sink call; powers the inspector's transfer-preview)batch_sizeโ pagination chunk size for the underlyingsource.querycalls
All transforms run inside scan's existing pagination loop and atomic-rollback contract โ any throw aborts the whole pass.
Validation is a source operation, not a store operation. Per-event blockers (malformed created, negative version) are caught inline by the scan loop on every Act.restore call and throw on the first hit; atomic transaction rollback in the sink means a failing restore leaves the target byte-for-byte unchanged. Cross-event invariants (duplicate ids, per-stream version gaps) are not the framework's job โ DB UNIQUE(stream, version) catches dupes at commit time, and partial backups intentionally have gaps.
Invariants an adapter must holdโ
- Per-stream version monotonicity: every event for a given stream has a
versionthat's strictly greater than the previous event'sversionfor that stream, starting at 0. - Optimistic concurrency: when
expectedVersionis provided,commitMUST throwConcurrencyErrorif the stream's current head version doesn't match. This includes catching adapter-specific unique-constraint violations and re-throwing asConcurrencyError. Callers cannot retry correctly on adapter-specific errors. - Atomic commits: a multi-event commit is all-or-nothing. Either all events land or none do.
- Atomic truncate:
truncatedeletes all events for a stream and inserts the seed event in a single transaction. Partial states are not observable. - Atomic restore (when implemented):
restorewipes events + streams and rewrites the source rows in a single transaction. On any throw mid-iteration, the store reverts byte-for-byte to its pre-call state. Cache invalidation after restore is the caller's responsibility โ restore does not touch theCacheport. - Backpressured query: adapters MUST invoke the per-event callback as
await Promise.resolve(callback(event)). Sync callbacks ((e) => arr.push(e)) resolve immediately and pay no overhead; async callbacks (async (e) => โฆ) throttle the read loop, which is howscanand the transfer pipeline avoid OOM on multi-million-event sources. - Lease exclusivity: a successful
claimreturns leases that no concurrentclaim()can return again until released byack/block/timeout. - Tombstone semantics: a tombstone event is a regular event with
name === TOMBSTONE_EVENT. Adapters don't need to know what it means โ the framework'saction()reads the head event to decide. Adapters just need to return tombstones in queries like any other event.
Concrete adaptersโ
| Adapter | Where | Use case |
|---|---|---|
InMemoryStore | libs/act/src/adapters/in-memory-store.ts | Tests, single-process dev |
PostgresStore | libs/act-pg/src/PostgresStore.ts | Production multi-process |
SqliteStore | libs/act-sqlite/src/SqliteStore.ts | Embedded, single-node |
What the framework does NOT promise the adapterโ
- Connection pooling โ the adapter implements it (PG:
pg.Pool; SQLite: libSQL's built-in) - Transactions โ the adapter wraps multi-step operations as needed
- Schema migration โ adapters define their own DDL in
seed(); users run it explicitly - Auth/connection strings โ adapter constructor takes a config; framework doesn't inspect
Cache contractโ
interface Cache extends Disposable {
get<TState>(stream): Promise<CacheEntry<TState> | undefined>;
set<TState>(stream, entry): Promise<void>;
invalidate(stream): Promise<void>;
clear(): Promise<void>;
}
interface CacheEntry<TState> {
readonly state: TState;
readonly version: number;
readonly event_id: number;
readonly patches: number;
readonly snaps: number;
}
Invariantsโ
getis a hint, not a contract: the cache may return undefined at any time (eviction, network failure for a Redis-backed adapter, cold start). The framework treatsundefinedthe same as a logical miss and falls back to store replay.setis best-effort: failures are logged but don't propagate. The cache is an optimization, not source of truth.invalidateshould be reliable: when called afterConcurrencyError, the framework relies on the entry being gone. A failedinvalidatefollowed by agetreturning the old entry would surface stale state. Adapters should treat this as a critical path.- Async by design: the interface is async even for in-memory implementations. Don't optimize away the async โ Redis/external caches need it.
Concrete adaptersโ
| Adapter | Where | Use case |
|---|---|---|
InMemoryCache | libs/act/src/adapters/in-memory-cache.ts | Single-process; LRU, default maxSize: 1000 |
For distributed deployments, a Redis-backed adapter is the natural extension. Not provided in this repo because Redis-vs-Memcached-vs-other choice is app-specific.
Logger contractโ
interface Logger extends Disposable {
level: string;
// Each level overloads on (obj, msg?) and (msg) โ see ports.ts
fatal(obj: unknown, msg?: string): void;
fatal(msg: string): void;
// ... error, warn, info, debug, trace follow the same pair of overloads
child(bindings: Record<string, unknown>): Logger;
}
Invariantsโ
- No-throw: log calls must never throw. A misbehaving logger crashing the framework is the classic operability footgun.
- Level gating: levels above
levelshould be no-ops. Thetracingmodule checkslogger.level === "trace"to decide whether to instrument event-sourcing and drain ops with breadcrumb logs. Lying about the level disables tracing silently. child(bindings)returns a logger that forwards to the same sink with merged bindings. Used byAct.create_correlationsand similar to add a per-instance binding (e.g.,correlationId).
Concrete adaptersโ
| Adapter | Where | Use case |
|---|---|---|
ConsoleLogger | libs/act/src/adapters/console-logger.ts | Default. JSON in production, colorized human-readable in dev. Zero deps. |
PinoLogger | libs/act-pino/src/index.ts | Production deployments using pino's transport ecosystem. |
Wiring it together โ a minimal appโ
import { act, store, cache, log, dispose } from "@rotorsoft/act";
import { PostgresStore } from "@rotorsoft/act-pg";
import { InMemoryCache } from "@rotorsoft/act"; // re-exported from main
import { PinoLogger } from "@rotorsoft/act-pino";
// 1. Wire ports BEFORE constructing Act
log(new PinoLogger({ level: "info" }));
store(new PostgresStore({ host: "...", database: "...", schema: "events", table: "events" }));
cache(new InMemoryCache({ maxSize: 5000 }));
// 2. Build the Act instance
const app = act()
.withState(...)
.build();
// 3. Run as normal
await app.do("...", target, payload);
If any port is left to default, the framework wires the in-memory implementation for that port. Useful for tests; deliberate for production.
Scoped ports (per-Act)โ
The singleton path covers the common case: one Act instance per process, one store, one cache. When you need more than one Act in the same process โ each with its own store and/or cache โ pass an ActOptions.scoped bag at build time:
import { act, InMemoryCache } from "@rotorsoft/act";
import { PostgresStore } from "@rotorsoft/act-pg";
const tenantApp = act()
.withState(...)
.build({
scoped: {
store: new PostgresStore({ schema: "tenant_a" }),
cache: new InMemoryCache({ maxSize: 5000 }),
},
});
The framework threads the bag through AsyncLocalStorage and wraps every public Act method (do, load, query, drain, settle, close, ...) so internal store()/cache() calls resolve to the scoped ports transparently. Adapters are unchanged. Both store and cache are required together โ sharing a single cache across two distinct stores would collide on stream-keyed entries.
The shared-builder pattern (multi-tenant, A/B testing)โ
For more than a couple of Acts โ multi-tenant SaaS, parallel test workers, side-by-side store experiments โ hold the builder in a constant and call .build({ scoped: ... }) once per tenant. The builder is reusable: the first build performs one-time work (projection merge, deprecation scan, startup advisory) and subsequent builds reuse the merged registry to produce independent Acts.
import { act, InMemoryCache, projection, state } from "@rotorsoft/act";
import { PostgresStore } from "@rotorsoft/act-pg";
// Compose the blueprint once โ no `.build()` yet.
const tenantBuilder = act()
.withState(Order)
.withState(Customer)
.withProjection(OrderProjection)
.on("OrderPlaced").do(reduceInventory).to("inventory");
// One Act per tenant, each with its own store + cache.
const apps = new Map<string, ReturnType<typeof tenantBuilder.build>>();
for (const tenant of tenants) {
apps.set(
tenant,
tenantBuilder.build({
scoped: {
store: new PostgresStore({ schema: tenant }),
cache: new InMemoryCache({ maxSize: 5000 }),
},
})
);
}
// New tenants signing up mid-process can call `.build()` lazily too.
function onTenantSignup(tenant: string) {
apps.set(
tenant,
tenantBuilder.build({
scoped: {
store: new PostgresStore({ schema: tenant }),
cache: new InMemoryCache({ maxSize: 5000 }),
},
})
);
}
The per-Act mutable state (drain controller, correlate cycle, settle loop, notify subscription, lifecycle emitter) is constructed fresh on every .build(). The shared blueprint (registry, states map, batch handlers, deprecation set) is read-only post-build and is passed by reference to each Act โ multi-tenant memory cost is dominated by the per-Act mutable state, not by N copies of the registry.
A/B store experiments are the same pattern with tenants replaced by the experiment arms โ apps.set("control", build({scoped: oldStore + oldCache})) and apps.set("candidate", build({scoped: newStore + newCache})).
When this is necessaryโ
Concrete scenarios:
- Multi-tenant SaaS in one process. Each tenant gets a dedicated store (e.g., per-schema
PostgresStoreon a shared host, or one DB per tenant) and a dedicated cache. The application code stays singleton-style โ no parameter threading โ because internals readstore()/cache()and the ALS context dispatches to the right tenant on every call. - Parallel test workers in one process. Vitest's
--threads=falseworker model and integration tests that want strict isolation without spinning up a process per test. Each test builds its own Act with a freshInMemoryStore+InMemoryCache, and concurrent test bodies don't leak through the singleton. - Hybrid storage per bounded context. A monolith where the "orders" context lives in Postgres but "audit" lives in SQLite (or vice versa). Each bounded context gets its own Act bound to its own backing store. Reactions across contexts go through whatever cross-process mechanism the operator wires (HTTP, message bus, or
Store.notifyif both speak the same protocol). - Side-by-side store experiments. Running an existing Act on
PostgresStoreand a candidate Act on a new adapter in parallel to compare correctness or performance under live traffic โ both pinned to the same process so they see the same input stream.
When not to use itโ
- Single-tenant single-store apps. Use the singleton path. The scoped overlay is invisible against everyday work but it still adds an
AsyncLocalStorage.run()wrap on every method call; there's no reason to opt in if you don't need isolation. - Different defaults on the same store. If the goal is just "use a different cache size" or "use a different log level," configure that via the adapter constructor on the singleton path. Scoped ports are for distinct adapter instances.
Contracts and caveatsโ
- Notify subscriptions bind to the scoped store at construction.
Store.notifyis wired once per Act, againstoptions.scoped.storewhen scoped or the singleton otherwise. Same as the singleton case: late injection afterbuild()doesn't take effect. - Lifecycle is the operator's. Scoped adapters are not registered with the framework's
dispose()registry. You own them โ dispose them explicitly (or wrap your owndispose()callback that does). The singleton registry only tracks adapters installed viastore(adapter)/cache(adapter)/log(adapter). - Logger stays singleton.
ActOptions.scopeddoesn't include a logger; all Acts in a process sharelog(). Per-Act logger overrides aren't required by current scenarios โ add via child binding (log().child({ tenant: ... })) at the call site if you need correlation. - Performance. ALS adds no measurable overhead in modern Node โ the port getter is ~65 ns whether scoped or not, and
app.do()/app.load()show no difference between scoped and unscoped Acts. Seelibs/act/PERFORMANCE.mdยง Per-Act scoped ports.
IAct is a public surface tooโ
The three port contracts above are the infrastructure extension points โ replace the in-memory default with PostgreSQL, swap the LRU cache for Redis, drop in pino instead of console. But IAct itself (the orchestrator's public interface) is also load-bearing for a different class of extension: HTTP transports that wrap an Act registry without owning it.
@rotorsoft/act-http's auto-generated API surfaces (/trpc, /hono, /openapi) take a built IAct instance and walk its registry.actions to emit one route per action. Each route resolves an actor + stream from the request and calls app.do(action, target, payload) โ app.do's signature ((action: string, target: Target, payload: unknown) => Promise<Snapshot[]>) is the contract those generators depend on. Same for app.query, app.query_array, app.load, and the registry shape on app.registry.actions.
That makes IAct part of the public surface for the package as much as the port contracts are. STABILITY.md already covers it: libs/act/src/act.ts (the IAct interface and lifecycle event shapes) is listed alongside libs/act/src/types/ports.ts as a charter-covered surface. Changes to IAct.do / query / query_array / registry.actions shape need the same additive-vs-breaking analysis as a port-contract change, with the same migration-note discipline when they're breaking. See the auto-generated API guide for the consumer side.
Pointersโ
libs/act/src/ports.tsโport()factory and the three default portslibs/act/src/types/ports.tsโStore,Cache,Logger,Disposablecontractslibs/act/src/adapters/โ default in-memory implementations of all threelibs/act-pg/src/PostgresStore.ts,libs/act-sqlite/src/SqliteStore.ts,libs/act-pino/src/index.tsโ production adapterslibs/act-pg/test/stress/โ multi-process stress harness exercising the Store contract under contention; useful as a worked example of which invariants the framework actually depends on