@rotorsoft/act 0.44.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +87 -379
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/@types/act.d.ts +43 -5
  4. package/dist/@types/act.d.ts.map +1 -1
  5. package/dist/@types/adapters/console-logger.d.ts.map +1 -1
  6. package/dist/@types/adapters/in-memory-store.d.ts +4 -1
  7. package/dist/@types/adapters/in-memory-store.d.ts.map +1 -1
  8. package/dist/@types/builders/act-builder.d.ts +33 -9
  9. package/dist/@types/builders/act-builder.d.ts.map +1 -1
  10. package/dist/@types/builders/slice-builder.d.ts +23 -8
  11. package/dist/@types/builders/slice-builder.d.ts.map +1 -1
  12. package/dist/@types/internal/build-classify.d.ts +20 -0
  13. package/dist/@types/internal/build-classify.d.ts.map +1 -1
  14. package/dist/@types/internal/correlate-cycle.d.ts +1 -0
  15. package/dist/@types/internal/correlate-cycle.d.ts.map +1 -1
  16. package/dist/@types/internal/drain-cycle.d.ts +43 -3
  17. package/dist/@types/internal/drain-cycle.d.ts.map +1 -1
  18. package/dist/@types/internal/drain.d.ts +3 -1
  19. package/dist/@types/internal/drain.d.ts.map +1 -1
  20. package/dist/@types/internal/index.d.ts +3 -2
  21. package/dist/@types/internal/index.d.ts.map +1 -1
  22. package/dist/@types/internal/reactions.d.ts.map +1 -1
  23. package/dist/@types/internal/tracing.d.ts +51 -0
  24. package/dist/@types/internal/tracing.d.ts.map +1 -1
  25. package/dist/@types/ports.d.ts +10 -0
  26. package/dist/@types/ports.d.ts.map +1 -1
  27. package/dist/@types/test/sandbox.d.ts +1 -1
  28. package/dist/@types/test/sandbox.d.ts.map +1 -1
  29. package/dist/@types/types/ports.d.ts +9 -2
  30. package/dist/@types/types/ports.d.ts.map +1 -1
  31. package/dist/@types/types/reaction.d.ts +20 -2
  32. package/dist/@types/types/reaction.d.ts.map +1 -1
  33. package/dist/{chunk-LKRNWD7C.js → chunk-PGTC7VOC.js} +46 -11
  34. package/dist/chunk-PGTC7VOC.js.map +1 -0
  35. package/dist/index.cjs +1139 -884
  36. package/dist/index.cjs.map +1 -1
  37. package/dist/index.js +1097 -876
  38. package/dist/index.js.map +1 -1
  39. package/dist/test/index.cjs +45 -11
  40. package/dist/test/index.cjs.map +1 -1
  41. package/dist/test/index.js +3 -3
  42. package/dist/test/index.js.map +1 -1
  43. package/package.json +2 -2
  44. package/dist/chunk-LKRNWD7C.js.map +0 -1
package/README.md CHANGED
@@ -4,451 +4,159 @@
4
4
  [![NPM Downloads](https://img.shields.io/npm/dm/@rotorsoft/act.svg)](https://www.npmjs.com/package/@rotorsoft/act)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
 
7
- [Act](../../README.md) core libraryEvent Sourcing + CQRS framework for TypeScript, built around DDD aggregates and reaction-driven workflows.
7
+ _Event-sourcing framework for TypeScript three primitives, Zod end to end, no broker required._
8
8
 
9
- > **Stability:** Public API governed by the [Act Stability Charter](../../STABILITY.md). Charter takes effect at 1.0 (gated on [milestone 1.0](https://github.com/Rotorsoft/act-root/milestone/1)).
9
+ ## Why this package
10
+
11
+ This is the framework core: the builders (`state`, `slice`, `projection`, `act`), the port interfaces (`Store`, `Cache`, `Logger`) with bundled in-memory implementations, the orchestrator that runs the correlate → drain loop, and the snapshot/cache layer that keeps `load()` fast on long streams. Around three primitives — **actions** (the changes you want to make), **state** (the data you care about), and **reactions** (what happens as a result) — it provides input validation against Zod schemas, optimistic-concurrency commit, derived state via patch reducers, fan-out reactions with backoff and dead-lettering, blocked-stream recovery, time-travel queries against the same log.
12
+
13
+ Your domain stays in TypeScript; your schemas stay in Zod. Pick a store at bootstrap — Postgres (`@rotorsoft/act-pg`), SQLite (`@rotorsoft/act-sqlite`), or the bundled in-memory default — and the application code stays the same. The published surface is stable under [SemVer](../../STABILITY.md) at 1.0.
14
+
15
+ For the project-level overview, see the [root README](../../README.md).
10
16
 
11
17
  ## Installation
12
18
 
13
- ```sh
14
- npm install @rotorsoft/act
15
- # or
19
+ ```bash
16
20
  pnpm add @rotorsoft/act
17
21
  ```
18
22
 
19
- **Requirements:** Node.js >= 22.18.0
23
+ For production, also install one of the durable stores: [`@rotorsoft/act-pg`](https://www.npmjs.com/package/@rotorsoft/act-pg) (Postgres) or [`@rotorsoft/act-sqlite`](https://www.npmjs.com/package/@rotorsoft/act-sqlite) (SQLite). The bundled `InMemoryStore` is used by default and is intended for development and tests.
20
24
 
21
- ## Quick Start
25
+ ## Quick start
22
26
 
23
- ```typescript
27
+ ```ts
24
28
  import { act, state } from "@rotorsoft/act";
25
29
  import { z } from "zod";
26
30
 
27
31
  const Counter = state({ Counter: z.object({ count: z.number() }) })
28
32
  .init(() => ({ count: 0 }))
29
33
  .emits({ Incremented: z.object({ amount: z.number() }) })
30
- .patch({ // optional only for events needing custom reducers (passthrough is the default)
31
- Incremented: ({ data }, state) => ({ count: state.count + data.amount }),
32
- })
34
+ .patch({ Incremented: ({ data }, s) => ({ count: s.count + data.amount }) })
33
35
  .on({ increment: z.object({ by: z.number() }) })
34
- .emit((action) => ["Incremented", { amount: action.by }])
36
+ .emit((action) => ["Incremented", { amount: action.by }])
35
37
  .build();
36
38
 
37
39
  const app = act().withState(Counter).build();
38
40
 
39
- await app.do("increment", { stream: "counter1", actor: { id: "1", name: "User" } }, { by: 5 });
40
- const snapshot = await app.load(Counter, "counter1");
41
- console.log(snapshot.state.count); // 5
41
+ await app.do("increment", { stream: "counter1", actor: { id: "1", name: "u" } }, { by: 5 });
42
+ const snap = await app.load(Counter, "counter1");
43
+ console.log(snap.state); // { count: 5 }
42
44
  ```
43
45
 
44
- ## Projections & Slices
46
+ Define state, declare actions, dispatch, load. Everything else — projections, reactions, slices, cross-process drain, time-travel — is more of the same builder calls.
47
+
48
+ ## API
49
+
50
+ Top-level exports:
51
+
52
+ - **Builders** — `state()`, `slice()`, `projection()`, `act()` build the domain. `withState`, `withSlice`, `withProjection` compose them.
53
+ - **Ports** — `store()`, `cache()`, `log()` are first-call-wins singletons; pass an adapter on first call to override the default. `dispose()` registers shutdown callbacks.
54
+ - **`Act` orchestrator** — `do`, `load`, `query`, `query_array`, `query_streams`, `query_stats`, `drain`, `settle`, `correlate`, `reset`, `unblock`, `blocked_streams`, `close` plus lifecycle events (`committed`, `notified`, `settled`, `blocked`, `closed`, …).
55
+ - **Errors** — `ValidationError`, `InvariantError`, `ConcurrencyError`, `StreamClosedError`, `NonRetryableError` plus the `Errors` constants for string-matching.
56
+ - **In-memory adapters** — `InMemoryStore`, `InMemoryCache`, `ConsoleLogger`.
57
+ - **Constants** — `SNAP_EVENT`, `TOMBSTONE_EVENT`.
58
+ - **Types** — full re-export of port interfaces, builder result types, lifecycle event payloads.
59
+
60
+ Full type reference: [typedoc](https://rotorsoft.github.io/act-root/docs/api/).
61
+
62
+ ## Common patterns
45
63
 
46
- Use `projection()` to build read-model updaters and `slice()` for vertical slice architecture. Use `.withState()`, `.withSlice()`, and `.withProjection()` to compose them:
64
+ ### Slices and projections
47
65
 
48
- ```typescript
66
+ `slice()` groups partial state with scoped reactions (vertical-slice architecture); `projection()` builds read-model updaters. Compose with `.withSlice()` / `.withProjection()`:
67
+
68
+ ```ts
49
69
  import { projection, slice } from "@rotorsoft/act";
50
70
 
51
- // Projection — read-model updater, handlers receive (event, stream)
71
+ // Projection — read-model updater. Handlers receive (event, stream).
52
72
  const CounterProjection = projection("counters")
53
73
  .on({ Incremented: z.object({ amount: z.number() }) })
54
74
  .do(async ({ stream, data }) => { /* update read model */ })
55
75
  .build();
56
76
 
57
- // Slice — partial state + scoped reactions, handlers receive (event, stream, app)
58
- // Projections can be embedded in slices when their events are a subset of the slice's events
77
+ // Slice — partial state + scoped reactions. Handlers receive (event, stream, app).
59
78
  const CounterSlice = slice()
60
79
  .withState(Counter)
61
- .withProjection(CounterProjection) // embed projection (events must be subset of slice events)
80
+ .withProjection(CounterProjection)
62
81
  .on("Incremented")
63
- .do(async (event, _stream, app) => { /* dispatch actions via app */ })
82
+ .do(async (event, _stream, app) => { /* dispatch via app */ })
64
83
  .to("counter-target")
65
84
  .build();
66
85
 
67
- // Standalone projections work at the act() level for cross-slice events
68
86
  const app = act().withSlice(CounterSlice).build();
69
87
  ```
70
88
 
71
- ## Related
72
-
73
- - [@rotorsoft/act-pg](https://www.npmjs.com/package/@rotorsoft/act-pg) - PostgreSQL adapter for production deployments
74
- - [@rotorsoft/act-pino](https://www.npmjs.com/package/@rotorsoft/act-pino) - Pino logger adapter
75
- - [Full Documentation](https://rotorsoft.github.io/act-root/)
76
- - [API Reference](https://rotorsoft.github.io/act-root/docs/api/)
77
- - [Examples](https://github.com/rotorsoft/act-root/tree/master/packages)
78
-
79
- ## Performance
80
-
81
- - [PERFORMANCE.md](./PERFORMANCE.md) — historical optimizations, batched projection replay, and the **[Reaction latency](./PERFORMANCE.md#reaction-latency-act-103)** section answering "how long from `do()` to reaction firing?"
82
- - [BENCH.md](../../BENCH.md) — index of every benchmark in the workspace with run commands and current numbers.
83
-
84
- ---
85
-
86
- ## Event Store
87
-
88
- The event store serves as the single source of truth for system state, persisting all changes as immutable events. It provides both durable storage and a queryable event history, enabling replayability, debugging, and distributed event-driven processing.
89
-
90
- ### Append-Only, Immutable Event Log
91
-
92
- Unlike traditional databases that update records in place, the event store follows an append-only model:
93
-
94
- - All state changes are recorded as new events — past data is never modified.
95
- - Events are immutable, providing a complete historical record.
96
- - Each event is time-stamped and versioned, allowing state reconstruction at any point in time.
97
-
98
- This immutability is critical for auditability, debugging, and consistent state reconstruction across distributed systems.
99
-
100
- ### Event Streams
101
-
102
- Events are grouped into streams, each representing a unique entity or domain process:
103
-
104
- - Each entity instance (e.g., a user, order, or transaction) has its own stream.
105
- - Events within a stream maintain strict ordering for correct state replay.
106
- - Streams are created dynamically as new entities appear.
107
-
108
- For example, an Order aggregate might have a stream containing:
109
-
110
- 1. `OrderCreated`
111
- 2. `OrderItemAdded`
112
- 3. `OrderItemRemoved`
113
- 4. `OrderShipped`
114
-
115
- Reconstructing the order's state means replaying these events in sequence, producing a deterministic result.
116
-
117
- ### Optimistic Concurrency
118
-
119
- Each event stream maintains a version number for conflict detection:
120
-
121
- - When committing events, the system verifies the stream's version matches the expected version.
122
- - If another process has written events in the meantime, a `ConcurrencyError` is thrown.
123
- - The caller can retry with the latest stream state, preventing lost updates.
124
-
125
- This ensures strong consistency without heavyweight locks.
126
-
127
- ```typescript
128
- // Version is tracked automatically — concurrent writes to the same stream are detected
129
- await app.do("increment", { stream: "counter1", actor }, { by: 1 });
130
- ```
131
-
132
- ### Querying
133
-
134
- Events can be retrieved in two ways:
135
-
136
- - **Load** — Fetch and replay all events for a given stream, reconstructing its current state:
137
- ```typescript
138
- const snapshot = await app.load(Counter, "counter1");
139
- ```
140
- - **Query** — Filter events by stream, name, time range, correlation ID, or position, with support for forward and backward traversal:
141
- ```typescript
142
- const events = await app.query_array({ stream: "counter1", names: ["Incremented"], limit: 10 });
143
- ```
144
-
145
- ### Snapshots
146
-
147
- Replaying all events from the beginning for every request can be expensive for long-lived streams. Act supports configurable snapshotting:
148
-
149
- ```typescript
150
- const Account = state({ Account: schema })
151
- // ...
152
- .snap((snap) => snap.patchCount >= 10) // snapshot every 10 events
153
- .build();
154
- ```
155
-
156
- When loading state, the system first loads the latest snapshot and replays only the events that came after it. For example, instead of replaying 1,000 events for an account balance, the system loads a snapshot and applies only the last few transactions.
157
-
158
- ### Storage Backends
159
-
160
- The event store uses a port/adapter pattern, making it easy to swap implementations:
161
-
162
- - **InMemoryStore** (included) — Fast, ephemeral storage for development and testing.
163
- - **[PostgresStore](https://www.npmjs.com/package/@rotorsoft/act-pg)** — Production-ready with ACID guarantees, connection pooling, and distributed processing.
164
-
165
- ```typescript
166
- import { store } from "@rotorsoft/act";
167
- import { PostgresStore } from "@rotorsoft/act-pg";
168
-
169
- // Development: in-memory (default)
170
- const s = store();
171
-
172
- // Production: inject PostgreSQL
173
- store(new PostgresStore({ host: "localhost", database: "myapp", user: "postgres", password: "secret" }));
174
- ```
89
+ Standalone projections (cross-slice events) work at the `act()` level via `.withProjection()`.
175
90
 
176
- Custom store implementations must fulfill the `Store` interface contract (see [CLAUDE.md](../../CLAUDE.md) or the source for details).
177
-
178
- ### Cache
179
-
180
- Cache is always-on with `InMemoryCache` as the default. It avoids full event replay on every `load()` by storing the latest state checkpoint in memory. On `load()`, the cache is checked first — only events committed after the cached position are replayed from the store. Actions update the cache automatically after each successful commit and invalidate on concurrency errors.
181
-
182
- ```typescript
183
- import { cache } from "@rotorsoft/act";
184
-
185
- // Cache is active by default (InMemoryCache, LRU, maxSize 1000)
186
- // load() and action() use it transparently — no setup needed
187
-
188
- // Replace with a custom adapter (e.g., Redis) for distributed caching:
189
- cache(new RedisCache({ url: "redis://localhost:6379" }));
190
- ```
191
-
192
- The `Cache` interface is async, so you can implement adapters backed by Redis or other external caches. `InMemoryCache` is included as a fast, in-process LRU implementation.
193
-
194
- ### Logger
195
-
196
- Logging uses the same port/adapter pattern. The default `ConsoleLogger` emits JSON lines in production (compatible with GCP, AWS CloudWatch, Datadog) and colorized output in development — zero dependencies.
197
-
198
- ```typescript
199
- import { log } from "@rotorsoft/act";
200
-
201
- const logger = log(); // ConsoleLogger (default)
202
- logger.info("Application started");
203
- ```
204
-
205
- For pino, inject the adapter from `@rotorsoft/act-pino`:
206
-
207
- ```typescript
208
- import { log } from "@rotorsoft/act";
209
- import { PinoLogger } from "@rotorsoft/act-pino";
210
-
211
- log(new PinoLogger({ level: "debug", pretty: true }));
212
- ```
213
-
214
- Custom logger implementations must fulfill the `Logger` interface (extends `Disposable` with `fatal`, `error`, `warn`, `info`, `debug`, `trace`, and `child` methods).
215
-
216
- #### Snapshots vs Cache
217
-
218
- Cache and snapshots are the same checkpoint pattern at different layers:
219
-
220
- - **Cache** (in-memory) — checked first on every `load()`. Eliminates store round-trips entirely on warm hits.
221
- - **Snapshots** (in-store) — written to the event store as `__snapshot__` events. Used as a fallback on cache miss (cold start, eviction, process restart) to avoid replaying the entire event stream.
222
-
223
- On cache hit, snapshot events in the store are skipped (`with_snaps: false`). On cache miss, the store is queried with `with_snaps: true` to find the latest snapshot and replay only events after it.
224
-
225
- ### Performance Considerations
226
-
227
- - **Cache is always-on** — warm reads skip the store entirely, delivering consistent throughput (7-46x faster than uncached). No configuration needed.
228
- - **Use snapshots for cold-start resilience** — on process restart or LRU eviction, snaps limit how much of the event stream must be replayed. Set `.snap((s) => s.patches >= 50)` for most use cases.
229
- - **Cache invalidation is automatic** — concurrency errors (`ERR_CONCURRENCY`) invalidate the stale cache entry, forcing a fresh load from the store on the next access.
230
- - **Snap writes are fire-and-forget** — `snap()` commits to the store asynchronously after `action()` returns. The cache is updated synchronously within `action()`, so subsequent reads see the post-snap state immediately without waiting for the store write.
231
- - **Atomic claim eliminates poll→lease overhead** — `claim()` fuses discovery and locking into a single SQL transaction using `FOR UPDATE SKIP LOCKED`, saving one round-trip per drain cycle and eliminating contention between workers.
232
- - **Watermark-aware claiming** — `claim()` skips caught-up streams (no pending events), focusing drain cycles on active work only. Up to 8x faster when most streams are idle.
233
- - Events are indexed by stream and version for fast lookups, with additional indexes on timestamps and correlation IDs.
234
- - The PostgreSQL adapter supports connection pooling and partitioning for high-volume deployments.
235
-
236
- For detailed benchmark data and performance evolution history, see [PERFORMANCE.md](PERFORMANCE.md).
237
-
238
- ## Event-Driven Processing
239
-
240
- Act handles event-driven workflows through atomic stream claiming and correlation, ensuring ordered, non-duplicated event processing without external message queues. The event store itself acts as the message backbone — events are written once and consumed by multiple independent reaction handlers.
241
-
242
- ### Reactions
243
-
244
- Reactions are asynchronous handlers triggered by events. They can update other state streams, trigger external integrations, or drive cross-aggregate workflows:
245
-
246
- ```typescript
247
- const app = act()
248
- .withState(Account)
249
- .withState(AuditLog)
250
- .on("Deposited")
251
- .do((event) => [{ name: "LogEntry", data: { message: `Deposit: ${event.data.amount}` } }])
252
- .to((event) => `audit-${event.stream}`) // resolver determines target stream
253
- .build();
254
- ```
255
-
256
- Resolvers dynamically determine which stream a reaction targets, enabling flexible event routing without hardcoded dependencies. They can include source regex patterns to limit which streams trigger the reaction.
257
-
258
- ### Stream Claiming
259
-
260
- Rather than processing events immediately, Act uses an atomic claim mechanism to coordinate distributed consumers. The `claim()` method atomically discovers and locks streams in a single operation using PostgreSQL's `FOR UPDATE SKIP LOCKED` pattern — competing consumers never block each other, and locked rows are silently skipped. This is the same pattern used by pgBoss, Graphile Worker, and other production job queues.
261
-
262
- - **Per-stream ordering** — Events within a stream are processed sequentially.
263
- - **Temporary ownership** — Claims expire after a configurable duration, allowing re-processing if a consumer fails.
264
- - **Zero-contention** — `FOR UPDATE SKIP LOCKED` means workers never block each other; locked rows are silently skipped.
265
- - **Backpressure** — Only a limited number of claims can be active at a time, preventing consumer overload.
266
-
267
- If a claim expires due to failure, the stream is automatically re-claimed by another consumer, ensuring no event is permanently lost.
268
-
269
- ### Event Correlation
270
-
271
- Act tracks causation chains across actions and reactions using correlation metadata:
272
-
273
- - Each action/event carries a `correlation` ID (request trace) and `causation` ID (what triggered it).
274
- - `app.correlate()` scans events, discovers new target streams via reaction resolvers, and registers them with `subscribe()`. It returns `{ subscribed, last_id }` where `subscribed` is the count of newly registered streams.
275
- - This enables full workflow tracing — from the initial user action through every downstream reaction.
276
-
277
- ```typescript
278
- // Correlate events to discover and subscribe new streams for processing
279
- const { subscribed, last_id } = await app.correlate();
280
-
281
- // Or run periodic background correlation
282
- app.start_correlations();
283
- ```
284
-
285
- ### Parallel Execution with Retry and Blocking
286
-
287
- While events within a stream are processed in order, multiple streams can be processed concurrently:
288
-
289
- - **Parallel handling** — Multiple streams are drained simultaneously for throughput.
290
- - **Retry with backoff** — Transient failures trigger retries before escalation.
291
- - **Stream blocking** — After exhausting retries, a stream is blocked to prevent cascading errors. Blocked streams can be inspected and unblocked manually.
292
-
293
- ### Draining
294
-
295
- The `drain` method processes pending reactions across all subscribed streams:
296
-
297
- ```typescript
298
- // Process pending reactions (synchronous, single cycle)
299
- await app.drain({ streamLimit: 100, eventLimit: 1000 });
300
-
301
- // Debounced correlate→drain for production (non-blocking, emits "settled" when done)
302
- app.settle();
303
-
304
- // Subscribe to the "settled" lifecycle event
305
- app.on("settled", (drain) => {
306
- // drain has { fetched, claimed, acked, blocked }
307
- // notify SSE clients, update caches, etc.
308
- });
309
- ```
310
-
311
- Drain cycles continue until all reactions have caught up to the latest events. Consumers only process new work — acknowledged events are skipped, and failed streams are re-claimed automatically.
312
-
313
- The `settle()` method is the recommended production pattern — it debounces rapid commits (10ms default), loops correlate→drain until a pass makes no progress (default `maxPasses: Infinity`, which acts only as a kill-switch for runaway reaction loops), and emits a `"settled"` event when done. A single `settle()` call after `app.reset(...)` fully catches up paginated streams.
314
-
315
- **Drain skip optimization:** At build time, Act classifies which event names have registered reactions. When `do()` commits events that have no reactions, `drain()` returns immediately — zero DB round-trips. This eliminates wasted claim/query/ack cycles for high-frequency events that don't need reaction processing. See [PERFORMANCE.md](PERFORMANCE.md) for benchmarks.
316
-
317
- **Batched projection replay:** Static-target projections can register a `.batch()` handler that receives all events in a single call, enabling bulk DB operations in one transaction. When defined, the batch handler replaces individual `.do()` handlers during drain — reducing N DB writes to 1. See [PERFORMANCE.md](PERFORMANCE.md) for benchmarks.
318
-
319
- ### Cross-Process Reactions (`Store.notify`)
320
-
321
- When two or more Act processes share a backing store, the second process has no in-process signal that the first committed. The default fallback is the polling/debounce path, which floors reaction latency at the poll interval. For lower latency, the configured store can implement the optional `Store.notify(handler)` hook; the orchestrator auto-wires the subscription at `build()` and wakes `settle()` immediately on remote commits.
322
-
323
- **Opt-in at the adapter level.** The cost (per-commit notification, dedicated DB connection per process) is wasted in single-instance deployments, so adapters default to off. Multi-process apps that need sub-poll wakeup enable it explicitly on every store instance:
324
-
325
- ```ts
326
- store(new PostgresStore({ ..., notify: true })); // opt in
327
- const app = act()
328
- .withState(Order)
329
- .on("OrderPlaced").do(reduceInventory).to("inventory")
330
- .build();
331
- // Worker B wakes within ~10 ms of Worker A's commit (vs. polling: ≥ poll interval).
332
-
333
- // Optional: tap the lifecycle event for fan-out (SSE, dashboards, audit)
334
- app.on("notified", (n) => sse.broadcast(n));
335
- ```
336
-
337
- Adapter status:
338
-
339
- - `PostgresStore` (`@rotorsoft/act-pg`) — implemented via `LISTEN`/`NOTIFY` on a per-`(schema, table)` channel.
340
- - `InMemoryStore` — not implemented; single-process, no remote writers.
341
- - `SqliteStore` (`@rotorsoft/act-sqlite`) — not implemented; single-node by design.
342
-
343
- Stores **self-filter** their own commits (per-instance UUID in the payload) so the `"notified"` lifecycle event surfaces only **cross-process** activity. Local commits already arm drain via `do()` — the notify path stays out of the local fast path.
344
-
345
- `notify` is a hint, not a contract: if the store doesn't implement it, or a notification is dropped, the existing debounce/poll path still drains correctly. Build-time contract: inject the configured store via `store(adapter)` *before* `act()...build()` — wiring binds at construction.
346
-
347
- ### Reaction Priority Lanes (ACT-102)
348
-
349
- When the worker is saturated (more lagging streams than `streamLimit` per cycle), priority biases which lagging stream gets the lease first:
91
+ ### Lifecycle wiring at bootstrap
350
92
 
351
93
  ```ts
352
- .on("OrderConfirmed")
353
- .do(sendCriticalNotification)
354
- .to({ target: "notifications-out", priority: 10 }) // jumps the lagging queue
355
- ```
356
-
357
- `claim()`'s lagging frontier orders by `priority DESC, at ASC`. Default priority is `0` — apps that don't opt in see no behavior change. **Per-stream event ordering is unchanged** — priority only biases *which streams claim() picks first*, never reorders events within a stream.
94
+ import { dispose, log, store } from "@rotorsoft/act";
95
+ import { PostgresStore } from "@rotorsoft/act-pg";
358
96
 
359
- Operators can override scheduling at runtime with `app.prioritize(filter, n)`. Filter shape mirrors `query_streams` (regex on `stream`/`source`, exact-match flags, `blocked` state). Sets the priority outright, ignoring the build-time max invariant — so it can decrease too:
97
+ store(new PostgresStore({ /* */ }));
98
+ await store().seed();
360
99
 
361
- ```ts
362
- await app.prioritize({ stream: "^proj-orders$", stream_exact: false }, 10);
363
- await app.prioritize({ source: "^audit-" }, -5);
364
- await app.prioritize({}, 0); // reset all to default
100
+ app.on("committed", () => app.settle()); // drain reactions on every commit
101
+ app.on("blocked", (xs) => log().error({ xs })); // page on blocked streams
102
+ dispose(async () => { /* your cleanup */ }); // wired into SIGINT/SIGTERM
365
103
  ```
366
104
 
367
- Only meaningful under saturation. With `streamLimit` ≥ candidate streams every cycle, every stream gets a slot every cycle and priority never binds. See [`@rotorsoft/act-pg/PERFORMANCE.md`](../act-pg/PERFORMANCE.md) for the ~11× speedup benchmark on tied-watermark replays under heavy contention.
105
+ See the [production checklist](https://rotorsoft.github.io/act-root/docs/guides/production-checklist) for the full pre-deploy walkthrough.
368
106
 
369
- ### Per-Act Scoped Ports (ACT-501)
370
-
371
- The singleton `store()` / `cache()` ports cover the common case: one Act per process. When you need multiple Acts in the same process — multi-tenant SaaS, parallel test workers, side-by-side store experiments — pass `ActOptions.scoped` at build time and the framework routes that Act's internal port reads to its own bag via `AsyncLocalStorage`. Adapters are unchanged.
372
-
373
- Hold the builder in a constant and call `.build()` once per tenant. The first build runs the one-time projection merge + deprecation scan; subsequent builds reuse the merged registry:
107
+ ### Time-travel
374
108
 
375
109
  ```ts
376
- import { act, InMemoryCache } from "@rotorsoft/act";
377
- import { PostgresStore } from "@rotorsoft/act-pg";
378
-
379
- const tenantBuilder = act()
380
- .withState(Order)
381
- .withProjection(OrderProjection)
382
- .on("OrderPlaced").do(reduceInventory).to("inventory");
383
-
384
- const apps = new Map<string, ReturnType<typeof tenantBuilder.build>>();
385
- for (const tenant of tenants) {
386
- apps.set(
387
- tenant,
388
- tenantBuilder.build({
389
- scoped: {
390
- store: new PostgresStore({ schema: tenant }),
391
- cache: new InMemoryCache({ maxSize: 5000 }),
392
- },
393
- })
394
- );
395
- }
396
-
397
- await apps.get("tenant_a")!.do("place", target, payload);
110
+ await app.load(Counter, "counter1", undefined, { before: 5000 }); // state at event id
111
+ await app.load(Counter, "counter1", undefined, { created_before: someDate }); // state at timestamp
398
112
  ```
399
113
 
400
- Both `store` and `cache` are required together — sharing a single cache across distinct stores would collide on stream-keyed entries. ALS overhead is essentially zero (~65 ns per `store()` read, scoped or not; no measurable difference in `app.do()` / `app.load()` throughput). See [PERFORMANCE.md § Per-Act scoped ports](./PERFORMANCE.md) for the bench and [`docs/architecture/extension-points.md`](../../docs/docs/architecture/extension-points.md) for the full pattern (use cases, contracts, caveats).
114
+ Same `load()` as everything else. The third parameter is a step-through callback that receives each intermediate snapshot during replay.
401
115
 
402
- ### Testing (ACT-503)
116
+ ### Recovery loop (operating Act)
403
117
 
404
- `@rotorsoft/act/test` exposes two thin helpers built on `ActOptions.scoped` for parallel-safe per-test isolation:
118
+ When a reaction handler fails past its retry budget (or throws `NonRetryableError`), the stream is blocked and stays out of `claim()` results. Operators:
405
119
 
406
120
  ```ts
407
- import { fixture, sandbox } from "@rotorsoft/act/test";
408
-
409
- // Common case — vitest fixture, auto-cleanup, supports test.concurrent
410
- const test = fixture(act().withState(Counter));
411
- test("increments", async ({ app }) => {
412
- await app.do("increment", { stream: "c", actor }, { by: 1 });
413
- });
414
-
415
- // Escape hatch — explicit lifecycle, multi-Act, beforeAll-shared, PG factory
416
- const { app, store, cache, dispose } = await sandbox(builder, {
417
- store: () => new PostgresStore({ schema: `t_${nanoid()}` }),
418
- });
121
+ const blocked = await app.blocked_streams();
122
+ // Inspect, fix the underlying cause, then:
123
+ await app.unblock(["webhooks-out-customer-42"]);
124
+ await app.unblock({ stream: "^webhooks-out-" }); // bulk
419
125
  ```
420
126
 
421
- See [`docs/concepts/testing.md`](../../docs/docs/concepts/testing.md) for the canonical pattern.
422
-
423
- ## Dual-Frontier Drain
424
-
425
- In event-sourced systems, consumers often subscribe to multiple event streams that advance at different rates: some produce bursts of events, while others stay idle for long periods. New streams can also be discovered while processing events from existing streams.
426
-
427
- Naive approaches have fundamental trade-offs:
428
-
429
- - Strictly serial processing across all streams blocks fast streams behind slow ones.
430
- - Fully independent processing risks inconsistent cross-stream states.
431
- - Prioritizing new streams over existing ones risks missing important events.
127
+ `unblock` resumes from where the stream stopped — it does **not** replay history. Use `app.reset(...)` only for projection rebuilds.
432
128
 
433
- Act addresses this with the **Dual-Frontier Drain** strategy.
129
+ ## Compatibility
434
130
 
435
- ### How It Works
131
+ - **Node**: >=22.18.0
132
+ - **Peer**: `zod` ^4.4.3
133
+ - **Bundled deps**: `@rotorsoft/act-patch` (state reducer)
134
+ - **Module formats**: ESM + CJS
135
+ - **TypeScript**: strict mode recommended for full inference
436
136
 
437
- Each drain cycle divides streams into two sets:
137
+ ## Stability
438
138
 
439
- - **Leading frontier** Streams already near the latest known event (the global frontier). These continue processing without waiting.
440
- - **Lagging frontier** — Streams that are behind or newly discovered. These are advanced quickly to catch up.
139
+ Public API governed by the [Act Stability Charter](../../STABILITY.md). The charter names exactly which surfaces are protected by SemVer (builders, `Act` interface, port interfaces, lifecycle event shapes, public type exports) and what's free to evolve (internal modules, performance characteristics, log formats). Breaking changes require a `BREAKING CHANGE:` commit footer and a written migration note. Charter takes effect at 1.0 (gated on [milestone 1.0](https://github.com/Rotorsoft/act-root/milestone/1)).
441
140
 
442
- **Fast-forwarding:** If a lagging stream has no matching events in the current window, its watermark is advanced using the leading frontier's position. This prevents stale streams from blocking global convergence.
141
+ ## Related packages
443
142
 
444
- **Dynamic correlation:** Event resolvers dynamically discover and add new streams as events arrive. Resolvers can include source regex patterns to limit which streams are matched. When a new matching stream is discovered, it joins the drain immediately.
143
+ - **[@rotorsoft/act-pg](https://www.npmjs.com/package/@rotorsoft/act-pg)** PostgreSQL store. Production default.
144
+ - **[@rotorsoft/act-sqlite](https://www.npmjs.com/package/@rotorsoft/act-sqlite)** — SQLite store. Single-node / edge.
145
+ - **[@rotorsoft/act-http](https://www.npmjs.com/package/@rotorsoft/act-http)** — `webhook` for outbound POST from reactions; `/sse` subpath for incremental state broadcast.
146
+ - **[@rotorsoft/act-pino](https://www.npmjs.com/package/@rotorsoft/act-pino)** — pino logger adapter.
147
+ - **[@rotorsoft/act-patch](https://www.npmjs.com/package/@rotorsoft/act-patch)** — immutable deep-merge patch utility used by state reducers.
148
+ - **[@rotorsoft/act-tck](https://www.npmjs.com/package/@rotorsoft/act-tck)** — conformance suite for `Store`/`Cache`/`Logger` adapters.
149
+ - **[@rotorsoft/act-diagram](https://www.npmjs.com/package/@rotorsoft/act-diagram)** — interactive SVG diagram of the domain model + `act` CLI.
445
150
 
446
- ### Why It Matters
151
+ ## Documentation
447
152
 
448
- - **Fast recovery** — Newly discovered or previously idle streams catch up quickly.
449
- - **No global blocking** — Fast streams are never paused to wait for slower ones.
450
- - **Eventual convergence** — All reactions end up aligned on the same global event position.
153
+ - **[Get started](https://rotorsoft.github.io/act-root/docs/intro)** — 5-minute walkthrough.
154
+ - **[Concepts](https://rotorsoft.github.io/act-root/docs/intro)** — state management, event sourcing, error handling, real-time, testing, configuration.
155
+ - **[Architecture](https://rotorsoft.github.io/act-root/docs/architecture)** — concurrency model, cache + snapshots, correlation + drain, cross-process reactions, priority lanes, close-cycle, schema evolution, extension points.
156
+ - **[Guides](https://rotorsoft.github.io/act-root/docs/intro)** — production checklist, projections to database, external integration, writing a custom store/cache/logger, contributing a new package, contracts CLI.
157
+ - **[PERFORMANCE.md](./PERFORMANCE.md)** — measured throughput numbers, optimization history, and the reaction-latency benchmark answering "how long from `do()` to reaction firing?"
158
+ - **[BENCH.md](../../BENCH.md)** — index of every benchmark in the workspace with run commands.
451
159
 
452
160
  ## License
453
161
 
454
- [MIT](https://github.com/rotorsoft/act-root/blob/master/LICENSE)
162
+ MIT