@rotorsoft/act 0.37.0 → 0.39.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/dist/index.js CHANGED
@@ -1,3 +1,24 @@
1
+ import {
2
+ ConsoleLogger,
3
+ ExitCodes,
4
+ InMemoryCache,
5
+ InMemoryStore,
6
+ LruSet,
7
+ PackageSchema,
8
+ SNAP_EVENT,
9
+ TOMBSTONE_EVENT,
10
+ cache,
11
+ config,
12
+ dispose,
13
+ disposeAndExit,
14
+ extend,
15
+ log,
16
+ port,
17
+ scoped,
18
+ sleep,
19
+ store,
20
+ validate
21
+ } from "./chunk-RHS57BUR.js";
1
22
  import {
2
23
  ActorSchema,
3
24
  CausationEventSchema,
@@ -14,844 +35,7 @@ import {
14
35
  ValidationError,
15
36
  ZodEmpty
16
37
  } from "./chunk-AGWZY6YT.js";
17
-
18
- // src/adapters/console-logger.ts
19
- var LEVEL_VALUES = {
20
- fatal: 60,
21
- error: 50,
22
- warn: 40,
23
- info: 30,
24
- debug: 20,
25
- trace: 10
26
- };
27
- var LEVEL_COLORS = {
28
- fatal: "\x1B[41m\x1B[37m",
29
- // white on red bg
30
- error: "\x1B[31m",
31
- // red
32
- warn: "\x1B[33m",
33
- // yellow
34
- info: "\x1B[32m",
35
- // green
36
- debug: "\x1B[36m",
37
- // cyan
38
- trace: "\x1B[90m"
39
- // gray
40
- };
41
- var RESET = "\x1B[0m";
42
- var noop = () => {
43
- };
44
- var ConsoleLogger = class _ConsoleLogger {
45
- level;
46
- _pretty;
47
- fatal;
48
- error;
49
- warn;
50
- info;
51
- debug;
52
- trace;
53
- constructor(options = {}) {
54
- const {
55
- level = "info",
56
- pretty = process.env.NODE_ENV !== "production",
57
- bindings
58
- } = options;
59
- this._pretty = pretty;
60
- this.level = level;
61
- const threshold = LEVEL_VALUES[level] ?? 30;
62
- const write = pretty ? this._prettyWrite.bind(this, bindings) : this._jsonWrite.bind(this, bindings);
63
- this.fatal = write.bind(this, "fatal", 60);
64
- this.error = threshold <= 50 ? write.bind(this, "error", 50) : noop;
65
- this.warn = threshold <= 40 ? write.bind(this, "warn", 40) : noop;
66
- this.info = threshold <= 30 ? write.bind(this, "info", 30) : noop;
67
- this.debug = threshold <= 20 ? write.bind(this, "debug", 20) : noop;
68
- this.trace = threshold <= 10 ? write.bind(this, "trace", 10) : noop;
69
- }
70
- /** No-op — `console.log` has no resources to release. */
71
- async dispose() {
72
- }
73
- /** @inheritDoc */
74
- child(bindings) {
75
- return new _ConsoleLogger({
76
- level: this.level,
77
- pretty: this._pretty,
78
- bindings
79
- });
80
- }
81
- _jsonWrite(bindings, level, _num, objOrMsg, msg) {
82
- let obj;
83
- let message;
84
- if (typeof objOrMsg === "string") {
85
- message = objOrMsg;
86
- obj = {};
87
- } else if (objOrMsg !== null && typeof objOrMsg === "object") {
88
- message = msg;
89
- obj = { ...objOrMsg };
90
- } else {
91
- message = msg;
92
- obj = { value: objOrMsg };
93
- }
94
- const entry = Object.assign({ level, time: Date.now() }, bindings, obj);
95
- if (message) entry.msg = message;
96
- let line;
97
- try {
98
- line = JSON.stringify(entry);
99
- } catch {
100
- line = JSON.stringify({
101
- level,
102
- time: entry.time,
103
- msg: message ?? "[unserializable]",
104
- unserializable: true
105
- });
106
- }
107
- process.stdout.write(line + "\n");
108
- }
109
- _prettyWrite(bindings, level, _num, objOrMsg, msg) {
110
- const color = LEVEL_COLORS[level];
111
- const tag = `${color}${level.toUpperCase().padEnd(5)}${RESET}`;
112
- const ts = (/* @__PURE__ */ new Date()).toISOString().slice(11, 23);
113
- let message;
114
- let data;
115
- if (typeof objOrMsg === "string") {
116
- message = objOrMsg;
117
- } else {
118
- message = msg ?? "";
119
- if (objOrMsg !== void 0 && objOrMsg !== null) {
120
- try {
121
- data = JSON.stringify(objOrMsg);
122
- } catch {
123
- data = "[unserializable]";
124
- }
125
- }
126
- }
127
- const bindStr = bindings && Object.keys(bindings).length ? ` ${JSON.stringify(bindings)}` : "";
128
- const parts = [ts, tag, message, data, bindStr].filter(Boolean);
129
- process.stdout.write(parts.join(" ") + "\n");
130
- }
131
- };
132
-
133
- // src/lru-map.ts
134
- var LruMap = class {
135
- constructor(_maxSize) {
136
- this._maxSize = _maxSize;
137
- }
138
- _entries = /* @__PURE__ */ new Map();
139
- get(key) {
140
- const v = this._entries.get(key);
141
- if (v === void 0) return void 0;
142
- this._entries.delete(key);
143
- this._entries.set(key, v);
144
- return v;
145
- }
146
- has(key) {
147
- return this._entries.has(key);
148
- }
149
- set(key, value) {
150
- this._entries.delete(key);
151
- if (this._entries.size >= this._maxSize) {
152
- const oldest = this._entries.keys().next().value;
153
- this._entries.delete(oldest);
154
- }
155
- this._entries.set(key, value);
156
- }
157
- delete(key) {
158
- return this._entries.delete(key);
159
- }
160
- clear() {
161
- this._entries.clear();
162
- }
163
- get size() {
164
- return this._entries.size;
165
- }
166
- };
167
- var LruSet = class {
168
- _map;
169
- constructor(maxSize) {
170
- this._map = new LruMap(maxSize);
171
- }
172
- has(value) {
173
- return this._map.has(value);
174
- }
175
- add(value) {
176
- this._map.set(value, true);
177
- }
178
- delete(value) {
179
- return this._map.delete(value);
180
- }
181
- clear() {
182
- this._map.clear();
183
- }
184
- get size() {
185
- return this._map.size;
186
- }
187
- };
188
-
189
- // src/adapters/in-memory-cache.ts
190
- var InMemoryCache = class {
191
- // CacheEntry<any> lets `get<TState>` and `set<TState>` flow without casts:
192
- // any is bidirectionally compatible with the per-call TState binding, while
193
- // the public Cache interface still presents a typed surface to callers.
194
- _entries;
195
- constructor(options) {
196
- this._entries = new LruMap(options?.maxSize ?? 1e3);
197
- }
198
- /** @inheritDoc */
199
- async get(stream) {
200
- return this._entries.get(stream);
201
- }
202
- /** @inheritDoc */
203
- async set(stream, entry) {
204
- this._entries.set(stream, entry);
205
- }
206
- /** @inheritDoc */
207
- async invalidate(stream) {
208
- this._entries.delete(stream);
209
- }
210
- /** @inheritDoc */
211
- async clear() {
212
- this._entries.clear();
213
- }
214
- /** @inheritDoc */
215
- async dispose() {
216
- this._entries.clear();
217
- }
218
- /** Current number of entries held by the LRU. */
219
- get size() {
220
- return this._entries.size;
221
- }
222
- };
223
-
224
- // src/utils.ts
225
- import { prettifyError, ZodError } from "zod";
226
-
227
- // src/config.ts
228
- import * as fs from "fs";
229
- import { z } from "zod";
230
- var PackageSchema = z.object({
231
- name: z.string().min(1),
232
- version: z.string().min(1),
233
- description: z.string().min(1).optional(),
234
- author: z.object({ name: z.string().min(1), email: z.string().optional() }).optional().or(z.string().min(1)).optional(),
235
- license: z.string().min(1).optional(),
236
- dependencies: z.record(z.string(), z.string()).optional()
237
- });
238
- var FALLBACK_PACKAGE = {
239
- name: "act-fallback",
240
- version: "0.0.0-fallback",
241
- description: "Synthetic fallback \u2014 package.json could not be loaded"
242
- };
243
- var getPackage = () => {
244
- try {
245
- const raw = fs.readFileSync("package.json");
246
- return JSON.parse(raw.toString());
247
- } catch (err) {
248
- pkgLoadError = err;
249
- return FALLBACK_PACKAGE;
250
- }
251
- };
252
- var pkgLoadError;
253
- var BaseSchema = PackageSchema.extend({
254
- env: z.enum(Environments),
255
- logLevel: z.enum(LogLevels),
256
- logSingleLine: z.boolean(),
257
- sleepMs: z.number().int().min(0).max(5e3)
258
- });
259
- var { NODE_ENV, LOG_LEVEL, LOG_SINGLE_LINE, SLEEP_MS } = process.env;
260
- var env = NODE_ENV || "development";
261
- var logLevel = LOG_LEVEL || (NODE_ENV === "test" ? "fatal" : NODE_ENV === "production" ? "info" : "trace");
262
- var logSingleLine = (LOG_SINGLE_LINE || "true") === "true";
263
- var sleepMs = parseInt(NODE_ENV === "test" ? "0" : SLEEP_MS ?? "100", 10);
264
- var pkg = getPackage();
265
- var _validated;
266
- var config = () => {
267
- if (!_validated) {
268
- _validated = extend(
269
- { ...pkg, env, logLevel, logSingleLine, sleepMs },
270
- BaseSchema
271
- );
272
- if (pkgLoadError) {
273
- const msg = pkgLoadError instanceof Error ? pkgLoadError.message : typeof pkgLoadError === "string" ? pkgLoadError : "unknown error";
274
- log().warn(
275
- `[act] Could not read package.json (${msg}); using synthetic name="${FALLBACK_PACKAGE.name}" version="${FALLBACK_PACKAGE.version}".`
276
- );
277
- pkgLoadError = void 0;
278
- }
279
- }
280
- return _validated;
281
- };
282
-
283
- // src/utils.ts
284
- var validate = (target, payload, schema) => {
285
- try {
286
- return schema ? schema.parse(payload) : payload;
287
- } catch (error) {
288
- if (error instanceof ZodError) {
289
- throw new ValidationError(target, payload, prettifyError(error));
290
- }
291
- throw new ValidationError(target, payload, error);
292
- }
293
- };
294
- var extend = (source, schema, target) => {
295
- const value = validate("config", source, schema);
296
- return { ...target, ...value };
297
- };
298
- async function sleep(ms) {
299
- return new Promise((resolve) => setTimeout(resolve, ms ?? config().sleepMs));
300
- }
301
-
302
- // src/adapters/in-memory-store.ts
303
- var InMemoryStream = class {
304
- constructor(stream, source, priority = 0) {
305
- this.stream = stream;
306
- this.source = source;
307
- this._priority = priority;
308
- }
309
- _at = -1;
310
- _retry = -1;
311
- _blocked = false;
312
- _error = "";
313
- _leased_by = void 0;
314
- _leased_until = void 0;
315
- _priority = 0;
316
- get priority() {
317
- return this._priority;
318
- }
319
- /**
320
- * Bump the priority via {@link subscribe}: keeps the maximum across
321
- * reactions so the highest-priority registrant wins.
322
- */
323
- bumpPriority(priority) {
324
- if (priority > this._priority) this._priority = priority;
325
- }
326
- /**
327
- * Set the priority outright via {@link prioritize}: operator
328
- * runtime override that ignores the build-time `max()` invariant.
329
- */
330
- setPriority(priority) {
331
- this._priority = priority;
332
- }
333
- get is_available() {
334
- return !this._blocked && (!this._leased_until || this._leased_until <= /* @__PURE__ */ new Date());
335
- }
336
- get at() {
337
- return this._at;
338
- }
339
- get retry() {
340
- return this._retry;
341
- }
342
- get blocked() {
343
- return this._blocked;
344
- }
345
- get error() {
346
- return this._error;
347
- }
348
- get leased_by() {
349
- return this._leased_by;
350
- }
351
- get leased_until() {
352
- return this._leased_until;
353
- }
354
- /**
355
- * Attempt to lease this stream for processing.
356
- * @param lease - The lease request.
357
- * @param millis - Lease duration in milliseconds.
358
- * @returns The granted lease or undefined if blocked.
359
- */
360
- lease(lease, millis) {
361
- if (millis > 0) {
362
- this._leased_by = lease.by;
363
- this._leased_until = new Date(Date.now() + millis);
364
- }
365
- this._retry = this._retry + 1;
366
- return {
367
- stream: this.stream,
368
- source: this.source,
369
- at: lease.at,
370
- by: lease.by,
371
- retry: this._retry,
372
- lagging: lease.lagging
373
- };
374
- }
375
- /**
376
- * Acknowledge completion of processing for this stream.
377
- * @param lease - The lease request.
378
- */
379
- ack(lease) {
380
- if (this._leased_by === lease.by) {
381
- this._leased_by = void 0;
382
- this._leased_until = void 0;
383
- this._at = lease.at;
384
- this._retry = -1;
385
- return {
386
- stream: this.stream,
387
- source: this.source,
388
- at: this._at,
389
- by: lease.by,
390
- retry: this._retry,
391
- lagging: lease.lagging
392
- };
393
- }
394
- }
395
- /**
396
- * Block a stream for processing after failing to process and reaching max retries with blocking enabled.
397
- * @param lease - The lease request.
398
- * @param error Blocked error message.
399
- */
400
- block(lease, error) {
401
- if (this._leased_by === lease.by) {
402
- this._blocked = true;
403
- this._error = error;
404
- return {
405
- stream: this.stream,
406
- source: this.source,
407
- at: this._at,
408
- by: this._leased_by,
409
- retry: this._retry,
410
- error: this._error,
411
- lagging: lease.lagging
412
- };
413
- }
414
- }
415
- /**
416
- * Reset this stream's watermark and state for replay. The retry counter
417
- * resets to -1 to match the constructor + ack() invariant ("released
418
- * stream"); the next claim() bumps it to 0 (first attempt).
419
- */
420
- reset() {
421
- this._at = -1;
422
- this._retry = -1;
423
- this._blocked = false;
424
- this._error = "";
425
- this._leased_by = void 0;
426
- this._leased_until = void 0;
427
- }
428
- };
429
- var InMemoryStore = class {
430
- // stored events
431
- _events = [];
432
- // stored stream positions and other metadata
433
- _streams = /* @__PURE__ */ new Map();
434
- // last committed version per stream — O(1) replacement for filter-on-commit
435
- _streamVersions = /* @__PURE__ */ new Map();
436
- // max non-snapshot event id per stream — drives the source-pattern probe in claim()
437
- // without scanning the full event log.
438
- _maxEventIdByStream = /* @__PURE__ */ new Map();
439
- // global max non-snapshot event id — fast pre-check for source-less streams in claim()
440
- _maxNonSnapEventId = -1;
441
- _resetIndexes() {
442
- this._events.length = 0;
443
- this._streamVersions.clear();
444
- this._maxEventIdByStream.clear();
445
- this._maxNonSnapEventId = -1;
446
- }
447
- /**
448
- * Dispose of the store and clear all events.
449
- * @returns Promise that resolves when disposal is complete.
450
- */
451
- async dispose() {
452
- await sleep();
453
- this._resetIndexes();
454
- }
455
- /**
456
- * Seed the store with initial data (no-op for in-memory).
457
- * @returns Promise that resolves when seeding is complete.
458
- */
459
- async seed() {
460
- await sleep();
461
- }
462
- /**
463
- * Drop all data from the store.
464
- * @returns Promise that resolves when the store is cleared.
465
- */
466
- async drop() {
467
- await sleep();
468
- this._resetIndexes();
469
- this._streams = /* @__PURE__ */ new Map();
470
- }
471
- in_query(query, e) {
472
- if (query.stream) {
473
- if (query.stream_exact) {
474
- if (e.stream !== query.stream) return false;
475
- } else if (!RegExp(`^${query.stream}$`).test(e.stream)) return false;
476
- }
477
- if (query.names && !query.names.includes(e.name)) return false;
478
- if (query.correlation && e.meta?.correlation !== query.correlation)
479
- return false;
480
- if (e.name === SNAP_EVENT && !query.with_snaps) return false;
481
- return true;
482
- }
483
- /**
484
- * Query events in the store, optionally filtered by query options.
485
- * @param callback - Function to call for each event.
486
- * @param query - Optional query options.
487
- * @returns The number of events processed.
488
- */
489
- async query(callback, query) {
490
- await sleep();
491
- let count = 0;
492
- if (query?.backward) {
493
- let i = (query?.before || this._events.length) - 1;
494
- while (i >= 0) {
495
- const e = this._events[i--];
496
- if (query && !this.in_query(query, e)) continue;
497
- if (query?.created_before && e.created >= query.created_before)
498
- continue;
499
- if (query.after && e.id <= query.after) break;
500
- if (query.created_after && e.created <= query.created_after) break;
501
- callback(e);
502
- count++;
503
- if (query?.limit && count >= query.limit) break;
504
- }
505
- } else {
506
- let i = (query?.after ?? -1) + 1;
507
- while (i < this._events.length) {
508
- const e = this._events[i++];
509
- if (query && !this.in_query(query, e)) continue;
510
- if (query?.created_after && e.created <= query.created_after) continue;
511
- if (query?.before && e.id >= query.before) break;
512
- if (query?.created_before && e.created >= query.created_before) break;
513
- callback(e);
514
- count++;
515
- if (query?.limit && count >= query.limit) break;
516
- }
517
- }
518
- return count;
519
- }
520
- /**
521
- * Commit one or more events to a stream.
522
- * @param stream - The stream name.
523
- * @param msgs - The events/messages to commit.
524
- * @param meta - Event metadata.
525
- * @param expectedVersion - Optional optimistic concurrency check.
526
- * @returns The committed events with metadata.
527
- * @throws ConcurrencyError if expectedVersion does not match.
528
- */
529
- async commit(stream, msgs, meta, expectedVersion) {
530
- await sleep();
531
- const currentVersion = this._streamVersions.get(stream) ?? -1;
532
- if (typeof expectedVersion === "number" && currentVersion !== expectedVersion) {
533
- throw new ConcurrencyError(
534
- stream,
535
- currentVersion,
536
- msgs,
537
- expectedVersion
538
- );
539
- }
540
- let version = currentVersion + 1;
541
- let lastNonSnapId = -1;
542
- const committed = msgs.map(({ name, data }) => {
543
- const c = {
544
- id: this._events.length,
545
- stream,
546
- version,
547
- created: /* @__PURE__ */ new Date(),
548
- name,
549
- data,
550
- meta
551
- };
552
- this._events.push(c);
553
- if (name !== SNAP_EVENT) lastNonSnapId = c.id;
554
- version++;
555
- return c;
556
- });
557
- this._streamVersions.set(stream, version - 1);
558
- if (lastNonSnapId >= 0) {
559
- this._maxEventIdByStream.set(stream, lastNonSnapId);
560
- this._maxNonSnapEventId = lastNonSnapId;
561
- }
562
- return committed;
563
- }
564
- /**
565
- * Atomically discovers and leases streams for processing.
566
- * Fuses poll + lease into a single operation.
567
- * @param lagging - Max streams from lagging frontier.
568
- * @param leading - Max streams from leading frontier.
569
- * @param by - Lease holder identifier.
570
- * @param millis - Lease duration in milliseconds.
571
- * @returns Granted leases.
572
- */
573
- async claim(lagging, leading, by, millis) {
574
- await sleep();
575
- const sourceRegex = /* @__PURE__ */ new Map();
576
- const getRegex = (source) => {
577
- let re = sourceRegex.get(source);
578
- if (!re) {
579
- re = new RegExp(source);
580
- sourceRegex.set(source, re);
581
- }
582
- return re;
583
- };
584
- const hasWork = (s) => {
585
- if (s.at < 0) return true;
586
- if (!s.source) return s.at < this._maxNonSnapEventId;
587
- const re = getRegex(s.source);
588
- for (const [streamName, maxId] of this._maxEventIdByStream) {
589
- if (maxId > s.at && re.test(streamName)) return true;
590
- }
591
- return false;
592
- };
593
- const available = [...this._streams.values()].filter(
594
- (s) => s.is_available && hasWork(s)
595
- );
596
- const lag = available.sort((a, b) => b.priority - a.priority || a.at - b.at).slice(0, lagging).map((s) => ({
597
- stream: s.stream,
598
- source: s.source,
599
- at: s.at,
600
- lagging: true
601
- }));
602
- const lead = available.sort((a, b) => b.at - a.at).slice(0, leading).map((s) => ({
603
- stream: s.stream,
604
- source: s.source,
605
- at: s.at,
606
- lagging: false
607
- }));
608
- const seen = /* @__PURE__ */ new Set();
609
- const combined = [...lag, ...lead].filter((p) => {
610
- if (seen.has(p.stream)) return false;
611
- seen.add(p.stream);
612
- return true;
613
- });
614
- return combined.map(
615
- (p) => this._streams.get(p.stream)?.lease({ ...p, by, retry: 0 }, millis)
616
- ).filter((l) => !!l);
617
- }
618
- /**
619
- * Registers streams for event processing. When the same stream is
620
- * resubscribed with a different priority, the **maximum** wins — so
621
- * the highest-priority registered reaction sets the scheduling lane.
622
- * Use {@link prioritize} for operator runtime overrides.
623
- *
624
- * @param streams - Streams to register with optional source + priority.
625
- * @returns subscribed count and current max watermark.
626
- */
627
- async subscribe(streams) {
628
- await sleep();
629
- let subscribed = 0;
630
- for (const { stream, source, priority = 0 } of streams) {
631
- const existing = this._streams.get(stream);
632
- if (existing) {
633
- existing.bumpPriority(priority);
634
- } else {
635
- this._streams.set(stream, new InMemoryStream(stream, source, priority));
636
- subscribed++;
637
- }
638
- }
639
- let watermark = -1;
640
- for (const s of this._streams.values()) {
641
- if (s.at > watermark) watermark = s.at;
642
- }
643
- return { subscribed, watermark };
644
- }
645
- /**
646
- * Acknowledge completion of processing for leased streams.
647
- * @param leases - Leases to acknowledge, including last processed watermark and lease holder.
648
- */
649
- async ack(leases) {
650
- await sleep();
651
- return leases.map((l) => this._streams.get(l.stream)?.ack(l)).filter((l) => !!l);
652
- }
653
- /**
654
- * Block a stream for processing after failing to process and reaching max retries with blocking enabled.
655
- * @param leases - Leases to block, including lease holder and last error message.
656
- * @returns Blocked leases.
657
- */
658
- async block(leases) {
659
- await sleep();
660
- return leases.map((l) => this._streams.get(l.stream)?.block(l, l.error)).filter((l) => !!l);
661
- }
662
- /**
663
- * Reset watermarks for the given streams to -1, clearing retry, blocked,
664
- * error, and lease state so they can be replayed from the beginning.
665
- * @param streams - Stream names to reset.
666
- * @returns Count of streams that were actually reset.
667
- */
668
- async reset(streams) {
669
- await sleep();
670
- let count = 0;
671
- for (const name of streams) {
672
- const s = this._streams.get(name);
673
- if (s) {
674
- s.reset();
675
- count++;
676
- }
677
- }
678
- return count;
679
- }
680
- /**
681
- * Bulk-update priority of streams matching `filter`. Mirrors
682
- * {@link query_streams}'s filter semantics — see {@link Store.prioritize}.
683
- * Unlike {@link subscribe} (which keeps `max()` of registered
684
- * priorities), this sets the priority outright — operator override
685
- * for the build-time scheduling policy.
686
- *
687
- * @returns Count of streams whose priority changed.
688
- */
689
- async prioritize(filter, priority) {
690
- await sleep();
691
- const streamRe = filter.stream && !filter.stream_exact ? new RegExp(`^${filter.stream}$`) : void 0;
692
- const sourceRe = filter.source && !filter.source_exact ? new RegExp(`^${filter.source}$`) : void 0;
693
- let count = 0;
694
- for (const s of this._streams.values()) {
695
- if (filter.stream !== void 0) {
696
- if (filter.stream_exact ? s.stream !== filter.stream : !streamRe.test(s.stream))
697
- continue;
698
- }
699
- if (filter.source !== void 0) {
700
- if (s.source === void 0) continue;
701
- if (filter.source_exact ? s.source !== filter.source : !sourceRe.test(s.source))
702
- continue;
703
- }
704
- if (filter.blocked !== void 0 && s.blocked !== filter.blocked)
705
- continue;
706
- if (s.priority !== priority) {
707
- s.setPriority(priority);
708
- count++;
709
- }
710
- }
711
- return count;
712
- }
713
- /**
714
- * Streams registered subscription positions to the callback, ordered by
715
- * stream name. Returns the highest event id in the store and the count
716
- * of positions emitted.
717
- */
718
- async query_streams(callback, query) {
719
- await sleep();
720
- const limit = query?.limit ?? 100;
721
- const after = query?.after;
722
- const blocked = query?.blocked;
723
- const streamRe = query?.stream && !query.stream_exact ? new RegExp(`^${query.stream}$`) : void 0;
724
- const sourceRe = query?.source && !query.source_exact ? new RegExp(`^${query.source}$`) : void 0;
725
- const sorted = [...this._streams.values()].sort(
726
- (a, b) => a.stream.localeCompare(b.stream)
727
- );
728
- let count = 0;
729
- for (const s of sorted) {
730
- if (after !== void 0 && s.stream <= after) continue;
731
- if (query?.stream !== void 0) {
732
- if (query.stream_exact ? s.stream !== query.stream : !streamRe.test(s.stream))
733
- continue;
734
- }
735
- if (query?.source !== void 0) {
736
- if (s.source === void 0) continue;
737
- if (query.source_exact ? s.source !== query.source : !sourceRe.test(s.source))
738
- continue;
739
- }
740
- if (blocked !== void 0 && s.blocked !== blocked) continue;
741
- callback({
742
- stream: s.stream,
743
- source: s.source,
744
- at: s.at,
745
- retry: s.retry,
746
- blocked: s.blocked,
747
- error: s.error,
748
- priority: s.priority,
749
- leased_by: s.leased_by,
750
- leased_until: s.leased_until
751
- });
752
- count++;
753
- if (count >= limit) break;
754
- }
755
- return { maxEventId: this._events.length - 1, count };
756
- }
757
- /**
758
- * Atomically truncates streams and seeds each with a snapshot or tombstone.
759
- * @param targets - Streams to truncate with optional snapshot state and meta.
760
- * @returns Map keyed by stream name, each entry with `deleted` count and `committed` event.
761
- */
762
- async truncate(targets) {
763
- await sleep();
764
- const deletedCounts = /* @__PURE__ */ new Map();
765
- const streamSet = new Set(targets.map((t) => t.stream));
766
- for (const e of this._events) {
767
- if (streamSet.has(e.stream)) {
768
- deletedCounts.set(e.stream, (deletedCounts.get(e.stream) ?? 0) + 1);
769
- }
770
- }
771
- this._events = this._events.filter((e) => !streamSet.has(e.stream));
772
- for (const stream of streamSet) {
773
- this._streams.delete(stream);
774
- this._streamVersions.delete(stream);
775
- this._maxEventIdByStream.delete(stream);
776
- }
777
- const result = /* @__PURE__ */ new Map();
778
- for (const { stream, snapshot, meta } of targets) {
779
- const event = {
780
- id: this._events.length,
781
- stream,
782
- version: 0,
783
- created: /* @__PURE__ */ new Date(),
784
- name: snapshot !== void 0 ? SNAP_EVENT : TOMBSTONE_EVENT,
785
- data: snapshot ?? {},
786
- meta: meta ?? { correlation: "", causation: {} }
787
- };
788
- this._events.push(event);
789
- this._streamVersions.set(stream, 0);
790
- if (event.name !== SNAP_EVENT) {
791
- this._maxEventIdByStream.set(stream, event.id);
792
- }
793
- result.set(stream, {
794
- deleted: deletedCounts.get(stream) ?? 0,
795
- committed: event
796
- });
797
- }
798
- let max = -1;
799
- for (const id of this._maxEventIdByStream.values()) if (id > max) max = id;
800
- this._maxNonSnapEventId = max;
801
- return result;
802
- }
803
- };
804
-
805
- // src/ports.ts
806
- var ExitCodes = ["ERROR", "EXIT"];
807
- var adapters = /* @__PURE__ */ new Map();
808
- function port(injector) {
809
- return (adapter) => {
810
- if (!adapters.has(injector.name)) {
811
- const injected = injector(adapter);
812
- adapters.set(injector.name, injected);
813
- log().info(`[act] + ${injector.name}:${injected.constructor.name}`);
814
- }
815
- return adapters.get(injector.name);
816
- };
817
- }
818
- var log = port(function log2(adapter) {
819
- const cfg = config();
820
- return adapter || new ConsoleLogger({
821
- level: cfg.logLevel,
822
- pretty: cfg.env !== "production"
823
- });
824
- });
825
- var store = port(function store2(adapter) {
826
- return adapter || new InMemoryStore();
827
- });
828
- var cache = port(function cache2(adapter) {
829
- return adapter || new InMemoryCache();
830
- });
831
- var disposers = [];
832
- async function disposeAndExit(code = "EXIT") {
833
- if (code === "ERROR" && config().env === "production") {
834
- log().warn(
835
- "disposeAndExit('ERROR') ignored in production \u2014 process kept alive"
836
- );
837
- return;
838
- }
839
- for (const disposer of [...disposers].reverse()) {
840
- await disposer();
841
- }
842
- for (const adapter of [...adapters.values()].reverse()) {
843
- await adapter.dispose();
844
- log().info(`[act] - ${adapter.constructor.name}`);
845
- }
846
- adapters.clear();
847
- config().env !== "test" && process.exit(code === "ERROR" ? 1 : 0);
848
- }
849
- function dispose(disposer) {
850
- disposer && disposers.push(disposer);
851
- return disposeAndExit;
852
- }
853
- var SNAP_EVENT = "__snapshot__";
854
- var TOMBSTONE_EVENT = "__tombstone__";
38
+ import "./chunk-5WRI5ZAA.js";
855
39
 
856
40
  // src/signals.ts
857
41
  process.once("SIGINT", async (arg) => {
@@ -2031,6 +1215,7 @@ var Act = class {
2031
1215
  this.registry = registry;
2032
1216
  this._states = _states;
2033
1217
  this._batch_handlers = batchHandlers;
1218
+ this._scoped = options.scoped ? (fn) => scoped.run(options.scoped, fn) : (fn) => fn();
2034
1219
  this._es = buildEs(this._logger);
2035
1220
  this._cd = buildDrain(this._logger);
2036
1221
  this._handle = buildHandle({
@@ -2076,14 +1261,8 @@ var Act = class {
2076
1261
  },
2077
1262
  options.settleDebounceMs ?? DEFAULT_SETTLE_DEBOUNCE_MS
2078
1263
  );
2079
- this._notify_disposer = this._wireNotify();
2080
- dispose(async () => {
2081
- this._emitter.removeAllListeners();
2082
- this.stop_correlations();
2083
- this.stop_settling();
2084
- const disposer = await this._notify_disposer;
2085
- if (disposer) await disposer();
2086
- });
1264
+ this._notify_disposer = this._wireNotify(options.scoped?.store ?? store());
1265
+ dispose(() => this.shutdown());
2087
1266
  }
2088
1267
  _emitter = new EventEmitter();
2089
1268
  /** Event names with at least one registered reaction (computed at build time) */
@@ -2145,6 +1324,11 @@ var Act = class {
2145
1324
  _event_to_state;
2146
1325
  /** Logger resolved at construction time (after user port configuration) */
2147
1326
  _logger = log();
1327
+ /** Wraps a public-method body so internal `store()`/`cache()` resolve to the
1328
+ * per-Act ports (ACT-501). No-op when the Act is unscoped — so the singleton
1329
+ * path keeps reading fresh `store()`/`cache()` per call, which matters for
1330
+ * tests that dispose and re-seed mid-suite. */
1331
+ _scoped;
2148
1332
  /** Pre-bound IAct methods reused across drain cycles. Only `do` varies per
2149
1333
  * payload (it captures the triggering event for reactingTo auto-inject). */
2150
1334
  _bound_do = this.do.bind(this);
@@ -2154,15 +1338,38 @@ var Act = class {
2154
1338
  /** Reaction dispatchers built once and handed to runDrainCycle each cycle. */
2155
1339
  _handle;
2156
1340
  _handle_batch;
1341
+ /** True after the first `shutdown()` call. Guards idempotency. */
1342
+ _shutdown_promise;
1343
+ /**
1344
+ * Per-instance teardown: remove lifecycle listeners, stop the
1345
+ * correlation worker, cancel any pending settle cycle, and tear
1346
+ * down the cross-process notify subscription.
1347
+ *
1348
+ * Idempotent — repeated calls return the same promise. Registered
1349
+ * automatically with the global `dispose()` registry at construction,
1350
+ * so process-wide `dispose()()` covers it; test helpers (or operators
1351
+ * that mint short-lived Acts) call it explicitly for prompt cleanup.
1352
+ */
1353
+ shutdown() {
1354
+ if (!this._shutdown_promise) {
1355
+ this._shutdown_promise = (async () => {
1356
+ this._emitter.removeAllListeners();
1357
+ this.stop_correlations();
1358
+ this.stop_settling();
1359
+ const disposer = await this._notify_disposer;
1360
+ if (disposer) await disposer();
1361
+ })();
1362
+ }
1363
+ return this._shutdown_promise;
1364
+ }
2157
1365
  /**
2158
1366
  * Subscribe to {@link Store.notify} when both the store and the
2159
1367
  * registry support it. Returns the disposer (or `undefined` when no
2160
1368
  * subscription was made). Errors during subscription are logged but
2161
1369
  * never thrown — `notify` is a hint, not a contract.
2162
1370
  */
2163
- async _wireNotify() {
1371
+ async _wireNotify(s) {
2164
1372
  if (this._reactive_events.size === 0) return void 0;
2165
- const s = store();
2166
1373
  if (!s.notify) return void 0;
2167
1374
  try {
2168
1375
  return await s.notify((notification) => {
@@ -2266,35 +1473,39 @@ var Act = class {
2266
1473
  * @see {@link ValidationError}, {@link InvariantError}, {@link ConcurrencyError}
2267
1474
  */
2268
1475
  async do(action2, target, payload, reactingTo, skipValidation = false) {
2269
- const snapshots = await this._es.action(
2270
- this.registry.actions[action2],
2271
- action2,
2272
- target,
2273
- payload,
2274
- reactingTo,
2275
- skipValidation
2276
- );
2277
- if (this._reactive_events.size > 0) {
2278
- for (const snap2 of snapshots) {
2279
- if (snap2.event?.name && this._reactive_events.has(snap2.event.name)) {
2280
- this._drain.arm();
2281
- break;
1476
+ return this._scoped(async () => {
1477
+ const snapshots = await this._es.action(
1478
+ this.registry.actions[action2],
1479
+ action2,
1480
+ target,
1481
+ payload,
1482
+ reactingTo,
1483
+ skipValidation
1484
+ );
1485
+ if (this._reactive_events.size > 0) {
1486
+ for (const snap2 of snapshots) {
1487
+ if (snap2.event?.name && this._reactive_events.has(snap2.event.name)) {
1488
+ this._drain.arm();
1489
+ break;
1490
+ }
2282
1491
  }
2283
1492
  }
2284
- }
2285
- this.emit("committed", snapshots);
2286
- return snapshots;
1493
+ this.emit("committed", snapshots);
1494
+ return snapshots;
1495
+ });
2287
1496
  }
2288
1497
  async load(stateOrName, stream, callback, asOf) {
2289
- let merged;
2290
- if (typeof stateOrName === "string") {
2291
- const found = this._states.get(stateOrName);
2292
- if (!found) throw new Error(`State "${stateOrName}" not found`);
2293
- merged = found;
2294
- } else {
2295
- merged = this._states.get(stateOrName.name) || stateOrName;
2296
- }
2297
- return await this._es.load(merged, stream, callback, asOf);
1498
+ return this._scoped(async () => {
1499
+ let merged;
1500
+ if (typeof stateOrName === "string") {
1501
+ const found = this._states.get(stateOrName);
1502
+ if (!found) throw new Error(`State "${stateOrName}" not found`);
1503
+ merged = found;
1504
+ } else {
1505
+ merged = this._states.get(stateOrName.name) || stateOrName;
1506
+ }
1507
+ return await this._es.load(merged, stream, callback, asOf);
1508
+ });
2298
1509
  }
2299
1510
  /**
2300
1511
  * Queries the event store for events matching a filter.
@@ -2343,14 +1554,16 @@ var Act = class {
2343
1554
  * @see {@link query_array} for loading events into memory
2344
1555
  */
2345
1556
  async query(query, callback) {
2346
- let first;
2347
- let last;
2348
- const count = await store().query((e) => {
2349
- if (!first) first = e;
2350
- last = e;
2351
- callback?.(e);
2352
- }, query);
2353
- return { first, last, count };
1557
+ return this._scoped(async () => {
1558
+ let first;
1559
+ let last;
1560
+ const count = await store().query((e) => {
1561
+ if (!first) first = e;
1562
+ last = e;
1563
+ callback?.(e);
1564
+ }, query);
1565
+ return { first, last, count };
1566
+ });
2354
1567
  }
2355
1568
  /**
2356
1569
  * Queries the event store and returns all matching events in memory.
@@ -2379,9 +1592,11 @@ var Act = class {
2379
1592
  * @see {@link query} for large result sets
2380
1593
  */
2381
1594
  async query_array(query) {
2382
- const events = [];
2383
- await store().query((e) => events.push(e), query);
2384
- return events;
1595
+ return this._scoped(async () => {
1596
+ const events = [];
1597
+ await store().query((e) => events.push(e), query);
1598
+ return events;
1599
+ });
2385
1600
  }
2386
1601
  /**
2387
1602
  * Processes pending reactions by draining uncommitted events from the event store.
@@ -2421,7 +1636,7 @@ var Act = class {
2421
1636
  * @see {@link start_correlations} for automatic correlation
2422
1637
  */
2423
1638
  async drain(options = {}) {
2424
- return this._drain.drain(options);
1639
+ return this._scoped(() => this._drain.drain(options));
2425
1640
  }
2426
1641
  /**
2427
1642
  * Discovers and registers new streams dynamically based on reaction resolvers.
@@ -2469,7 +1684,7 @@ var Act = class {
2469
1684
  * @see {@link stop_correlations} to stop automatic correlation
2470
1685
  */
2471
1686
  async correlate(query = { after: -1, limit: 10 }) {
2472
- return this._correlate.correlate(query);
1687
+ return this._scoped(() => this._correlate.correlate(query));
2473
1688
  }
2474
1689
  /**
2475
1690
  * Starts automatic periodic correlation worker for discovering new streams.
@@ -2590,9 +1805,11 @@ var Act = class {
2590
1805
  * @see {@link settle} for the debounced full-catch-up loop
2591
1806
  */
2592
1807
  async reset(streams) {
2593
- const count = await store().reset(streams);
2594
- if (count > 0 && this._reactive_events.size > 0) this._drain.arm();
2595
- return count;
1808
+ return this._scoped(async () => {
1809
+ const count = await store().reset(streams);
1810
+ if (count > 0 && this._reactive_events.size > 0) this._drain.arm();
1811
+ return count;
1812
+ });
2596
1813
  }
2597
1814
  /**
2598
1815
  * Bulk-update scheduling priority for streams matching `filter`.
@@ -2633,7 +1850,7 @@ var Act = class {
2633
1850
  * @see {@link claim} for how priority biases scheduling
2634
1851
  */
2635
1852
  async prioritize(filter, priority) {
2636
- return store().prioritize(filter, priority);
1853
+ return this._scoped(() => store().prioritize(filter, priority));
2637
1854
  }
2638
1855
  /**
2639
1856
  * Close the books — guard, archive, truncate, and optionally restart streams.
@@ -2670,16 +1887,18 @@ var Act = class {
2670
1887
  */
2671
1888
  async close(targets) {
2672
1889
  if (!targets.length) return { truncated: /* @__PURE__ */ new Map(), skipped: [] };
2673
- await this.correlate({ limit: 1e3 });
2674
- const result = await runCloseCycle(targets, {
2675
- reactiveEventsSize: this._reactive_events.size,
2676
- eventToState: this._event_to_state,
2677
- load: this._es.load,
2678
- tombstone: this._es.tombstone,
2679
- logger: this._logger
1890
+ return this._scoped(async () => {
1891
+ await this.correlate({ limit: 1e3 });
1892
+ const result = await runCloseCycle(targets, {
1893
+ reactiveEventsSize: this._reactive_events.size,
1894
+ eventToState: this._event_to_state,
1895
+ load: this._es.load,
1896
+ tombstone: this._es.tombstone,
1897
+ logger: this._logger
1898
+ });
1899
+ this.emit("closed", result);
1900
+ return result;
2680
1901
  });
2681
- this.emit("closed", result);
2682
- return result;
2683
1902
  }
2684
1903
  /**
2685
1904
  * Debounced, non-blocking correlate→drain cycle.
@@ -2733,6 +1952,41 @@ function act() {
2733
1952
  };
2734
1953
  const pendingProjections = [];
2735
1954
  const batchHandlers = /* @__PURE__ */ new Map();
1955
+ let _built = false;
1956
+ const finalizeDeprecations = () => {
1957
+ const deprecationSummary = [];
1958
+ for (const state2 of states.values()) {
1959
+ const eventNames = Object.keys(state2.events);
1960
+ const deprecated = deprecatedEventNames(eventNames);
1961
+ if (deprecated.size === 0) continue;
1962
+ state2._deprecated = deprecated;
1963
+ for (const name of deprecated) {
1964
+ const current = currentVersionOf(name, eventNames);
1965
+ deprecationSummary.push({
1966
+ stateName: state2.name,
1967
+ deprecated: name,
1968
+ current
1969
+ });
1970
+ }
1971
+ for (const [actionName, handler] of Object.entries(state2.on)) {
1972
+ const staticTarget = handler?._staticEmit;
1973
+ if (staticTarget && deprecated.has(staticTarget)) {
1974
+ const current = currentVersionOf(staticTarget, eventNames);
1975
+ throw new Error(
1976
+ `Action "${actionName}" in state "${state2.name}" emits deprecated event "${staticTarget}". A newer version exists: "${current}". Update the .emit() call to target the current version. The reducer (.patch) for "${staticTarget}" stays as-is \u2014 historical events still need it.`
1977
+ );
1978
+ }
1979
+ }
1980
+ }
1981
+ if (deprecationSummary.length > 0) {
1982
+ const list = deprecationSummary.map(
1983
+ (d) => `"${d.deprecated}" (current: "${d.current}", state: "${d.stateName}")`
1984
+ ).join(", ");
1985
+ log().info(
1986
+ `Act registered ${deprecationSummary.length} deprecated event(s): ${list}. These are legacy versions kept for the read path. Consider truncating closed streams via app.close() when feasible to reduce historical event load. See docs/docs/architecture/event-schema-evolution.md.`
1987
+ );
1988
+ }
1989
+ };
2736
1990
  const builder = {
2737
1991
  withState: (state2) => {
2738
1992
  registerState(state2, states, registry.actions, registry.events);
@@ -2776,41 +2030,13 @@ function act() {
2776
2030
  }
2777
2031
  }),
2778
2032
  build: (options) => {
2779
- for (const proj of pendingProjections) {
2780
- mergeProjection(proj, registry.events);
2781
- registerBatchHandler(proj, batchHandlers);
2782
- }
2783
- const deprecationSummary = [];
2784
- for (const state2 of states.values()) {
2785
- const eventNames = Object.keys(state2.events);
2786
- const deprecated = deprecatedEventNames(eventNames);
2787
- if (deprecated.size === 0) continue;
2788
- state2._deprecated = deprecated;
2789
- for (const name of deprecated) {
2790
- const current = currentVersionOf(name, eventNames);
2791
- deprecationSummary.push({
2792
- stateName: state2.name,
2793
- deprecated: name,
2794
- current
2795
- });
2796
- }
2797
- for (const [actionName, handler] of Object.entries(state2.on)) {
2798
- const staticTarget = handler?._staticEmit;
2799
- if (staticTarget && deprecated.has(staticTarget)) {
2800
- const current = currentVersionOf(staticTarget, eventNames);
2801
- throw new Error(
2802
- `Action "${actionName}" in state "${state2.name}" emits deprecated event "${staticTarget}". A newer version exists: "${current}". Update the .emit() call to target the current version. The reducer (.patch) for "${staticTarget}" stays as-is \u2014 historical events still need it.`
2803
- );
2804
- }
2033
+ if (!_built) {
2034
+ for (const proj of pendingProjections) {
2035
+ mergeProjection(proj, registry.events);
2036
+ registerBatchHandler(proj, batchHandlers);
2805
2037
  }
2806
- }
2807
- if (deprecationSummary.length > 0) {
2808
- const list = deprecationSummary.map(
2809
- (d) => `"${d.deprecated}" (current: "${d.current}", state: "${d.stateName}")`
2810
- ).join(", ");
2811
- log().info(
2812
- `Act registered ${deprecationSummary.length} deprecated event(s): ${list}. These are legacy versions kept for the read path. Consider truncating closed streams via app.close() when feasible to reduce historical event load. See docs/docs/architecture/event-schema-evolution.md.`
2813
- );
2038
+ finalizeDeprecations();
2039
+ _built = true;
2814
2040
  }
2815
2041
  return new Act(
2816
2042
  registry,
@@ -3053,6 +2279,7 @@ export {
3053
2279
  log,
3054
2280
  port,
3055
2281
  projection,
2282
+ scoped,
3056
2283
  sleep,
3057
2284
  slice,
3058
2285
  state,