@rotorsoft/act 0.44.0 → 0.46.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.
- package/README.md +87 -379
- package/dist/.tsbuildinfo +1 -1
- package/dist/@types/act.d.ts +93 -5
- package/dist/@types/act.d.ts.map +1 -1
- package/dist/@types/adapters/console-logger.d.ts.map +1 -1
- package/dist/@types/adapters/in-memory-store.d.ts +4 -1
- package/dist/@types/adapters/in-memory-store.d.ts.map +1 -1
- package/dist/@types/builders/act-builder.d.ts +33 -9
- package/dist/@types/builders/act-builder.d.ts.map +1 -1
- package/dist/@types/builders/slice-builder.d.ts +23 -8
- package/dist/@types/builders/slice-builder.d.ts.map +1 -1
- package/dist/@types/internal/audit.d.ts +95 -0
- package/dist/@types/internal/audit.d.ts.map +1 -0
- package/dist/@types/internal/build-classify.d.ts +20 -0
- package/dist/@types/internal/build-classify.d.ts.map +1 -1
- package/dist/@types/internal/correlate-cycle.d.ts +1 -0
- package/dist/@types/internal/correlate-cycle.d.ts.map +1 -1
- package/dist/@types/internal/drain-cycle.d.ts +43 -3
- package/dist/@types/internal/drain-cycle.d.ts.map +1 -1
- package/dist/@types/internal/drain.d.ts +3 -1
- package/dist/@types/internal/drain.d.ts.map +1 -1
- package/dist/@types/internal/index.d.ts +4 -2
- package/dist/@types/internal/index.d.ts.map +1 -1
- package/dist/@types/internal/reactions.d.ts.map +1 -1
- package/dist/@types/internal/tracing.d.ts +51 -0
- package/dist/@types/internal/tracing.d.ts.map +1 -1
- package/dist/@types/ports.d.ts +10 -0
- package/dist/@types/ports.d.ts.map +1 -1
- package/dist/@types/test/sandbox.d.ts +1 -1
- package/dist/@types/test/sandbox.d.ts.map +1 -1
- package/dist/@types/types/audit.d.ts +126 -0
- package/dist/@types/types/audit.d.ts.map +1 -0
- package/dist/@types/types/index.d.ts +1 -0
- package/dist/@types/types/index.d.ts.map +1 -1
- package/dist/@types/types/ports.d.ts +9 -2
- package/dist/@types/types/ports.d.ts.map +1 -1
- package/dist/@types/types/reaction.d.ts +20 -2
- package/dist/@types/types/reaction.d.ts.map +1 -1
- package/dist/{chunk-VMX7RPTC.js → chunk-TZWDSNSN.js} +1 -1
- package/dist/{chunk-VMX7RPTC.js.map → chunk-TZWDSNSN.js.map} +1 -1
- package/dist/{chunk-LKRNWD7C.js → chunk-VC6MSVC3.js} +47 -12
- package/dist/chunk-VC6MSVC3.js.map +1 -0
- package/dist/index.cjs +1584 -886
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1538 -874
- package/dist/index.js.map +1 -1
- package/dist/test/index.cjs +52 -18
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.js +11 -11
- package/dist/test/index.js.map +1 -1
- package/dist/types/index.cjs.map +1 -1
- package/dist/types/index.js +1 -1
- package/package.json +2 -2
- package/dist/chunk-LKRNWD7C.js.map +0 -1
package/README.md
CHANGED
|
@@ -4,451 +4,159 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@rotorsoft/act)
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
_Event-sourcing framework for TypeScript — three primitives, Zod end to end, no broker required._
|
|
8
8
|
|
|
9
|
-
|
|
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
|
-
```
|
|
14
|
-
npm install @rotorsoft/act
|
|
15
|
-
# or
|
|
19
|
+
```bash
|
|
16
20
|
pnpm add @rotorsoft/act
|
|
17
21
|
```
|
|
18
22
|
|
|
19
|
-
|
|
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
|
|
25
|
+
## Quick start
|
|
22
26
|
|
|
23
|
-
```
|
|
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({
|
|
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
|
-
|
|
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: "
|
|
40
|
-
const
|
|
41
|
-
console.log(
|
|
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
|
-
|
|
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
|
-
|
|
64
|
+
### Slices and projections
|
|
47
65
|
|
|
48
|
-
|
|
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
|
|
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
|
|
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)
|
|
80
|
+
.withProjection(CounterProjection)
|
|
62
81
|
.on("Incremented")
|
|
63
|
-
.do(async (event, _stream, app) => { /* dispatch
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
-
|
|
97
|
+
store(new PostgresStore({ /* … */ }));
|
|
98
|
+
await store().seed();
|
|
360
99
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
105
|
+
See the [production checklist](https://rotorsoft.github.io/act-root/docs/guides/production-checklist) for the full pre-deploy walkthrough.
|
|
368
106
|
|
|
369
|
-
###
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
|
|
114
|
+
Same `load()` as everything else. The third parameter is a step-through callback that receives each intermediate snapshot during replay.
|
|
401
115
|
|
|
402
|
-
###
|
|
116
|
+
### Recovery loop (operating Act)
|
|
403
117
|
|
|
404
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
+
## Compatibility
|
|
434
130
|
|
|
435
|
-
|
|
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
|
-
|
|
137
|
+
## Stability
|
|
438
138
|
|
|
439
|
-
|
|
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
|
-
|
|
141
|
+
## Related packages
|
|
443
142
|
|
|
444
|
-
**
|
|
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
|
-
|
|
151
|
+
## Documentation
|
|
447
152
|
|
|
448
|
-
- **
|
|
449
|
-
- **
|
|
450
|
-
- **
|
|
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
|
-
|
|
162
|
+
MIT
|