@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.
Files changed (54) hide show
  1. package/README.md +87 -379
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/@types/act.d.ts +93 -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/audit.d.ts +95 -0
  13. package/dist/@types/internal/audit.d.ts.map +1 -0
  14. package/dist/@types/internal/build-classify.d.ts +20 -0
  15. package/dist/@types/internal/build-classify.d.ts.map +1 -1
  16. package/dist/@types/internal/correlate-cycle.d.ts +1 -0
  17. package/dist/@types/internal/correlate-cycle.d.ts.map +1 -1
  18. package/dist/@types/internal/drain-cycle.d.ts +43 -3
  19. package/dist/@types/internal/drain-cycle.d.ts.map +1 -1
  20. package/dist/@types/internal/drain.d.ts +3 -1
  21. package/dist/@types/internal/drain.d.ts.map +1 -1
  22. package/dist/@types/internal/index.d.ts +4 -2
  23. package/dist/@types/internal/index.d.ts.map +1 -1
  24. package/dist/@types/internal/reactions.d.ts.map +1 -1
  25. package/dist/@types/internal/tracing.d.ts +51 -0
  26. package/dist/@types/internal/tracing.d.ts.map +1 -1
  27. package/dist/@types/ports.d.ts +10 -0
  28. package/dist/@types/ports.d.ts.map +1 -1
  29. package/dist/@types/test/sandbox.d.ts +1 -1
  30. package/dist/@types/test/sandbox.d.ts.map +1 -1
  31. package/dist/@types/types/audit.d.ts +126 -0
  32. package/dist/@types/types/audit.d.ts.map +1 -0
  33. package/dist/@types/types/index.d.ts +1 -0
  34. package/dist/@types/types/index.d.ts.map +1 -1
  35. package/dist/@types/types/ports.d.ts +9 -2
  36. package/dist/@types/types/ports.d.ts.map +1 -1
  37. package/dist/@types/types/reaction.d.ts +20 -2
  38. package/dist/@types/types/reaction.d.ts.map +1 -1
  39. package/dist/{chunk-VMX7RPTC.js → chunk-TZWDSNSN.js} +1 -1
  40. package/dist/{chunk-VMX7RPTC.js.map → chunk-TZWDSNSN.js.map} +1 -1
  41. package/dist/{chunk-LKRNWD7C.js → chunk-VC6MSVC3.js} +47 -12
  42. package/dist/chunk-VC6MSVC3.js.map +1 -0
  43. package/dist/index.cjs +1584 -886
  44. package/dist/index.cjs.map +1 -1
  45. package/dist/index.js +1538 -874
  46. package/dist/index.js.map +1 -1
  47. package/dist/test/index.cjs +52 -18
  48. package/dist/test/index.cjs.map +1 -1
  49. package/dist/test/index.js +11 -11
  50. package/dist/test/index.js.map +1 -1
  51. package/dist/types/index.cjs.map +1 -1
  52. package/dist/types/index.js +1 -1
  53. package/package.json +2 -2
  54. package/dist/chunk-LKRNWD7C.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  ConsoleLogger,
3
+ DEFAULT_LANE,
3
4
  ExitCodes,
4
5
  InMemoryCache,
5
6
  InMemoryStore,
@@ -18,7 +19,7 @@ import {
18
19
  sleep,
19
20
  store,
20
21
  validate
21
- } from "./chunk-LKRNWD7C.js";
22
+ } from "./chunk-VC6MSVC3.js";
22
23
  import {
23
24
  ActorSchema,
24
25
  CausationEventSchema,
@@ -35,7 +36,7 @@ import {
35
36
  TargetSchema,
36
37
  ValidationError,
37
38
  ZodEmpty
38
- } from "./chunk-VMX7RPTC.js";
39
+ } from "./chunk-TZWDSNSN.js";
39
40
  import "./chunk-5WRI5ZAA.js";
40
41
 
41
42
  // src/signals.ts
@@ -59,24 +60,459 @@ process.once("unhandledRejection", async (arg) => {
59
60
  // src/act.ts
60
61
  import EventEmitter from "events";
61
62
 
63
+ // src/internal/event-versions.ts
64
+ var VERSION_SUFFIX = /^(.+?)_v(\d+)$/;
65
+ function parse(name) {
66
+ const m = name.match(VERSION_SUFFIX);
67
+ if (m) {
68
+ const v = Number.parseInt(m[2], 10);
69
+ if (v >= 2) return { base: m[1], version: v };
70
+ }
71
+ return { base: name, version: 1 };
72
+ }
73
+ function deprecatedEventNames(names) {
74
+ const groups = /* @__PURE__ */ new Map();
75
+ for (const name of names) {
76
+ const { base, version } = parse(name);
77
+ const list = groups.get(base);
78
+ if (list) list.push({ version, name });
79
+ else groups.set(base, [{ version, name }]);
80
+ }
81
+ const deprecated = /* @__PURE__ */ new Set();
82
+ for (const list of groups.values()) {
83
+ if (list.length < 2) continue;
84
+ list.sort((a, b) => b.version - a.version);
85
+ for (let i = 1; i < list.length; i++) deprecated.add(list[i].name);
86
+ }
87
+ return deprecated;
88
+ }
89
+ function currentVersionOf(deprecatedName, allNames) {
90
+ const target = parse(deprecatedName);
91
+ let highest;
92
+ for (const name of allNames) {
93
+ const { base, version } = parse(name);
94
+ if (base !== target.base) continue;
95
+ if (!highest || version > highest.version) highest = { version, name };
96
+ }
97
+ return highest && highest.version > target.version ? highest.name : void 0;
98
+ }
99
+
100
+ // src/internal/audit.ts
101
+ var DEFAULTS = {
102
+ idle_days: 90,
103
+ restart_min: 1e4,
104
+ stuck_minutes: 30,
105
+ deprecated_min: 0.1,
106
+ drift_min: 500,
107
+ near_block: 3
108
+ };
109
+ var ALL_CATEGORIES = [
110
+ "schema",
111
+ "close-candidate",
112
+ "restart-candidate",
113
+ "deprecated-load",
114
+ "reaction-health",
115
+ "snapshot-drift",
116
+ "routing-health",
117
+ "correlation-gaps",
118
+ "clock-anomalies"
119
+ ];
120
+ async function* audit(deps, categories, options = {}) {
121
+ const requested = new Set(categories ?? [...ALL_CATEGORIES]);
122
+ const orderedCategories = ALL_CATEGORIES.filter((c) => requested.has(c));
123
+ const passes = orderedCategories.map(
124
+ (c) => PASS_FACTORIES[c](deps, options)
125
+ );
126
+ const needStats = passes.some((p) => p.onStat !== void 0);
127
+ const needStreams = passes.some((p) => p.onStream !== void 0);
128
+ const needEvents = passes.some((p) => p.onEvent !== void 0);
129
+ if (needStats) {
130
+ const stats = await deps.store().query_stats({}, { count: true, names: true });
131
+ for (const [stream, s] of stats) {
132
+ for (const p of passes) p.onStat?.(stream, s);
133
+ }
134
+ }
135
+ if (needStreams) {
136
+ await deps.store().query_streams((pos) => {
137
+ for (const p of passes) p.onStream?.(pos);
138
+ });
139
+ }
140
+ if (needEvents) {
141
+ await deps.store().query((event) => {
142
+ for (const p of passes) p.onEvent?.(event);
143
+ }, options.query);
144
+ }
145
+ for (const p of passes) await p.finalize?.(deps);
146
+ for (const p of passes) {
147
+ for (const f of p.drain()) yield f;
148
+ }
149
+ }
150
+ var makeSchemaPass = (deps) => {
151
+ const findings = [];
152
+ return {
153
+ category: "schema",
154
+ onEvent(event) {
155
+ const name = String(event.name);
156
+ const state2 = deps.event_to_state.get(name);
157
+ if (!state2) {
158
+ if (name.startsWith("__")) return;
159
+ findings.push({
160
+ category: "schema",
161
+ stream: event.stream,
162
+ event_id: event.id,
163
+ name,
164
+ reason: "unknown_event_name"
165
+ });
166
+ return;
167
+ }
168
+ const schema = state2.events[name];
169
+ const parsed = schema.safeParse(event.data);
170
+ if (!parsed.success) {
171
+ findings.push({
172
+ category: "schema",
173
+ stream: event.stream,
174
+ event_id: event.id,
175
+ name,
176
+ reason: "schema_validation_failed",
177
+ zod_error: parsed.error
178
+ });
179
+ }
180
+ },
181
+ drain: () => findings
182
+ };
183
+ };
184
+ var makeDeprecatedLoadPass = (deps, options) => {
185
+ const share_min = options.thresholds?.deprecated_min ?? DEFAULTS.deprecated_min;
186
+ const totals = /* @__PURE__ */ new Map();
187
+ const perStream = /* @__PURE__ */ new Map();
188
+ return {
189
+ category: "deprecated-load",
190
+ onStat(stream, { names }) {
191
+ for (const [name, count] of Object.entries(names)) {
192
+ totals.set(name, (totals.get(name) ?? 0) + count);
193
+ let m = perStream.get(name);
194
+ if (!m) {
195
+ m = /* @__PURE__ */ new Map();
196
+ perStream.set(name, m);
197
+ }
198
+ m.set(stream, count);
199
+ }
200
+ },
201
+ drain() {
202
+ const findings = [];
203
+ const grand = [...totals.values()].reduce((s, n) => s + n, 0);
204
+ if (grand === 0) return findings;
205
+ const deprecated = deprecatedEventNames(deps.known_events);
206
+ const sorted = [...deprecated].map((name) => ({ name, count: totals.get(name) ?? 0 })).sort((a, b) => b.count - a.count);
207
+ for (const { name, count } of sorted) {
208
+ if (count === 0) continue;
209
+ if (count / grand < share_min) continue;
210
+ const currentVersion = currentVersionOf(name, deps.known_events);
211
+ const topStreams = [...perStream.get(name).entries()].map(([stream, c]) => ({ stream, count: c })).sort((a, b) => b.count - a.count).slice(0, 10);
212
+ findings.push({
213
+ category: "deprecated-load",
214
+ name,
215
+ current_version: currentVersion,
216
+ total: count,
217
+ top_streams: topStreams
218
+ });
219
+ }
220
+ return findings;
221
+ }
222
+ };
223
+ };
224
+ var makeCloseCandidatePass = (deps, options) => {
225
+ const idle_days = options.thresholds?.idle_days ?? DEFAULTS.idle_days;
226
+ const terminal_events = new Set(options.thresholds?.terminal_events ?? []);
227
+ const idle_cutoff = Date.now() - idle_days * 24 * 60 * 60 * 1e3;
228
+ const findings = [];
229
+ return {
230
+ category: "close-candidate",
231
+ onStat(stream, { head }) {
232
+ const head_name = String(head.name);
233
+ if (head_name.startsWith("__")) return;
234
+ const head_time = head.created.getTime();
235
+ const is_idle = head_time < idle_cutoff;
236
+ const is_terminal = terminal_events.has(head_name);
237
+ if (!is_idle && !is_terminal) return;
238
+ findings.push({
239
+ category: "close-candidate",
240
+ stream,
241
+ last_event_at: head.created.toISOString(),
242
+ reason: is_terminal ? "terminal" : "idle",
243
+ idle_days: is_idle ? Math.floor((Date.now() - head_time) / (24 * 60 * 60 * 1e3)) : void 0,
244
+ restart_supported: restartIsSupported(deps, head_name)
245
+ });
246
+ },
247
+ drain: () => findings
248
+ };
249
+ };
250
+ var makeRestartCandidatePass = (deps, options) => {
251
+ const threshold = options.thresholds?.restart_min ?? DEFAULTS.restart_min;
252
+ const findings = [];
253
+ return {
254
+ category: "restart-candidate",
255
+ onStat(stream, { head, count, names }) {
256
+ if (count < threshold) return;
257
+ const head_name = String(head.name);
258
+ if (head_name.startsWith("__")) return;
259
+ if (!restartIsSupported(deps, head_name)) return;
260
+ findings.push({
261
+ category: "restart-candidate",
262
+ stream,
263
+ count,
264
+ // names map is sparse — `__snapshot__` key absent when the
265
+ // stream has never been snapshotted (a common case for the
266
+ // restart-candidate signal).
267
+ snaps: names["__snapshot__"] ?? 0
268
+ });
269
+ },
270
+ drain: () => findings
271
+ };
272
+ };
273
+ var makeReactionHealthPass = (_deps, options) => {
274
+ const near_block = options.thresholds?.near_block ?? DEFAULTS.near_block;
275
+ const stuck_minutes = options.thresholds?.stuck_minutes ?? DEFAULTS.stuck_minutes;
276
+ const stuck_cutoff = Date.now() - stuck_minutes * 60 * 1e3;
277
+ const findings = [];
278
+ return {
279
+ category: "reaction-health",
280
+ onStream(p) {
281
+ if (p.blocked) {
282
+ findings.push({
283
+ category: "reaction-health",
284
+ stream: p.stream,
285
+ status: "blocked",
286
+ retry: p.retry,
287
+ reason: p.error || "blocked without recorded error"
288
+ });
289
+ return;
290
+ }
291
+ if (p.retry >= near_block) {
292
+ findings.push({
293
+ category: "reaction-health",
294
+ stream: p.stream,
295
+ status: "near-block",
296
+ retry: p.retry,
297
+ reason: `retry ${p.retry} \u2265 near-block threshold ${near_block}`
298
+ });
299
+ return;
300
+ }
301
+ if (p.leased_by && p.leased_until && p.leased_until.getTime() < stuck_cutoff) {
302
+ const minutes = Math.floor(
303
+ (Date.now() - p.leased_until.getTime()) / (60 * 1e3)
304
+ );
305
+ findings.push({
306
+ category: "reaction-health",
307
+ stream: p.stream,
308
+ status: "stuck-backoff",
309
+ retry: p.retry,
310
+ reason: `lease expired ${minutes}m ago without release`
311
+ });
312
+ }
313
+ },
314
+ drain: () => findings
315
+ };
316
+ };
317
+ var makeSnapshotDriftPass = (deps, options) => {
318
+ const drift_min = options.thresholds?.drift_min ?? DEFAULTS.drift_min;
319
+ const candidates = [];
320
+ const findings = [];
321
+ return {
322
+ category: "snapshot-drift",
323
+ onStat(stream, { head, count, names }) {
324
+ if (!restartIsSupported(deps, String(head.name))) return;
325
+ if (count < drift_min) return;
326
+ candidates.push({
327
+ stream,
328
+ total: count,
329
+ snaps: names["__snapshot__"] ?? 0
330
+ });
331
+ },
332
+ async finalize(deps2) {
333
+ for (const { stream, total, snaps } of candidates) {
334
+ let events_since_snap = total;
335
+ let snap_at;
336
+ if (snaps > 0) {
337
+ const collected = [];
338
+ await deps2.store().query(
339
+ (e) => {
340
+ collected.push({ id: e.id });
341
+ },
342
+ {
343
+ stream,
344
+ stream_exact: true,
345
+ names: ["__snapshot__"],
346
+ backward: true,
347
+ limit: 1,
348
+ with_snaps: true
349
+ }
350
+ );
351
+ snap_at = collected[0].id;
352
+ let after = 0;
353
+ await deps2.store().query(
354
+ () => {
355
+ after++;
356
+ },
357
+ { stream, stream_exact: true, after: snap_at }
358
+ );
359
+ events_since_snap = after;
360
+ }
361
+ if (events_since_snap < drift_min) continue;
362
+ findings.push({
363
+ category: "snapshot-drift",
364
+ stream,
365
+ events_since_snap,
366
+ snap_at
367
+ });
368
+ }
369
+ },
370
+ drain: () => findings
371
+ };
372
+ };
373
+ var makeRoutingHealthPass = (deps) => {
374
+ const findings = [];
375
+ const seenEventNames = /* @__PURE__ */ new Set();
376
+ return {
377
+ category: "routing-health",
378
+ onStream(p) {
379
+ if (!p.lane) return;
380
+ if (deps.declared_lanes.has(p.lane)) return;
381
+ findings.push({
382
+ category: "routing-health",
383
+ stream: p.stream,
384
+ reason: "unknown-lane",
385
+ lane: p.lane
386
+ });
387
+ },
388
+ onStat(_stream, { names }) {
389
+ for (const name of Object.keys(names)) {
390
+ seenEventNames.add(name);
391
+ }
392
+ },
393
+ finalize() {
394
+ for (const name of seenEventNames) {
395
+ if (name.startsWith("__")) continue;
396
+ if (deps.routed_events.has(name)) continue;
397
+ findings.push({
398
+ category: "routing-health",
399
+ stream: "*",
400
+ reason: "unrouted"
401
+ });
402
+ }
403
+ return Promise.resolve();
404
+ },
405
+ drain: () => findings
406
+ };
407
+ };
408
+ var makeCorrelationGapsPass = () => {
409
+ const seenIds = /* @__PURE__ */ new Set();
410
+ const checks = [];
411
+ return {
412
+ category: "correlation-gaps",
413
+ onEvent(e) {
414
+ seenIds.add(e.id);
415
+ const causation = e.meta?.causation;
416
+ const parentId = causation?.event?.id;
417
+ if (parentId !== void 0) {
418
+ checks.push({ stream: e.stream, id: e.id, parentId });
419
+ }
420
+ },
421
+ drain() {
422
+ const findings = [];
423
+ for (const { stream, id, parentId } of checks) {
424
+ if (!seenIds.has(parentId)) {
425
+ findings.push({
426
+ category: "correlation-gaps",
427
+ stream,
428
+ event_id: id,
429
+ reason: "orphan-parent"
430
+ });
431
+ }
432
+ }
433
+ return findings;
434
+ }
435
+ };
436
+ };
437
+ var makeClockAnomaliesPass = () => {
438
+ const findings = [];
439
+ const lastPerStream = /* @__PURE__ */ new Map();
440
+ return {
441
+ category: "clock-anomalies",
442
+ onEvent(e) {
443
+ const created = e.created.getTime();
444
+ if (created > Date.now()) {
445
+ findings.push({
446
+ category: "clock-anomalies",
447
+ stream: e.stream,
448
+ event_id: e.id,
449
+ reason: "future-created"
450
+ });
451
+ }
452
+ const prev = lastPerStream.get(e.stream);
453
+ if (prev !== void 0 && created < prev) {
454
+ findings.push({
455
+ category: "clock-anomalies",
456
+ stream: e.stream,
457
+ event_id: e.id,
458
+ reason: "out-of-order"
459
+ });
460
+ }
461
+ lastPerStream.set(e.stream, created);
462
+ },
463
+ drain: () => findings
464
+ };
465
+ };
466
+ function restartIsSupported(deps, headEventName) {
467
+ const state2 = deps.event_to_state.get(headEventName);
468
+ return state2?.snap !== void 0;
469
+ }
470
+ var PASS_FACTORIES = {
471
+ schema: makeSchemaPass,
472
+ "deprecated-load": makeDeprecatedLoadPass,
473
+ "close-candidate": makeCloseCandidatePass,
474
+ "restart-candidate": makeRestartCandidatePass,
475
+ "reaction-health": makeReactionHealthPass,
476
+ "snapshot-drift": makeSnapshotDriftPass,
477
+ "routing-health": makeRoutingHealthPass,
478
+ "correlation-gaps": makeCorrelationGapsPass,
479
+ "clock-anomalies": makeClockAnomaliesPass
480
+ };
481
+
62
482
  // src/internal/build-classify.ts
483
+ var ALL_LANES = /* @__PURE__ */ Symbol("act-1103/all-lanes");
63
484
  function classifyRegistry(registry, states) {
64
485
  const statics = /* @__PURE__ */ new Map();
65
486
  const reactiveEvents = /* @__PURE__ */ new Set();
487
+ const eventToLanes = /* @__PURE__ */ new Map();
66
488
  let hasDynamicResolvers = false;
67
489
  for (const [name, register] of Object.entries(registry.events)) {
68
490
  if (register.reactions.size > 0) reactiveEvents.add(name);
69
491
  for (const reaction of register.reactions.values()) {
70
492
  if (typeof reaction.resolver === "function") {
71
493
  hasDynamicResolvers = true;
494
+ eventToLanes.set(name, ALL_LANES);
72
495
  } else {
73
- const { target, source, priority = 0 } = reaction.resolver;
496
+ const { target, source, priority = 0, lane } = reaction.resolver;
497
+ const lane_name = lane ?? "default";
498
+ const existing_lanes = eventToLanes.get(name);
499
+ if (existing_lanes !== ALL_LANES) {
500
+ const set = existing_lanes ?? /* @__PURE__ */ new Set();
501
+ set.add(lane_name);
502
+ eventToLanes.set(name, set);
503
+ }
74
504
  const key = `${target}|${source ?? ""}`;
75
505
  const existing = statics.get(key);
76
506
  if (!existing) {
77
- statics.set(key, { stream: target, source, priority });
78
- } else if (priority > existing.priority) {
79
- statics.set(key, { ...existing, priority });
507
+ statics.set(key, { stream: target, source, priority, lane });
508
+ } else {
509
+ if ((existing.lane ?? void 0) !== (lane ?? void 0))
510
+ throw new Error(
511
+ `Stream "${target}" has conflicting lane assignments ("${existing.lane ?? "default"}" vs "${lane ?? "default"}")`
512
+ );
513
+ if (priority > existing.priority) {
514
+ statics.set(key, { ...existing, priority });
515
+ }
80
516
  }
81
517
  }
82
518
  }
@@ -91,7 +527,8 @@ function classifyRegistry(registry, states) {
91
527
  staticTargets: [...statics.values()],
92
528
  hasDynamicResolvers,
93
529
  reactiveEvents,
94
- eventToState
530
+ eventToState,
531
+ eventToLanes
95
532
  };
96
533
  }
97
534
 
@@ -302,6 +739,7 @@ var CorrelateCycle = class {
302
739
  const entry = correlated.get(resolved.target) || {
303
740
  source: resolved.source,
304
741
  priority: incomingPriority,
742
+ lane: resolved.lane,
305
743
  payloads: []
306
744
  };
307
745
  if (incomingPriority > entry.priority)
@@ -320,10 +758,11 @@ var CorrelateCycle = class {
320
758
  );
321
759
  if (correlated.size) {
322
760
  const streams = [...correlated.entries()].map(
323
- ([stream, { source, priority }]) => ({
761
+ ([stream, { source, priority, lane }]) => ({
324
762
  stream,
325
763
  source,
326
- priority
764
+ priority,
765
+ lane
327
766
  })
328
767
  );
329
768
  const { subscribed } = await this.cd.subscribe(streams);
@@ -408,904 +847,918 @@ function computeLagLeadRatio(handled, lagging, leading) {
408
847
  return Math.max(RATIO_MIN, Math.min(RATIO_MAX, lagging_avg / total));
409
848
  }
410
849
 
411
- // src/internal/drain-cycle.ts
412
- async function runDrainCycle(ops, registry, batchHandlers, handle, handleBatch, lagging, leading, eventLimit, leaseMillis, isDeferred) {
413
- const leased = await ops.claim(lagging, leading, randomUUID(), leaseMillis);
414
- if (!leased.length) return void 0;
415
- const active = isDeferred ? leased.filter((l) => !isDeferred(l.stream)) : leased;
416
- if (!active.length) {
417
- return {
418
- leased,
419
- fetched: [],
420
- handled: [],
421
- acked: [],
422
- blocked: []
423
- };
424
- }
425
- const fetched = await ops.fetch(active, eventLimit);
426
- const fetchMap = /* @__PURE__ */ new Map();
427
- const fetch_window_at = fetched.reduce(
428
- (max, { at, events }) => Math.max(max, events.at(-1)?.id || at),
429
- 0
430
- );
431
- for (const f of fetched) {
432
- const { stream, events } = f;
433
- const payloads = events.flatMap((event) => {
434
- const register = registry.events[event.name];
435
- if (!register) return [];
436
- return [...register.reactions.values()].filter((reaction) => {
437
- const resolved = typeof reaction.resolver === "function" ? reaction.resolver(event) : reaction.resolver;
438
- return resolved && resolved.target === stream;
439
- }).map((reaction) => ({ ...reaction, event }));
440
- });
441
- fetchMap.set(stream, { fetch: f, payloads });
442
- }
443
- const handled = await Promise.all(
444
- active.map((lease) => {
445
- const entry = fetchMap.get(lease.stream);
446
- const at = entry.fetch.events.at(-1)?.id || fetch_window_at;
447
- const { payloads } = entry;
448
- const batchHandler = batchHandlers.get(lease.stream);
449
- if (batchHandler && payloads.length > 0) {
450
- return handleBatch({ ...lease, at }, payloads, batchHandler);
451
- }
452
- return handle({ ...lease, at }, payloads);
850
+ // src/internal/drain.ts
851
+ var claim = (lagging, leading, by, millis, lane) => store().claim(lagging, leading, by, millis, lane);
852
+ async function fetch(leased, eventLimit) {
853
+ return Promise.all(
854
+ leased.map(async ({ stream, source, at, lagging }) => {
855
+ const events = [];
856
+ await store().query((e) => events.push(e), {
857
+ stream: source,
858
+ after: at,
859
+ limit: eventLimit
860
+ });
861
+ return { stream, source, at, lagging, events };
453
862
  })
454
863
  );
455
- const acked = await ops.ack(
456
- handled.filter(({ error }) => !error).map(({ at, lease }) => ({ ...lease, at }))
457
- );
458
- const blocked = await ops.block(
459
- handled.filter(({ block: block2 }) => block2).map(({ lease, error }) => ({ ...lease, error }))
460
- );
461
- return { leased, fetched, handled, acked, blocked };
462
864
  }
463
- var EMPTY_DRAIN = {
464
- fetched: [],
465
- leased: [],
466
- acked: [],
467
- blocked: []
468
- };
469
- var DrainController = class {
470
- constructor(deps) {
471
- this.deps = deps;
865
+ var ack = (leases) => store().ack(leases);
866
+ var block = (leases) => store().block(leases);
867
+ var subscribe = (streams) => store().subscribe(streams);
868
+
869
+ // src/internal/event-sourcing.ts
870
+ import { patch } from "@rotorsoft/act-patch";
871
+ async function snap(snapshot) {
872
+ try {
873
+ const { id, stream, name, meta, version } = snapshot.event;
874
+ await store().commit(
875
+ stream,
876
+ [{ name: SNAP_EVENT, data: snapshot.state }],
877
+ {
878
+ correlation: meta.correlation,
879
+ causation: { event: { id, name, stream } }
880
+ },
881
+ version
882
+ // IMPORTANT! - state events are committed right after the snapshot event
883
+ );
884
+ } catch (error) {
885
+ log().error(error);
472
886
  }
473
- _armed = false;
474
- _locked = false;
475
- _ratio = 0.5;
476
- /**
477
- * Per-stream backoff: `stream → nextAttemptAt` (ms since epoch). Set by
478
- * `_finalize` via `HandleResult.nextAttemptAt`; cleared on successful
479
- * ack or terminal block. Lives in process memory — per-worker pacing
480
- * by design (see {@link BackoffOptions} for the multi-worker trade-off).
481
- */
482
- _backoff = /* @__PURE__ */ new Map();
483
- /** Timer re-arming drain at the earliest pending `nextAttemptAt`. */
484
- _backoffTimer;
485
- /**
486
- * Signal that a commit (or reset / cold-start) may have produced work.
487
- * Subsequent `drain()` calls will run the pipeline; once the pipeline
488
- * settles to no-progress, the controller disarms itself.
489
- */
490
- arm() {
491
- this._armed = true;
887
+ }
888
+ async function tombstone(stream, expectedVersion, correlation) {
889
+ try {
890
+ const [committed] = await store().commit(
891
+ stream,
892
+ [{ name: TOMBSTONE_EVENT, data: {} }],
893
+ { correlation, causation: {} },
894
+ expectedVersion
895
+ );
896
+ return committed;
897
+ } catch (error) {
898
+ if (error instanceof ConcurrencyError) return void 0;
899
+ throw error;
492
900
  }
493
- /** Read-only flag — true while a commit / reset is unprocessed. */
494
- get armed() {
495
- return this._armed;
496
- }
497
- /** Returns true when `stream` is currently within a backoff window. */
498
- isDeferred = (stream) => {
499
- const next = this._backoff.get(stream);
500
- return next !== void 0 && next > Date.now();
501
- };
502
- /**
503
- * Schedule the next drain re-arm at the earliest pending backoff
504
- * expiry. Called only when the backoff map is non-empty (caller guard).
505
- * Idempotent — collapses many simultaneously deferred streams into a
506
- * single timer.
507
- */
508
- scheduleBackoffWake() {
509
- if (this._backoffTimer) clearTimeout(this._backoffTimer);
510
- let earliest = Number.POSITIVE_INFINITY;
511
- for (const t of this._backoff.values()) if (t < earliest) earliest = t;
512
- const delay = Math.max(0, earliest - Date.now());
513
- this._backoffTimer = setTimeout(() => {
514
- this._backoffTimer = void 0;
515
- const now = Date.now();
516
- for (const [stream, at] of this._backoff) {
517
- if (at <= now) this._backoff.delete(stream);
518
- }
519
- this._armed = true;
520
- }, delay);
521
- this._backoffTimer.unref();
522
- }
523
- /** Run one drain pass. Short-circuits when not armed or already running. */
524
- async drain({
525
- streamLimit = 10,
526
- eventLimit = 10,
527
- leaseMillis = 1e4
528
- } = {}) {
529
- if (!this._armed) return EMPTY_DRAIN;
530
- if (this._locked) return EMPTY_DRAIN;
531
- try {
532
- this._locked = true;
533
- const lagging = Math.ceil(streamLimit * this._ratio);
534
- const leading = streamLimit - lagging;
535
- const cycle = await runDrainCycle(
536
- this.deps.ops,
537
- this.deps.registry,
538
- this.deps.batchHandlers,
539
- this.deps.handle,
540
- this.deps.handleBatch,
541
- lagging,
542
- leading,
543
- eventLimit,
544
- leaseMillis,
545
- this._backoff.size > 0 ? this.isDeferred : void 0
546
- );
547
- if (!cycle) {
548
- this._armed = false;
549
- return EMPTY_DRAIN;
550
- }
551
- const { leased, fetched, handled, acked, blocked } = cycle;
552
- this._ratio = computeLagLeadRatio(handled, lagging, leading);
553
- for (const lease of acked) this._backoff.delete(lease.stream);
554
- for (const lease of blocked) this._backoff.delete(lease.stream);
555
- for (const h of handled) {
556
- if (h.nextAttemptAt !== void 0 && !h.block) {
557
- this._backoff.set(h.lease.stream, h.nextAttemptAt);
558
- }
901
+ }
902
+ async function load(me, stream, callback, asOf) {
903
+ const timeTravel = !!asOf && Object.values(asOf).some((v) => v !== void 0);
904
+ const cached = timeTravel ? void 0 : await cache().get(stream);
905
+ const cache_hit = !!cached;
906
+ let state2 = cached?.state ?? (me.init ? me.init() : {});
907
+ let patches = cached?.patches ?? 0;
908
+ let snaps = cached?.snaps ?? 0;
909
+ let version = cached?.version ?? -1;
910
+ let replayed = 0;
911
+ let event;
912
+ await store().query(
913
+ (e) => {
914
+ event = e;
915
+ version = e.version;
916
+ if (e.name === SNAP_EVENT) {
917
+ state2 = e.data;
918
+ snaps++;
919
+ patches = 0;
920
+ replayed++;
921
+ } else if (me.patch[e.name]) {
922
+ state2 = patch(state2, me.patch[e.name](event, state2));
923
+ patches++;
924
+ replayed++;
925
+ } else if (e.name !== TOMBSTONE_EVENT) {
926
+ log().warn(
927
+ `Skipping unknown event "${String(e.name)}" on stream "${stream}" (id=${e.id}) \u2014 no reducer in state "${me.name}"`
928
+ );
559
929
  }
560
- if (this._backoff.size > 0) this.scheduleBackoffWake();
561
- if (acked.length) this.deps.onAcked(acked);
562
- if (blocked.length) this.deps.onBlocked(blocked);
563
- const hasErrors = handled.some(({ error }) => error);
564
- if (!acked.length && !blocked.length && !hasErrors) this._armed = false;
565
- return { fetched, leased, acked, blocked };
566
- } catch (error) {
567
- this.deps.logger.error(error);
568
- return EMPTY_DRAIN;
569
- } finally {
570
- this._locked = false;
930
+ callback?.({
931
+ event,
932
+ state: state2,
933
+ version,
934
+ patches,
935
+ snaps,
936
+ cache_hit,
937
+ replayed
938
+ });
939
+ },
940
+ {
941
+ stream,
942
+ stream_exact: true,
943
+ ...cached ? { after: cached.event_id } : { with_snaps: true, ...asOf }
571
944
  }
945
+ );
946
+ if (replayed > 0 && !timeTravel && event) {
947
+ await cache().set(stream, {
948
+ state: state2,
949
+ version,
950
+ event_id: event.id,
951
+ patches,
952
+ snaps
953
+ });
572
954
  }
573
- };
574
-
575
- // src/internal/event-versions.ts
576
- var VERSION_SUFFIX = /^(.+?)_v(\d+)$/;
577
- function parse(name) {
578
- const m = name.match(VERSION_SUFFIX);
579
- if (m) {
580
- const v = Number.parseInt(m[2], 10);
581
- if (v >= 2) return { base: m[1], version: v };
582
- }
583
- return { base: name, version: 1 };
584
- }
585
- function deprecatedEventNames(names) {
586
- const groups = /* @__PURE__ */ new Map();
587
- for (const name of names) {
588
- const { base, version } = parse(name);
589
- const list = groups.get(base);
590
- if (list) list.push({ version, name });
591
- else groups.set(base, [{ version, name }]);
592
- }
593
- const deprecated = /* @__PURE__ */ new Set();
594
- for (const list of groups.values()) {
595
- if (list.length < 2) continue;
596
- list.sort((a, b) => b.version - a.version);
597
- for (let i = 1; i < list.length; i++) deprecated.add(list[i].name);
598
- }
599
- return deprecated;
955
+ return { event, state: state2, version, patches, snaps, cache_hit, replayed };
600
956
  }
601
- function currentVersionOf(deprecatedName, allNames) {
602
- const target = parse(deprecatedName);
603
- let highest;
604
- for (const name of allNames) {
605
- const { base, version } = parse(name);
606
- if (base !== target.base) continue;
607
- if (!highest || version > highest.version) highest = { version, name };
957
+ async function action(me, action2, target, payload, reactingTo, skipValidation = false, correlator = defaultCorrelator) {
958
+ const { stream, expectedVersion, actor } = target;
959
+ if (!stream) throw new Error("Missing target stream");
960
+ const validated = skipValidation ? payload : validate(action2, payload, me.actions[action2]);
961
+ const snapshot = await load(me, stream);
962
+ if (snapshot.event?.name === TOMBSTONE_EVENT)
963
+ throw new StreamClosedError(stream);
964
+ const expected = expectedVersion ?? snapshot.event?.version;
965
+ if (me.given) {
966
+ const invariants = me.given[action2] || [];
967
+ invariants.forEach(({ valid, description }) => {
968
+ if (!valid(snapshot.state, actor))
969
+ throw new InvariantError(
970
+ action2,
971
+ validated,
972
+ target,
973
+ snapshot,
974
+ description
975
+ );
976
+ });
608
977
  }
609
- return highest && highest.version > target.version ? highest.name : void 0;
610
- }
611
-
612
- // src/internal/merge.ts
613
- import { ZodObject } from "zod";
614
- function baseTypeName(zodType) {
615
- let t = zodType;
616
- while (typeof t.unwrap === "function") {
617
- t = t.unwrap();
978
+ const result = me.on[action2](validated, snapshot, target);
979
+ if (!result) return [snapshot];
980
+ if (Array.isArray(result) && result.length === 0) {
981
+ return [snapshot];
618
982
  }
619
- return t.constructor.name;
620
- }
621
- function mergeSchemas(existing, incoming, stateName) {
622
- if (existing instanceof ZodObject && incoming instanceof ZodObject) {
623
- const existingShape = existing.shape;
624
- const incomingShape = incoming.shape;
625
- for (const key of Object.keys(incomingShape)) {
626
- if (key in existingShape) {
627
- const existingBase = baseTypeName(existingShape[key]);
628
- const incomingBase = baseTypeName(incomingShape[key]);
629
- if (existingBase !== incomingBase) {
630
- throw new Error(
631
- `Schema conflict in "${stateName}": key "${key}" has type "${existingBase}" but incoming partial declares "${incomingBase}"`
632
- );
633
- }
983
+ const tuples = Array.isArray(result[0]) ? result : [result];
984
+ const deprecated = me._deprecated;
985
+ if (deprecated && deprecated.size > 0) {
986
+ const me_ = me;
987
+ const warned = me_._warned ?? (me_._warned = /* @__PURE__ */ new Set());
988
+ for (const [name] of tuples) {
989
+ const evt = name;
990
+ if (deprecated.has(evt) && !warned.has(evt)) {
991
+ warned.add(evt);
992
+ log().warn(
993
+ `Action "${String(action2)}" emitted deprecated event "${evt}". A newer version exists in the registry \u2014 update the action's .emit() to target the current version. (warned once per process)`
994
+ );
634
995
  }
635
996
  }
636
- return existing.extend(incomingShape);
637
- }
638
- return existing;
639
- }
640
- function mergeInits(existing, incoming) {
641
- return () => ({ ...existing(), ...incoming() });
642
- }
643
- function registerState(state2, states, actions, events) {
644
- const existing = states.get(state2.name);
645
- if (existing) {
646
- mergeIntoExisting(state2, existing, states, actions, events);
647
- } else {
648
- registerNewState(state2, states, actions, events);
649
- }
650
- }
651
- function registerNewState(state2, states, actions, events) {
652
- states.set(state2.name, state2);
653
- for (const name of Object.keys(state2.actions)) {
654
- if (actions[name]) throw new Error(`Duplicate action "${name}"`);
655
- actions[name] = state2;
656
- }
657
- for (const name of Object.keys(state2.events)) {
658
- if (events[name]) throw new Error(`Duplicate event "${name}"`);
659
- events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
660
- }
661
- }
662
- function mergeIntoExisting(state2, existing, states, actions, events) {
663
- for (const name of Object.keys(state2.actions)) {
664
- if (existing.actions[name] === state2.actions[name]) continue;
665
- if (actions[name]) throw new Error(`Duplicate action "${name}"`);
666
997
  }
667
- for (const name of Object.keys(state2.events)) {
668
- if (existing.events[name] === state2.events[name]) continue;
669
- if (existing.events[name]) {
670
- throw new Error(
671
- `Event "${name}" in state "${state2.name}" is declared with different Zod schemas across slices. Cross-slice event schemas must reference the same instance \u2014 extract a shared schema (e.g. \`export const ${name} = z.object({ ... })\` in a shared module) and import it in every slice that declares it.`
672
- );
998
+ const emitted = tuples.map(([name, data]) => ({
999
+ name,
1000
+ data: skipValidation ? data : validate(name, data, me.events[name])
1001
+ }));
1002
+ const meta = {
1003
+ correlation: reactingTo?.meta.correlation || correlator({
1004
+ action: action2,
1005
+ state: me.name,
1006
+ stream,
1007
+ actor: target.actor
1008
+ }),
1009
+ causation: {
1010
+ action: {
1011
+ name: action2,
1012
+ ...target
1013
+ // payload intentionally omitted: it can be large or contain PII,
1014
+ // and callers correlate via the correlation id when they need it.
1015
+ },
1016
+ event: reactingTo ? {
1017
+ id: reactingTo.id,
1018
+ name: reactingTo.name,
1019
+ stream: reactingTo.stream
1020
+ } : void 0
673
1021
  }
674
- if (events[name]) throw new Error(`Duplicate event "${name}"`);
675
- }
676
- const mergedPatch = mergePatches(existing.patch, state2.patch, state2.name);
677
- const merged = {
678
- ...existing,
679
- state: mergeSchemas(existing.state, state2.state, state2.name),
680
- init: mergeInits(existing.init, state2.init),
681
- events: { ...existing.events, ...state2.events },
682
- actions: { ...existing.actions, ...state2.actions },
683
- patch: mergedPatch,
684
- on: { ...existing.on, ...state2.on },
685
- given: { ...existing.given, ...state2.given },
686
- snap: state2.snap && existing.snap && state2.snap !== existing.snap ? (() => {
687
- throw new Error(
688
- `Duplicate snap strategy for state "${state2.name}"`
689
- );
690
- })() : state2.snap || existing.snap
691
1022
  };
692
- states.set(state2.name, merged);
693
- for (const name of Object.keys(merged.actions)) {
694
- actions[name] = merged;
695
- }
696
- for (const name of Object.keys(state2.events)) {
697
- if (events[name]) continue;
698
- events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
699
- }
700
- }
701
- function mergePatches(existing, incoming, stateName) {
702
- const merged = { ...existing };
703
- for (const name of Object.keys(incoming)) {
704
- const existingP = existing[name];
705
- const incomingP = incoming[name];
706
- if (!existingP) {
707
- merged[name] = incomingP;
708
- continue;
709
- }
710
- const existingIsDefault = existingP._passthrough;
711
- const incomingIsDefault = incomingP._passthrough;
712
- if (!existingIsDefault && !incomingIsDefault && existingP !== incomingP) {
713
- throw new Error(
714
- `Duplicate custom patch for event "${name}" in state "${stateName}"`
715
- );
716
- }
717
- if (existingIsDefault && !incomingIsDefault) {
718
- merged[name] = incomingP;
719
- }
720
- }
721
- return merged;
722
- }
723
- function mergeEventRegister(target, source) {
724
- for (const [eventName, sourceReg] of Object.entries(source)) {
725
- const targetReg = target[eventName];
726
- if (!targetReg) continue;
727
- for (const [name, reaction] of sourceReg.reactions) {
728
- targetReg.reactions.set(name, reaction);
729
- }
730
- }
731
- }
732
- function mergeProjection(proj, events) {
733
- for (const eventName of Object.keys(proj.events)) {
734
- const projRegister = proj.events[eventName];
735
- const existing = events[eventName];
736
- if (!existing) {
737
- events[eventName] = {
738
- schema: projRegister.schema,
739
- reactions: new Map(projRegister.reactions)
740
- };
741
- } else {
742
- for (const [name, reaction] of projRegister.reactions) {
743
- let key = name;
744
- while (existing.reactions.has(key)) key = `${key}_p`;
745
- existing.reactions.set(key, reaction);
746
- }
1023
+ let committed;
1024
+ try {
1025
+ committed = await store().commit(
1026
+ stream,
1027
+ emitted,
1028
+ meta,
1029
+ // Reactions skip optimistic concurrency: they always append against the
1030
+ // current head. Stream leasing already serializes concurrent reactions,
1031
+ // and forcing version checks here would turn ordinary catch-up into
1032
+ // spurious retries.
1033
+ reactingTo ? void 0 : expected
1034
+ );
1035
+ } catch (error) {
1036
+ if (error instanceof ConcurrencyError) {
1037
+ await cache().invalidate(stream);
747
1038
  }
1039
+ throw error;
748
1040
  }
1041
+ let { state: state2, patches } = snapshot;
1042
+ const snapshots = committed.map((event) => {
1043
+ const p = me.patch[event.name](event, state2);
1044
+ state2 = patch(state2, p);
1045
+ patches++;
1046
+ return {
1047
+ event,
1048
+ state: state2,
1049
+ version: event.version,
1050
+ patches,
1051
+ snaps: snapshot.snaps,
1052
+ patch: p,
1053
+ cache_hit: snapshot.cache_hit,
1054
+ replayed: snapshot.replayed
1055
+ };
1056
+ });
1057
+ const last = snapshots.at(-1);
1058
+ const snapped = me.snap?.(last);
1059
+ cache().set(stream, {
1060
+ state: last.state,
1061
+ version: last.event.version,
1062
+ event_id: last.event.id,
1063
+ patches: snapped ? 0 : last.patches,
1064
+ snaps: snapped ? last.snaps + 1 : last.snaps
1065
+ }).catch((err) => log().error(err));
1066
+ if (snapped) void snap(last);
1067
+ return snapshots;
749
1068
  }
750
- var _this_ = ({ stream }) => ({
751
- source: stream,
752
- target: stream
1069
+
1070
+ // src/internal/tracing.ts
1071
+ var PRETTY = config().env !== "production";
1072
+ var C_BLUE = "\x1B[38;5;39m";
1073
+ var C_ORANGE = "\x1B[38;5;208m";
1074
+ var C_GREEN = "\x1B[38;5;42m";
1075
+ var C_MAGENTA = "\x1B[38;5;165m";
1076
+ var C_DRAIN = "\x1B[38;5;244m";
1077
+ var C_HIT = "\x1B[38;5;82m";
1078
+ var C_MISS = "\x1B[38;5;220m";
1079
+ var C_RESET = "\x1B[0m";
1080
+ var es_caption = (caption, color, body) => PRETTY ? `${color}${body}${C_RESET}` : `${caption}: ${body}`;
1081
+ var C_LANE = "\x1B[38;5;183m";
1082
+ var C_DIM = "\x1B[38;5;240m";
1083
+ var C_ERR = "\x1B[38;5;196m";
1084
+ var C_STREAM = "\x1B[38;5;226m";
1085
+ var dim = (text) => PRETTY ? `${C_DIM}${text}${C_RESET}` : text;
1086
+ var hue = (color, text) => PRETTY ? `${color}${text}${C_RESET}` : text;
1087
+ var drain_caption = (caption, lane) => {
1088
+ const showLane = lane && lane !== "default";
1089
+ if (PRETTY) {
1090
+ const tag = `${C_DRAIN}>> ${caption}${C_RESET}`;
1091
+ return showLane ? `${tag} ${C_LANE}${lane}${C_RESET}` : tag;
1092
+ }
1093
+ return showLane ? `>> ${caption} ${lane}` : `>> ${caption}`;
1094
+ };
1095
+ var cache_marker = (hit) => {
1096
+ const word = hit ? "hit" : "miss";
1097
+ if (!PRETTY) return word;
1098
+ return `${hit ? C_HIT : C_MISS}${word}${C_RESET}${C_GREEN}`;
1099
+ };
1100
+ var stats_marker = (version, replayed, snaps, patches) => {
1101
+ const text = `v=${version} replayed=${replayed} snaps=${snaps} patches=${patches}`;
1102
+ if (!PRETTY) return text;
1103
+ return `${C_DRAIN}${text}${C_RESET}${C_GREEN}`;
1104
+ };
1105
+ var as_of_marker = (asOf) => {
1106
+ if (!asOf) return "";
1107
+ const parts = [];
1108
+ if (asOf.before !== void 0) parts.push(`before=${asOf.before}`);
1109
+ if (asOf.created_before !== void 0)
1110
+ parts.push(`created_before=${asOf.created_before.toISOString()}`);
1111
+ if (asOf.created_after !== void 0)
1112
+ parts.push(`created_after=${asOf.created_after.toISOString()}`);
1113
+ if (asOf.limit !== void 0) parts.push(`limit=${asOf.limit}`);
1114
+ return parts.length ? ` (as-of ${parts.join(" ")})` : " (as-of)";
1115
+ };
1116
+ var traced = (inner, exit, entry) => (async (...args) => {
1117
+ entry?.(...args);
1118
+ const result = await inner(...args);
1119
+ exit?.(result, ...args);
1120
+ return result;
753
1121
  });
754
-
755
- // src/internal/backoff.ts
756
- function computeBackoffDelay(retry, opts) {
757
- if (!opts || opts.baseMs <= 0) return 0;
758
- const r = Math.max(0, retry);
759
- let delay;
760
- switch (opts.strategy) {
761
- case "fixed":
762
- delay = opts.baseMs;
763
- break;
764
- case "linear":
765
- delay = opts.baseMs * (r + 1);
766
- break;
767
- case "exponential":
768
- delay = opts.baseMs * 2 ** r;
769
- if (opts.maxMs !== void 0) delay = Math.min(delay, opts.maxMs);
770
- break;
1122
+ function buildEs(logger, correlator = defaultCorrelator) {
1123
+ const boundAction = (me, actionName, target, payload, reactingTo, skipValidation = false) => action(
1124
+ me,
1125
+ actionName,
1126
+ target,
1127
+ payload,
1128
+ reactingTo,
1129
+ skipValidation,
1130
+ correlator
1131
+ );
1132
+ if (logger.level !== "trace") {
1133
+ return {
1134
+ snap,
1135
+ load,
1136
+ action: boundAction,
1137
+ tombstone
1138
+ };
771
1139
  }
772
- if (opts.jitter) delay = delay * (0.5 + Math.random());
773
- return Math.max(0, Math.floor(delay));
774
- }
775
-
776
- // src/internal/reactions.ts
777
- function finalize(lease, handled, at, error, options, logger) {
778
- if (!error) return { lease, handled, at };
779
- logger.error(error);
780
- const nonRetryable = error instanceof NonRetryableError;
781
- const block2 = options.blockOnError && (nonRetryable || lease.retry >= options.maxRetries);
782
- if (block2)
783
- logger.error(
784
- nonRetryable ? `Blocking ${lease.stream} on non-retryable error.` : `Blocking ${lease.stream} after ${lease.retry} retries.`
785
- );
786
- const nextAttemptAt = !block2 && options.backoff ? Date.now() + computeBackoffDelay(lease.retry, options.backoff) : void 0;
787
1140
  return {
788
- lease,
789
- handled,
790
- at,
791
- error: handled === 0 ? error.message : void 0,
792
- block: block2,
793
- nextAttemptAt
794
- };
795
- }
796
- function buildHandle(deps) {
797
- const { logger, boundDo, boundLoad, boundQuery, boundQueryArray } = deps;
798
- return async (lease, payloads) => {
799
- if (payloads.length === 0) return { lease, handled: 0, at: lease.at };
800
- const stream = lease.stream;
801
- let at = payloads.at(0).event.id;
802
- let handled = 0;
803
- if (lease.retry > 0)
804
- logger.warn(`Retrying ${stream}@${at} (${lease.retry}).`);
805
- const scopedApp = {
806
- do: boundDo,
807
- load: boundLoad,
808
- query: boundQuery,
809
- query_array: boundQueryArray
810
- };
811
- for (const payload of payloads) {
812
- const { event, handler } = payload;
813
- scopedApp.do = (action2, target, actionPayload, reactingTo, skipValidation) => boundDo(
814
- action2,
815
- target,
816
- actionPayload,
817
- reactingTo ?? event,
818
- skipValidation
1141
+ snap: traced(snap, void 0, (snapshot) => {
1142
+ logger.trace(
1143
+ es_caption(
1144
+ "snap",
1145
+ C_MAGENTA,
1146
+ `${snapshot.event.stream}@${snapshot.event.version}`
1147
+ )
819
1148
  );
820
- try {
821
- await handler(event, stream, scopedApp);
822
- at = event.id;
823
- handled++;
824
- } catch (error) {
825
- return finalize(
826
- lease,
827
- handled,
828
- at,
829
- error,
830
- payload.options,
831
- logger
1149
+ }),
1150
+ load: traced(load, (result, _me, stream, _cb, asOf) => {
1151
+ const stats = stats_marker(
1152
+ result.version,
1153
+ result.replayed,
1154
+ result.snaps,
1155
+ result.patches
1156
+ );
1157
+ logger.trace(
1158
+ es_caption(
1159
+ "load",
1160
+ C_GREEN,
1161
+ `${stream}${as_of_marker(asOf)} ${cache_marker(result.cache_hit)} ${stats}`
1162
+ )
1163
+ );
1164
+ }),
1165
+ action: traced(
1166
+ boundAction,
1167
+ (snapshots, _me, _action, target) => {
1168
+ const committed = snapshots.filter((s) => s.event);
1169
+ if (committed.length) {
1170
+ logger.trace(
1171
+ committed.map((s) => s.event.data),
1172
+ es_caption(
1173
+ "committed",
1174
+ C_ORANGE,
1175
+ `${target.stream}.${committed.map((s) => s.event.name).join(", ")}`
1176
+ )
1177
+ );
1178
+ }
1179
+ },
1180
+ (_me, action2, target, payload) => {
1181
+ logger.trace(
1182
+ payload,
1183
+ es_caption("action", C_BLUE, `${target.stream}.${action2}`)
832
1184
  );
833
1185
  }
834
- }
835
- return finalize(lease, handled, at, void 0, payloads[0].options, logger);
1186
+ ),
1187
+ tombstone: traced(tombstone, (committed, stream) => {
1188
+ if (committed)
1189
+ logger.trace(
1190
+ es_caption("tombstoned", C_ORANGE, `${stream}@${committed.version}`)
1191
+ );
1192
+ })
836
1193
  };
837
1194
  }
838
- function buildHandleBatch(logger) {
839
- return async (lease, payloads, batchHandler) => {
840
- const stream = lease.stream;
841
- const events = payloads.map((p) => p.event);
842
- const options = payloads[0].options;
843
- if (lease.retry > 0)
844
- logger.warn(`Retrying batch ${stream}@${events[0].id} (${lease.retry}).`);
845
- try {
846
- await batchHandler(events, stream);
847
- return finalize(
848
- lease,
849
- events.length,
850
- events.at(-1).id,
851
- void 0,
852
- options,
853
- logger
854
- );
855
- } catch (error) {
856
- return finalize(lease, 0, lease.at, error, options, logger);
857
- }
1195
+ function buildDrain(logger) {
1196
+ return {
1197
+ claim,
1198
+ fetch,
1199
+ ack,
1200
+ block,
1201
+ subscribe: logger.level !== "trace" ? subscribe : traced(subscribe, (result, streams) => {
1202
+ if (!result.subscribed) return;
1203
+ const lanes = new Set(streams.map((s) => s.lane ?? "default"));
1204
+ const uniformLane = lanes.size === 1 ? streams[0]?.lane : void 0;
1205
+ const data = streams.map(
1206
+ ({ stream, lane }) => uniformLane || !lane || lane === "default" ? hue(C_STREAM, stream) : `${hue(C_STREAM, stream)}${dim(`[${lane}]`)}`
1207
+ ).join(" ");
1208
+ logger.trace(`${drain_caption("correlated", uniformLane)} ${data}`);
1209
+ })
858
1210
  };
859
1211
  }
1212
+ function traceCycle(logger, leased, fetched, handled, acked, blocked) {
1213
+ if (logger.level !== "trace" || !leased.length) return;
1214
+ const lane = leased[0]?.lane;
1215
+ const fetchByStream = new Map(fetched.map((f) => [f.stream, f]));
1216
+ const ackedByStream = new Map(acked.map((a) => [a.stream, a.at]));
1217
+ const blockedByStream = new Map(blocked.map((b) => [b.stream, b.error]));
1218
+ const failedByStream = new Map(
1219
+ handled.filter((h) => h.error).map((h) => [h.lease.stream, h])
1220
+ );
1221
+ const detail = leased.map(({ stream, at, retry }) => {
1222
+ const f = fetchByStream.get(stream);
1223
+ const key = f?.source ? `${hue(C_STREAM, stream)}${dim(`<-${f.source}`)}` : hue(C_STREAM, stream);
1224
+ const events = f && f.events.length ? ` ${dim(
1225
+ `[${f.events.map(({ id, name }) => `#${id} ${String(name)}`).join(", ")}]`
1226
+ )}` : "";
1227
+ const ackedAt = ackedByStream.get(stream);
1228
+ const ackPart = ackedAt !== void 0 ? hue(C_HIT, `\u2713 @${ackedAt}`) : "";
1229
+ const failure = failedByStream.get(stream);
1230
+ let failPart = "";
1231
+ if (failure) {
1232
+ const failedAt = failure.failed_at ?? at;
1233
+ const blockedError = blockedByStream.get(stream);
1234
+ if (blockedError !== void 0) {
1235
+ failPart = `${hue(C_ERR, `\u2717 @${failedAt}/${retry}`)} ${dim(`(${blockedError})`)}`;
1236
+ } else {
1237
+ failPart = `${hue(C_MISS, `\u26A0 @${failedAt}/${retry}`)} ${dim(`(${failure.error})`)}`;
1238
+ }
1239
+ }
1240
+ let tail;
1241
+ if (ackPart && failPart) tail = ` ${ackPart} ${failPart}`;
1242
+ else if (ackPart) tail = ` ${ackPart}`;
1243
+ else if (failPart) tail = ` ${failPart}`;
1244
+ else tail = ` ${dim(`\u2298 @${at}/${retry}`)}`;
1245
+ return `${key}${events}${tail}`;
1246
+ }).join(", ");
1247
+ logger.trace(`${drain_caption("drained", lane)} ${detail}`);
1248
+ }
860
1249
 
861
- // src/internal/settle.ts
862
- var SettleLoop = class {
863
- constructor(deps, defaultDebounceMs) {
1250
+ // src/internal/drain-cycle.ts
1251
+ async function runDrainCycle(ops, registry, batchHandlers, handle, handleBatch, lagging, leading, eventLimit, leaseMillis, isDeferred, lane) {
1252
+ const leased = await ops.claim(
1253
+ lagging,
1254
+ leading,
1255
+ randomUUID(),
1256
+ leaseMillis,
1257
+ lane
1258
+ );
1259
+ if (!leased.length) return void 0;
1260
+ const active = isDeferred ? leased.filter((l) => !isDeferred(l.stream)) : leased;
1261
+ if (!active.length) {
1262
+ return {
1263
+ leased,
1264
+ fetched: [],
1265
+ handled: [],
1266
+ acked: [],
1267
+ blocked: []
1268
+ };
1269
+ }
1270
+ const fetched = await ops.fetch(active, eventLimit);
1271
+ const fetchMap = /* @__PURE__ */ new Map();
1272
+ const fetch_window_at = fetched.reduce(
1273
+ (max, { at, events }) => Math.max(max, events.at(-1)?.id || at),
1274
+ 0
1275
+ );
1276
+ for (const f of fetched) {
1277
+ const { stream, events } = f;
1278
+ const payloads = events.flatMap((event) => {
1279
+ const register = registry.events[event.name];
1280
+ if (!register) return [];
1281
+ return [...register.reactions.values()].filter((reaction) => {
1282
+ const resolved = typeof reaction.resolver === "function" ? reaction.resolver(event) : reaction.resolver;
1283
+ return resolved && resolved.target === stream;
1284
+ }).map((reaction) => ({ ...reaction, event }));
1285
+ });
1286
+ fetchMap.set(stream, { fetch: f, payloads });
1287
+ }
1288
+ const handled = await Promise.all(
1289
+ active.map((lease) => {
1290
+ const entry = fetchMap.get(lease.stream);
1291
+ const at = entry.fetch.events.at(-1)?.id || fetch_window_at;
1292
+ const { payloads } = entry;
1293
+ const batchHandler = batchHandlers.get(lease.stream);
1294
+ if (batchHandler && payloads.length > 0) {
1295
+ return handleBatch({ ...lease, at }, payloads, batchHandler);
1296
+ }
1297
+ return handle({ ...lease, at }, payloads);
1298
+ })
1299
+ );
1300
+ const acked = await ops.ack(
1301
+ handled.filter((h) => h.handled > 0 || !h.error).map((h) => ({ ...h.lease, at: h.acked_at }))
1302
+ );
1303
+ const blocked = await ops.block(
1304
+ handled.filter(({ block: block2 }) => block2).map(({ lease, error }) => ({ ...lease, error }))
1305
+ );
1306
+ return { leased, fetched, handled, acked, blocked };
1307
+ }
1308
+ var EMPTY_DRAIN = {
1309
+ fetched: [],
1310
+ leased: [],
1311
+ acked: [],
1312
+ blocked: []
1313
+ };
1314
+ var DrainController = class {
1315
+ constructor(deps) {
864
1316
  this.deps = deps;
865
- this.defaultDebounceMs = defaultDebounceMs;
866
1317
  }
867
- _timer = void 0;
868
- _running = false;
1318
+ _armed = false;
1319
+ _locked = false;
1320
+ _ratio = 0.5;
1321
+ /**
1322
+ * Per-stream backoff: `stream → nextAttemptAt` (ms since epoch). Set by
1323
+ * `_finalize` via `HandleResult.nextAttemptAt`; cleared on successful
1324
+ * ack or terminal block. Lives in process memory — per-worker pacing
1325
+ * by design (see {@link BackoffOptions} for the multi-worker trade-off).
1326
+ */
1327
+ _backoff = /* @__PURE__ */ new Map();
1328
+ /** Timer re-arming drain at the earliest pending `nextAttemptAt`. */
1329
+ _backoffTimer;
1330
+ /** Worker timer (ACT-1103). Set when `start()` is active, undefined otherwise. */
1331
+ _worker;
1332
+ _stopped = false;
1333
+ /**
1334
+ * Signal that a commit (or reset / cold-start) may have produced work.
1335
+ * Subsequent `drain()` calls will run the pipeline; once the pipeline
1336
+ * settles to no-progress, the controller disarms itself.
1337
+ */
1338
+ arm() {
1339
+ this._armed = true;
1340
+ }
1341
+ /** Read-only flag — true while a commit / reset is unprocessed. */
1342
+ get armed() {
1343
+ return this._armed;
1344
+ }
1345
+ /** Returns true when `stream` is currently within a backoff window. */
1346
+ isDeferred = (stream) => {
1347
+ const next = this._backoff.get(stream);
1348
+ return next !== void 0 && next > Date.now();
1349
+ };
1350
+ /**
1351
+ * Schedule the next drain re-arm at the earliest pending backoff
1352
+ * expiry. Called only when the backoff map is non-empty (caller guard).
1353
+ * Idempotent — collapses many simultaneously deferred streams into a
1354
+ * single timer.
1355
+ */
1356
+ scheduleBackoffWake() {
1357
+ if (this._backoffTimer) clearTimeout(this._backoffTimer);
1358
+ let earliest = Number.POSITIVE_INFINITY;
1359
+ for (const t of this._backoff.values()) if (t < earliest) earliest = t;
1360
+ const delay = Math.max(0, earliest - Date.now());
1361
+ this._backoffTimer = setTimeout(() => {
1362
+ this._backoffTimer = void 0;
1363
+ const now = Date.now();
1364
+ for (const [stream, at] of this._backoff) {
1365
+ if (at <= now) this._backoff.delete(stream);
1366
+ }
1367
+ this._armed = true;
1368
+ }, delay);
1369
+ this._backoffTimer.unref();
1370
+ }
1371
+ /** Lane this controller drains (undefined = legacy single-lane span). */
1372
+ get lane() {
1373
+ return this.deps.lane;
1374
+ }
869
1375
  /**
870
- * Schedule a settle pass. Multiple calls inside the debounce window
871
- * coalesce into one cycle. The cycle runs correlate→drain in a loop
872
- * until no progress is made (no new subscriptions, no acks, no blocks)
873
- * or `maxPasses` is reached, then emits the `"settled"` lifecycle event
874
- * via {@link SettleDeps.onSettled}.
1376
+ * Start a per-lane worker that drains at the lane's `cycleMs`
1377
+ * cadence (ACT-1103). When armed, the worker calls `drain()` on every
1378
+ * tick and re-schedules; when not armed, it still re-schedules at
1379
+ * `cycleMs` so a future `arm()` is picked up on the next tick.
1380
+ *
1381
+ * The setTimeout chain uses `unref()` so it doesn't keep the process
1382
+ * alive on its own.
875
1383
  */
876
- schedule(options = {}) {
877
- const {
878
- debounceMs = this.defaultDebounceMs,
879
- correlate: correlateQuery = { after: -1, limit: 100 },
880
- maxPasses = Infinity,
881
- ...drainOptions
882
- } = options;
883
- if (this._timer) clearTimeout(this._timer);
884
- this._timer = setTimeout(() => {
885
- this._timer = void 0;
886
- if (this._running) return;
887
- this._running = true;
888
- (async () => {
889
- await this.deps.init();
890
- let lastDrain;
891
- for (let i = 0; i < maxPasses; i++) {
892
- const { subscribed } = await this.deps.correlate({
893
- ...correlateQuery,
894
- after: this.deps.checkpoint()
895
- });
896
- lastDrain = await this.deps.drain(drainOptions);
897
- const made_progress = subscribed > 0 || lastDrain.acked.length > 0 || lastDrain.blocked.length > 0;
898
- if (!made_progress) break;
899
- }
900
- if (lastDrain) this.deps.onSettled(lastDrain);
901
- })().catch((err) => this.deps.logger.error(err)).finally(() => {
902
- this._running = false;
903
- });
904
- }, debounceMs);
1384
+ start(cycleMs) {
1385
+ if (this._worker || this._stopped) return;
1386
+ const tick = async () => {
1387
+ if (this._armed) await this.drain();
1388
+ if (this._stopped) return;
1389
+ this._worker = setTimeout(tick, cycleMs);
1390
+ this._worker.unref();
1391
+ };
1392
+ this._worker = setTimeout(tick, cycleMs);
1393
+ this._worker.unref();
905
1394
  }
906
- /** Cancel any pending or active settle cycle. Idempotent. */
1395
+ /** Stop the per-lane worker. Idempotent. */
907
1396
  stop() {
908
- if (this._timer) {
909
- clearTimeout(this._timer);
910
- this._timer = void 0;
1397
+ this._stopped = true;
1398
+ if (this._worker) {
1399
+ clearTimeout(this._worker);
1400
+ this._worker = void 0;
1401
+ }
1402
+ }
1403
+ /** Run one drain pass. Short-circuits when not armed or already running. */
1404
+ async drain(options = {}) {
1405
+ if (!this._armed) return EMPTY_DRAIN;
1406
+ if (this._locked) return EMPTY_DRAIN;
1407
+ const d = this.deps.defaults ?? {};
1408
+ const streamLimit = d.streamLimit ?? options.streamLimit ?? 10;
1409
+ const eventLimit = d.eventLimit ?? options.eventLimit ?? 10;
1410
+ const leaseMillis = d.leaseMillis ?? options.leaseMillis ?? 1e4;
1411
+ try {
1412
+ this._locked = true;
1413
+ const lagging = Math.ceil(streamLimit * this._ratio);
1414
+ const leading = streamLimit - lagging;
1415
+ const cycle = await runDrainCycle(
1416
+ this.deps.ops,
1417
+ this.deps.registry,
1418
+ this.deps.batchHandlers,
1419
+ this.deps.handle,
1420
+ this.deps.handleBatch,
1421
+ lagging,
1422
+ leading,
1423
+ eventLimit,
1424
+ leaseMillis,
1425
+ this._backoff.size > 0 ? this.isDeferred : void 0,
1426
+ this.deps.lane
1427
+ );
1428
+ if (!cycle) {
1429
+ this._armed = false;
1430
+ return EMPTY_DRAIN;
1431
+ }
1432
+ const { leased, fetched, handled, acked, blocked } = cycle;
1433
+ traceCycle(this.deps.logger, leased, fetched, handled, acked, blocked);
1434
+ this._ratio = computeLagLeadRatio(handled, lagging, leading);
1435
+ for (const lease of acked) this._backoff.delete(lease.stream);
1436
+ for (const lease of blocked) this._backoff.delete(lease.stream);
1437
+ for (const h of handled) {
1438
+ if (h.nextAttemptAt !== void 0 && !h.block) {
1439
+ this._backoff.set(h.lease.stream, h.nextAttemptAt);
1440
+ }
1441
+ }
1442
+ if (this._backoff.size > 0) this.scheduleBackoffWake();
1443
+ if (acked.length) this.deps.onAcked(acked);
1444
+ if (blocked.length) this.deps.onBlocked(blocked);
1445
+ const hasErrors = handled.some(({ error }) => error);
1446
+ if (!acked.length && !blocked.length && !hasErrors) this._armed = false;
1447
+ return { fetched, leased, acked, blocked };
1448
+ } catch (error) {
1449
+ this.deps.logger.error(error);
1450
+ return EMPTY_DRAIN;
1451
+ } finally {
1452
+ this._locked = false;
911
1453
  }
912
1454
  }
913
1455
  };
914
1456
 
915
- // src/internal/drain.ts
916
- var claim = (lagging, leading, by, millis) => store().claim(lagging, leading, by, millis);
917
- async function fetch(leased, eventLimit) {
918
- return Promise.all(
919
- leased.map(async ({ stream, source, at, lagging }) => {
920
- const events = [];
921
- await store().query((e) => events.push(e), {
922
- stream: source,
923
- after: at,
924
- limit: eventLimit
925
- });
926
- return { stream, source, at, lagging, events };
927
- })
928
- );
1457
+ // src/internal/merge.ts
1458
+ import { ZodObject } from "zod";
1459
+ function baseTypeName(zodType) {
1460
+ let t = zodType;
1461
+ while (typeof t.unwrap === "function") {
1462
+ t = t.unwrap();
1463
+ }
1464
+ return t.constructor.name;
929
1465
  }
930
- var ack = (leases) => store().ack(leases);
931
- var block = (leases) => store().block(leases);
932
- var subscribe = (streams) => store().subscribe(streams);
933
-
934
- // src/internal/event-sourcing.ts
935
- import { patch } from "@rotorsoft/act-patch";
936
- async function snap(snapshot) {
937
- try {
938
- const { id, stream, name, meta, version } = snapshot.event;
939
- await store().commit(
940
- stream,
941
- [{ name: SNAP_EVENT, data: snapshot.state }],
942
- {
943
- correlation: meta.correlation,
944
- causation: { event: { id, name, stream } }
945
- },
946
- version
947
- // IMPORTANT! - state events are committed right after the snapshot event
948
- );
949
- } catch (error) {
950
- log().error(error);
1466
+ function mergeSchemas(existing, incoming, stateName) {
1467
+ if (existing instanceof ZodObject && incoming instanceof ZodObject) {
1468
+ const existingShape = existing.shape;
1469
+ const incomingShape = incoming.shape;
1470
+ for (const key of Object.keys(incomingShape)) {
1471
+ if (key in existingShape) {
1472
+ const existingBase = baseTypeName(existingShape[key]);
1473
+ const incomingBase = baseTypeName(incomingShape[key]);
1474
+ if (existingBase !== incomingBase) {
1475
+ throw new Error(
1476
+ `Schema conflict in "${stateName}": key "${key}" has type "${existingBase}" but incoming partial declares "${incomingBase}"`
1477
+ );
1478
+ }
1479
+ }
1480
+ }
1481
+ return existing.extend(incomingShape);
951
1482
  }
1483
+ return existing;
952
1484
  }
953
- async function tombstone(stream, expectedVersion, correlation) {
954
- try {
955
- const [committed] = await store().commit(
956
- stream,
957
- [{ name: TOMBSTONE_EVENT, data: {} }],
958
- { correlation, causation: {} },
959
- expectedVersion
960
- );
961
- return committed;
962
- } catch (error) {
963
- if (error instanceof ConcurrencyError) return void 0;
964
- throw error;
1485
+ function mergeInits(existing, incoming) {
1486
+ return () => ({ ...existing(), ...incoming() });
1487
+ }
1488
+ function registerState(state2, states, actions, events) {
1489
+ const existing = states.get(state2.name);
1490
+ if (existing) {
1491
+ mergeIntoExisting(state2, existing, states, actions, events);
1492
+ } else {
1493
+ registerNewState(state2, states, actions, events);
965
1494
  }
966
1495
  }
967
- async function load(me, stream, callback, asOf) {
968
- const timeTravel = !!asOf && Object.values(asOf).some((v) => v !== void 0);
969
- const cached = timeTravel ? void 0 : await cache().get(stream);
970
- const cache_hit = !!cached;
971
- let state2 = cached?.state ?? (me.init ? me.init() : {});
972
- let patches = cached?.patches ?? 0;
973
- let snaps = cached?.snaps ?? 0;
974
- let version = cached?.version ?? -1;
975
- let replayed = 0;
976
- let event;
977
- await store().query(
978
- (e) => {
979
- event = e;
980
- version = e.version;
981
- if (e.name === SNAP_EVENT) {
982
- state2 = e.data;
983
- snaps++;
984
- patches = 0;
985
- replayed++;
986
- } else if (me.patch[e.name]) {
987
- state2 = patch(state2, me.patch[e.name](event, state2));
988
- patches++;
989
- replayed++;
990
- } else if (e.name !== TOMBSTONE_EVENT) {
991
- log().warn(
992
- `Skipping unknown event "${String(e.name)}" on stream "${stream}" (id=${e.id}) \u2014 no reducer in state "${me.name}"`
993
- );
994
- }
995
- callback?.({
996
- event,
997
- state: state2,
998
- version,
999
- patches,
1000
- snaps,
1001
- cache_hit,
1002
- replayed
1003
- });
1004
- },
1005
- {
1006
- stream,
1007
- stream_exact: true,
1008
- ...cached ? { after: cached.event_id } : { with_snaps: true, ...asOf }
1009
- }
1010
- );
1011
- if (replayed > 0 && !timeTravel && event) {
1012
- await cache().set(stream, {
1013
- state: state2,
1014
- version,
1015
- event_id: event.id,
1016
- patches,
1017
- snaps
1018
- });
1496
+ function registerNewState(state2, states, actions, events) {
1497
+ states.set(state2.name, state2);
1498
+ for (const name of Object.keys(state2.actions)) {
1499
+ if (actions[name]) throw new Error(`Duplicate action "${name}"`);
1500
+ actions[name] = state2;
1501
+ }
1502
+ for (const name of Object.keys(state2.events)) {
1503
+ if (events[name]) throw new Error(`Duplicate event "${name}"`);
1504
+ events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
1505
+ }
1506
+ }
1507
+ function mergeIntoExisting(state2, existing, states, actions, events) {
1508
+ for (const name of Object.keys(state2.actions)) {
1509
+ if (existing.actions[name] === state2.actions[name]) continue;
1510
+ if (actions[name]) throw new Error(`Duplicate action "${name}"`);
1511
+ }
1512
+ for (const name of Object.keys(state2.events)) {
1513
+ if (existing.events[name] === state2.events[name]) continue;
1514
+ if (existing.events[name]) {
1515
+ throw new Error(
1516
+ `Event "${name}" in state "${state2.name}" is declared with different Zod schemas across slices. Cross-slice event schemas must reference the same instance \u2014 extract a shared schema (e.g. \`export const ${name} = z.object({ ... })\` in a shared module) and import it in every slice that declares it.`
1517
+ );
1518
+ }
1519
+ if (events[name]) throw new Error(`Duplicate event "${name}"`);
1019
1520
  }
1020
- return { event, state: state2, version, patches, snaps, cache_hit, replayed };
1021
- }
1022
- async function action(me, action2, target, payload, reactingTo, skipValidation = false, correlator = defaultCorrelator) {
1023
- const { stream, expectedVersion, actor } = target;
1024
- if (!stream) throw new Error("Missing target stream");
1025
- const validated = skipValidation ? payload : validate(action2, payload, me.actions[action2]);
1026
- const snapshot = await load(me, stream);
1027
- if (snapshot.event?.name === TOMBSTONE_EVENT)
1028
- throw new StreamClosedError(stream);
1029
- const expected = expectedVersion ?? snapshot.event?.version;
1030
- if (me.given) {
1031
- const invariants = me.given[action2] || [];
1032
- invariants.forEach(({ valid, description }) => {
1033
- if (!valid(snapshot.state, actor))
1034
- throw new InvariantError(
1035
- action2,
1036
- validated,
1037
- target,
1038
- snapshot,
1039
- description
1040
- );
1041
- });
1521
+ const mergedPatch = mergePatches(existing.patch, state2.patch, state2.name);
1522
+ const merged = {
1523
+ ...existing,
1524
+ state: mergeSchemas(existing.state, state2.state, state2.name),
1525
+ init: mergeInits(existing.init, state2.init),
1526
+ events: { ...existing.events, ...state2.events },
1527
+ actions: { ...existing.actions, ...state2.actions },
1528
+ patch: mergedPatch,
1529
+ on: { ...existing.on, ...state2.on },
1530
+ given: { ...existing.given, ...state2.given },
1531
+ snap: state2.snap && existing.snap && state2.snap !== existing.snap ? (() => {
1532
+ throw new Error(
1533
+ `Duplicate snap strategy for state "${state2.name}"`
1534
+ );
1535
+ })() : state2.snap || existing.snap
1536
+ };
1537
+ states.set(state2.name, merged);
1538
+ for (const name of Object.keys(merged.actions)) {
1539
+ actions[name] = merged;
1042
1540
  }
1043
- const result = me.on[action2](validated, snapshot, target);
1044
- if (!result) return [snapshot];
1045
- if (Array.isArray(result) && result.length === 0) {
1046
- return [snapshot];
1541
+ for (const name of Object.keys(state2.events)) {
1542
+ if (events[name]) continue;
1543
+ events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
1047
1544
  }
1048
- const tuples = Array.isArray(result[0]) ? result : [result];
1049
- const deprecated = me._deprecated;
1050
- if (deprecated && deprecated.size > 0) {
1051
- const me_ = me;
1052
- const warned = me_._warned ?? (me_._warned = /* @__PURE__ */ new Set());
1053
- for (const [name] of tuples) {
1054
- const evt = name;
1055
- if (deprecated.has(evt) && !warned.has(evt)) {
1056
- warned.add(evt);
1057
- log().warn(
1058
- `Action "${String(action2)}" emitted deprecated event "${evt}". A newer version exists in the registry \u2014 update the action's .emit() to target the current version. (warned once per process)`
1059
- );
1060
- }
1545
+ }
1546
+ function mergePatches(existing, incoming, stateName) {
1547
+ const merged = { ...existing };
1548
+ for (const name of Object.keys(incoming)) {
1549
+ const existingP = existing[name];
1550
+ const incomingP = incoming[name];
1551
+ if (!existingP) {
1552
+ merged[name] = incomingP;
1553
+ continue;
1554
+ }
1555
+ const existingIsDefault = existingP._passthrough;
1556
+ const incomingIsDefault = incomingP._passthrough;
1557
+ if (!existingIsDefault && !incomingIsDefault && existingP !== incomingP) {
1558
+ throw new Error(
1559
+ `Duplicate custom patch for event "${name}" in state "${stateName}"`
1560
+ );
1561
+ }
1562
+ if (existingIsDefault && !incomingIsDefault) {
1563
+ merged[name] = incomingP;
1061
1564
  }
1062
1565
  }
1063
- const emitted = tuples.map(([name, data]) => ({
1064
- name,
1065
- data: skipValidation ? data : validate(name, data, me.events[name])
1066
- }));
1067
- const meta = {
1068
- correlation: reactingTo?.meta.correlation || correlator({
1069
- action: action2,
1070
- state: me.name,
1071
- stream,
1072
- actor: target.actor
1073
- }),
1074
- causation: {
1075
- action: {
1076
- name: action2,
1077
- ...target
1078
- // payload intentionally omitted: it can be large or contain PII,
1079
- // and callers correlate via the correlation id when they need it.
1080
- },
1081
- event: reactingTo ? {
1082
- id: reactingTo.id,
1083
- name: reactingTo.name,
1084
- stream: reactingTo.stream
1085
- } : void 0
1566
+ return merged;
1567
+ }
1568
+ function mergeEventRegister(target, source) {
1569
+ for (const [eventName, sourceReg] of Object.entries(source)) {
1570
+ const targetReg = target[eventName];
1571
+ if (!targetReg) continue;
1572
+ for (const [name, reaction] of sourceReg.reactions) {
1573
+ targetReg.reactions.set(name, reaction);
1086
1574
  }
1087
- };
1088
- let committed;
1089
- try {
1090
- committed = await store().commit(
1091
- stream,
1092
- emitted,
1093
- meta,
1094
- // Reactions skip optimistic concurrency: they always append against the
1095
- // current head. Stream leasing already serializes concurrent reactions,
1096
- // and forcing version checks here would turn ordinary catch-up into
1097
- // spurious retries.
1098
- reactingTo ? void 0 : expected
1099
- );
1100
- } catch (error) {
1101
- if (error instanceof ConcurrencyError) {
1102
- await cache().invalidate(stream);
1575
+ }
1576
+ }
1577
+ function mergeProjection(proj, events) {
1578
+ for (const eventName of Object.keys(proj.events)) {
1579
+ const projRegister = proj.events[eventName];
1580
+ const existing = events[eventName];
1581
+ if (!existing) {
1582
+ events[eventName] = {
1583
+ schema: projRegister.schema,
1584
+ reactions: new Map(projRegister.reactions)
1585
+ };
1586
+ } else {
1587
+ for (const [name, reaction] of projRegister.reactions) {
1588
+ let key = name;
1589
+ while (existing.reactions.has(key)) key = `${key}_p`;
1590
+ existing.reactions.set(key, reaction);
1591
+ }
1103
1592
  }
1104
- throw error;
1105
1593
  }
1106
- let { state: state2, patches } = snapshot;
1107
- const snapshots = committed.map((event) => {
1108
- const p = me.patch[event.name](event, state2);
1109
- state2 = patch(state2, p);
1110
- patches++;
1111
- return {
1112
- event,
1113
- state: state2,
1114
- version: event.version,
1115
- patches,
1116
- snaps: snapshot.snaps,
1117
- patch: p,
1118
- cache_hit: snapshot.cache_hit,
1119
- replayed: snapshot.replayed
1120
- };
1121
- });
1122
- const last = snapshots.at(-1);
1123
- const snapped = me.snap?.(last);
1124
- cache().set(stream, {
1125
- state: last.state,
1126
- version: last.event.version,
1127
- event_id: last.event.id,
1128
- patches: snapped ? 0 : last.patches,
1129
- snaps: snapped ? last.snaps + 1 : last.snaps
1130
- }).catch((err) => log().error(err));
1131
- if (snapped) void snap(last);
1132
- return snapshots;
1133
1594
  }
1134
-
1135
- // src/internal/tracing.ts
1136
- var PRETTY = config().env !== "production";
1137
- var C_BLUE = "\x1B[38;5;39m";
1138
- var C_ORANGE = "\x1B[38;5;208m";
1139
- var C_GREEN = "\x1B[38;5;42m";
1140
- var C_MAGENTA = "\x1B[38;5;165m";
1141
- var C_DRAIN = "\x1B[38;5;244m";
1142
- var C_HIT = "\x1B[38;5;82m";
1143
- var C_MISS = "\x1B[38;5;220m";
1144
- var C_RESET = "\x1B[0m";
1145
- var es_caption = (caption, color, body) => PRETTY ? `${color}${body}${C_RESET}` : `${caption}: ${body}`;
1146
- var drain_caption = (caption) => {
1147
- const tag = `>> ${caption}`;
1148
- return PRETTY ? `${C_DRAIN}${tag}${C_RESET}` : tag;
1149
- };
1150
- var cache_marker = (hit) => {
1151
- const word = hit ? "hit" : "miss";
1152
- if (!PRETTY) return word;
1153
- return `${hit ? C_HIT : C_MISS}${word}${C_RESET}${C_GREEN}`;
1154
- };
1155
- var stats_marker = (version, replayed, snaps, patches) => {
1156
- const text = `v=${version} replayed=${replayed} snaps=${snaps} patches=${patches}`;
1157
- if (!PRETTY) return text;
1158
- return `${C_DRAIN}${text}${C_RESET}${C_GREEN}`;
1159
- };
1160
- var as_of_marker = (asOf) => {
1161
- if (!asOf) return "";
1162
- const parts = [];
1163
- if (asOf.before !== void 0) parts.push(`before=${asOf.before}`);
1164
- if (asOf.created_before !== void 0)
1165
- parts.push(`created_before=${asOf.created_before.toISOString()}`);
1166
- if (asOf.created_after !== void 0)
1167
- parts.push(`created_after=${asOf.created_after.toISOString()}`);
1168
- if (asOf.limit !== void 0) parts.push(`limit=${asOf.limit}`);
1169
- return parts.length ? ` (as-of ${parts.join(" ")})` : " (as-of)";
1170
- };
1171
- var traced = (inner, exit, entry) => (async (...args) => {
1172
- entry?.(...args);
1173
- const result = await inner(...args);
1174
- exit?.(result, ...args);
1175
- return result;
1595
+ var _this_ = ({ stream }) => ({
1596
+ source: stream,
1597
+ target: stream
1176
1598
  });
1177
- function buildEs(logger, correlator = defaultCorrelator) {
1178
- const boundAction = (me, actionName, target, payload, reactingTo, skipValidation = false) => action(
1179
- me,
1180
- actionName,
1181
- target,
1182
- payload,
1183
- reactingTo,
1184
- skipValidation,
1185
- correlator
1186
- );
1187
- if (logger.level !== "trace") {
1188
- return {
1189
- snap,
1190
- load,
1191
- action: boundAction,
1192
- tombstone
1193
- };
1599
+
1600
+ // src/internal/backoff.ts
1601
+ function computeBackoffDelay(retry, opts) {
1602
+ if (!opts || opts.baseMs <= 0) return 0;
1603
+ const r = Math.max(0, retry);
1604
+ let delay;
1605
+ switch (opts.strategy) {
1606
+ case "fixed":
1607
+ delay = opts.baseMs;
1608
+ break;
1609
+ case "linear":
1610
+ delay = opts.baseMs * (r + 1);
1611
+ break;
1612
+ case "exponential":
1613
+ delay = opts.baseMs * 2 ** r;
1614
+ if (opts.maxMs !== void 0) delay = Math.min(delay, opts.maxMs);
1615
+ break;
1194
1616
  }
1617
+ if (opts.jitter) delay = delay * (0.5 + Math.random());
1618
+ return Math.max(0, Math.floor(delay));
1619
+ }
1620
+
1621
+ // src/internal/reactions.ts
1622
+ function finalize(lease, handled, at, error, options, logger, failed_at) {
1623
+ if (!error) return { lease, handled, acked_at: at };
1624
+ logger.error(error);
1625
+ const nonRetryable = error instanceof NonRetryableError;
1626
+ const block2 = options.blockOnError && (nonRetryable || lease.retry >= options.maxRetries);
1627
+ if (block2)
1628
+ logger.error(
1629
+ nonRetryable ? `Blocking ${lease.stream} on non-retryable error.` : `Blocking ${lease.stream} after ${lease.retry} retries.`
1630
+ );
1631
+ const nextAttemptAt = !block2 && options.backoff ? Date.now() + computeBackoffDelay(lease.retry, options.backoff) : void 0;
1195
1632
  return {
1196
- snap: traced(snap, void 0, (snapshot) => {
1197
- logger.trace(
1198
- es_caption(
1199
- "snap",
1200
- C_MAGENTA,
1201
- `${snapshot.event.stream}@${snapshot.event.version}`
1202
- )
1203
- );
1204
- }),
1205
- load: traced(load, (result, _me, stream, _cb, asOf) => {
1206
- const stats = stats_marker(
1207
- result.version,
1208
- result.replayed,
1209
- result.snaps,
1210
- result.patches
1211
- );
1212
- logger.trace(
1213
- es_caption(
1214
- "load",
1215
- C_GREEN,
1216
- `${stream}${as_of_marker(asOf)} ${cache_marker(result.cache_hit)} ${stats}`
1217
- )
1218
- );
1219
- }),
1220
- action: traced(
1221
- boundAction,
1222
- (snapshots, _me, _action, target) => {
1223
- const committed = snapshots.filter((s) => s.event);
1224
- if (committed.length) {
1225
- logger.trace(
1226
- committed.map((s) => s.event.data),
1227
- es_caption(
1228
- "committed",
1229
- C_ORANGE,
1230
- `${target.stream}.${committed.map((s) => s.event.name).join(", ")}`
1231
- )
1232
- );
1233
- }
1234
- },
1235
- (_me, action2, target, payload) => {
1236
- logger.trace(
1237
- payload,
1238
- es_caption("action", C_BLUE, `${target.stream}.${action2}`)
1239
- );
1240
- }
1241
- ),
1242
- tombstone: traced(tombstone, (committed, stream) => {
1243
- if (committed)
1244
- logger.trace(
1245
- es_caption("tombstoned", C_ORANGE, `${stream}@${committed.version}`)
1246
- );
1247
- })
1633
+ lease,
1634
+ handled,
1635
+ acked_at: at,
1636
+ error: error.message,
1637
+ block: block2,
1638
+ nextAttemptAt,
1639
+ failed_at
1248
1640
  };
1249
1641
  }
1250
- function buildDrain(logger) {
1251
- if (logger.level !== "trace") {
1252
- return {
1253
- claim,
1254
- fetch,
1255
- ack,
1256
- block,
1257
- subscribe
1642
+ function buildHandle(deps) {
1643
+ const { logger, boundDo, boundLoad, boundQuery, boundQueryArray } = deps;
1644
+ return async (lease, payloads) => {
1645
+ if (payloads.length === 0) return { lease, handled: 0, acked_at: lease.at };
1646
+ const stream = lease.stream;
1647
+ let at = payloads.at(0).event.id;
1648
+ let handled = 0;
1649
+ if (lease.retry > 0)
1650
+ logger.warn(`Retrying ${stream}@${at} (${lease.retry}).`);
1651
+ const scopedApp = {
1652
+ do: boundDo,
1653
+ load: boundLoad,
1654
+ query: boundQuery,
1655
+ query_array: boundQueryArray
1258
1656
  };
1259
- }
1260
- return {
1261
- claim: traced(claim, (leased) => {
1262
- if (leased.length) {
1263
- const data = Object.fromEntries(
1264
- leased.map(({ stream, at, retry }) => [stream, { at, retry }])
1265
- );
1266
- logger.trace(data, drain_caption("claimed"));
1267
- }
1268
- }),
1269
- fetch: traced(fetch, (fetched) => {
1270
- const data = Object.fromEntries(
1271
- fetched.map(({ stream, source, events }) => {
1272
- const key = source ? `${stream}<-${source}` : stream;
1273
- const value = Object.fromEntries(
1274
- events.map(({ id, stream: stream2, name }) => [id, { [stream2]: name }])
1275
- );
1276
- return [key, value];
1277
- })
1657
+ for (const payload of payloads) {
1658
+ const { event, handler } = payload;
1659
+ scopedApp.do = (action2, target, actionPayload, reactingTo, skipValidation) => boundDo(
1660
+ action2,
1661
+ target,
1662
+ actionPayload,
1663
+ reactingTo ?? event,
1664
+ skipValidation
1278
1665
  );
1279
- logger.trace(data, drain_caption("fetched"));
1280
- }),
1281
- ack: traced(ack, (acked) => {
1282
- if (acked.length) {
1283
- const data = Object.fromEntries(
1284
- acked.map(({ stream, at, retry }) => [stream, { at, retry }])
1285
- );
1286
- logger.trace(data, drain_caption("acked"));
1287
- }
1288
- }),
1289
- block: traced(block, (blocked) => {
1290
- if (blocked.length) {
1291
- const data = Object.fromEntries(
1292
- blocked.map(({ stream, at, retry, error }) => [
1293
- stream,
1294
- { at, retry, error }
1295
- ])
1666
+ try {
1667
+ await handler(event, stream, scopedApp);
1668
+ at = event.id;
1669
+ handled++;
1670
+ } catch (error) {
1671
+ return finalize(
1672
+ lease,
1673
+ handled,
1674
+ at,
1675
+ error,
1676
+ payload.options,
1677
+ logger,
1678
+ event.id
1296
1679
  );
1297
- logger.trace(data, drain_caption("blocked"));
1298
- }
1299
- }),
1300
- subscribe: traced(subscribe, (result, streams) => {
1301
- if (result.subscribed) {
1302
- const data = streams.map(({ stream }) => stream).join(" ");
1303
- logger.trace(`${drain_caption("correlated")} ${data}`);
1304
1680
  }
1305
- })
1681
+ }
1682
+ return finalize(lease, handled, at, void 0, payloads[0].options, logger);
1683
+ };
1684
+ }
1685
+ function buildHandleBatch(logger) {
1686
+ return async (lease, payloads, batchHandler) => {
1687
+ const stream = lease.stream;
1688
+ const events = payloads.map((p) => p.event);
1689
+ const options = payloads[0].options;
1690
+ if (lease.retry > 0)
1691
+ logger.warn(`Retrying batch ${stream}@${events[0].id} (${lease.retry}).`);
1692
+ try {
1693
+ await batchHandler(events, stream);
1694
+ return finalize(
1695
+ lease,
1696
+ events.length,
1697
+ events.at(-1).id,
1698
+ void 0,
1699
+ options,
1700
+ logger
1701
+ );
1702
+ } catch (error) {
1703
+ return finalize(lease, 0, lease.at, error, options, logger);
1704
+ }
1306
1705
  };
1307
1706
  }
1308
1707
 
1708
+ // src/internal/settle.ts
1709
+ var SettleLoop = class {
1710
+ constructor(deps, defaultDebounceMs) {
1711
+ this.deps = deps;
1712
+ this.defaultDebounceMs = defaultDebounceMs;
1713
+ }
1714
+ _timer = void 0;
1715
+ _running = false;
1716
+ /**
1717
+ * Schedule a settle pass. Multiple calls inside the debounce window
1718
+ * coalesce into one cycle. The cycle runs correlate→drain in a loop
1719
+ * until no progress is made (no new subscriptions, no acks, no blocks)
1720
+ * or `maxPasses` is reached, then emits the `"settled"` lifecycle event
1721
+ * via {@link SettleDeps.onSettled}.
1722
+ */
1723
+ schedule(options = {}) {
1724
+ const {
1725
+ debounceMs = this.defaultDebounceMs,
1726
+ correlate: correlateQuery = { after: -1, limit: 100 },
1727
+ maxPasses = Infinity,
1728
+ ...drainOptions
1729
+ } = options;
1730
+ if (this._timer) clearTimeout(this._timer);
1731
+ this._timer = setTimeout(() => {
1732
+ this._timer = void 0;
1733
+ if (this._running) return;
1734
+ this._running = true;
1735
+ (async () => {
1736
+ await this.deps.init();
1737
+ let lastDrain;
1738
+ for (let i = 0; i < maxPasses; i++) {
1739
+ const { subscribed } = await this.deps.correlate({
1740
+ ...correlateQuery,
1741
+ after: this.deps.checkpoint()
1742
+ });
1743
+ lastDrain = await this.deps.drain(drainOptions);
1744
+ const made_progress = subscribed > 0 || lastDrain.acked.length > 0 || lastDrain.blocked.length > 0;
1745
+ if (!made_progress) break;
1746
+ }
1747
+ if (lastDrain) this.deps.onSettled(lastDrain);
1748
+ })().catch((err) => this.deps.logger.error(err)).finally(() => {
1749
+ this._running = false;
1750
+ });
1751
+ }, debounceMs);
1752
+ }
1753
+ /** Cancel any pending or active settle cycle. Idempotent. */
1754
+ stop() {
1755
+ if (this._timer) {
1756
+ clearTimeout(this._timer);
1757
+ this._timer = void 0;
1758
+ }
1759
+ }
1760
+ };
1761
+
1309
1762
  // src/act.ts
1310
1763
  var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
1311
1764
  var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
@@ -1320,11 +1773,26 @@ var Act = class {
1320
1773
  * @param _states Merged map of state name → state definition
1321
1774
  * @param batchHandlers Static-target projection batch handlers (target → handler)
1322
1775
  * @param options Tuning knobs — see {@link ActOptions}
1776
+ * @param lanes Declared drain lanes (ACT-1103). The builder collects
1777
+ * these from `.withLane(...)` calls. Slice 1 records them on the
1778
+ * instance; later slices fan out one `DrainController` per lane.
1323
1779
  */
1324
- constructor(registry, _states = /* @__PURE__ */ new Map(), batchHandlers = /* @__PURE__ */ new Map(), options = {}) {
1780
+ constructor(registry, _states = /* @__PURE__ */ new Map(), batchHandlers = /* @__PURE__ */ new Map(), options = {}, lanes = []) {
1325
1781
  this.registry = registry;
1326
1782
  this._states = _states;
1327
1783
  this._batch_handlers = batchHandlers;
1784
+ this._lanes = lanes;
1785
+ if (options.onlyLanes && options.onlyLanes.length > 0) {
1786
+ const declared = /* @__PURE__ */ new Set([
1787
+ "default",
1788
+ ...lanes.map((l) => l.name)
1789
+ ]);
1790
+ const unknown = options.onlyLanes.filter((l) => !declared.has(l));
1791
+ if (unknown.length > 0)
1792
+ throw new Error(
1793
+ `ActOptions.onlyLanes references undeclared lane(s): ${unknown.map((l) => `"${l}"`).join(", ")}`
1794
+ );
1795
+ }
1328
1796
  this._scoped = options.scoped ? (fn) => scoped.run(options.scoped, fn) : (fn) => fn();
1329
1797
  this._correlator = options.correlator ?? defaultCorrelator;
1330
1798
  this._es = buildEs(this._logger, this._correlator);
@@ -1337,19 +1805,53 @@ var Act = class {
1337
1805
  boundQueryArray: this._bound_query_array
1338
1806
  });
1339
1807
  this._handle_batch = buildHandleBatch(this._logger);
1340
- const { staticTargets, hasDynamicResolvers, reactiveEvents, eventToState } = classifyRegistry(this.registry, this._states);
1808
+ const {
1809
+ staticTargets,
1810
+ hasDynamicResolvers,
1811
+ reactiveEvents,
1812
+ eventToState,
1813
+ eventToLanes
1814
+ } = classifyRegistry(this.registry, this._states);
1341
1815
  this._reactive_events = reactiveEvents;
1342
1816
  this._event_to_state = eventToState;
1343
- this._drain = new DrainController({
1817
+ this._event_to_lanes = eventToLanes;
1818
+ const allLanes = ["default", ...lanes.map((l) => l.name)];
1819
+ const onlySet = options.onlyLanes && options.onlyLanes.length > 0 ? new Set(options.onlyLanes) : void 0;
1820
+ const activeLanes = onlySet ? allLanes.filter((n) => onlySet.has(n)) : allLanes;
1821
+ const singleDefaultLane = activeLanes.length === 1 && activeLanes[0] === "default";
1822
+ this._drain_controllers = /* @__PURE__ */ new Map();
1823
+ for (const name of activeLanes) {
1824
+ const cfg = lanes.find((l) => l.name === name);
1825
+ const controller = new DrainController({
1826
+ logger: this._logger,
1827
+ ops: this._cd,
1828
+ registry: this.registry,
1829
+ batchHandlers: this._batch_handlers,
1830
+ handle: this._handle,
1831
+ handleBatch: this._handle_batch,
1832
+ onAcked: (acked) => this.emit("acked", acked),
1833
+ onBlocked: (blocked) => this.emit("blocked", blocked),
1834
+ // Pass lane only when a true per-lane controller is active.
1835
+ // The all-lanes (single default) case keeps lane=undefined so
1836
+ // adapter SQL collapses to the pre-1103 shape.
1837
+ lane: singleDefaultLane ? void 0 : name,
1838
+ defaults: cfg && {
1839
+ streamLimit: cfg.streamLimit,
1840
+ leaseMillis: cfg.leaseMillis
1841
+ }
1842
+ });
1843
+ if (cfg?.cycleMs !== void 0) controller.start(cfg.cycleMs);
1844
+ this._drain_controllers.set(name, controller);
1845
+ }
1846
+ this._audit_deps = {
1847
+ store,
1344
1848
  logger: this._logger,
1345
- ops: this._cd,
1346
- registry: this.registry,
1347
- batchHandlers: this._batch_handlers,
1348
- handle: this._handle,
1349
- handleBatch: this._handle_batch,
1350
- onAcked: (acked) => this.emit("acked", acked),
1351
- onBlocked: (blocked) => this.emit("blocked", blocked)
1352
- });
1849
+ event_to_state: eventToState,
1850
+ states: this._states,
1851
+ known_events: new Set(eventToState.keys()),
1852
+ declared_lanes: new Set(this._drain_controllers.keys()),
1853
+ routed_events: new Set(eventToLanes.keys())
1854
+ };
1353
1855
  this._correlate = new CorrelateCycle(
1354
1856
  this.registry,
1355
1857
  staticTargets,
@@ -1358,7 +1860,7 @@ var Act = class {
1358
1860
  options.maxSubscribedStreams ?? DEFAULT_MAX_SUBSCRIBED_STREAMS,
1359
1861
  // Cold start: assume drain is needed (historical events may need processing)
1360
1862
  () => {
1361
- if (this._reactive_events.size > 0) this._drain.arm();
1863
+ if (this._reactive_events.size > 0) this._armAll();
1362
1864
  }
1363
1865
  );
1364
1866
  this._settle = new SettleLoop(
@@ -1378,8 +1880,8 @@ var Act = class {
1378
1880
  _emitter = new EventEmitter();
1379
1881
  /** Event names with at least one registered reaction (computed at build time) */
1380
1882
  _reactive_events;
1381
- /** Drain pipeline driver: armed flag, concurrency lock, adaptive ratio. */
1382
- _drain;
1883
+ /** One DrainController per active lane, keyed by lane name. */
1884
+ _drain_controllers;
1383
1885
  /** Correlation state machine: lazy init, dynamic-resolver scan, periodic worker. */
1384
1886
  _correlate;
1385
1887
  /** Debounced correlate→drain catch-up loop. */
@@ -1433,6 +1935,22 @@ var Act = class {
1433
1935
  * set when seeding a `restart` snapshot in multi-state apps.
1434
1936
  */
1435
1937
  _event_to_state;
1938
+ /**
1939
+ * Event-name → lane fan-in for selective arming (ACT-1103). Built by
1940
+ * `classifyRegistry` once per build. `"all"` means at least one of
1941
+ * the event's reactions is a dynamic resolver (lane opaque until
1942
+ * runtime); a `Set<string>` lists the static lanes only that event's
1943
+ * reactions target.
1944
+ */
1945
+ _event_to_lanes;
1946
+ /**
1947
+ * Audit dependency bag (#723). Built once at construction; held as
1948
+ * an immutable snapshot of the registry state the audit module
1949
+ * needs. Lives in `internal/audit.ts` — this orchestrator never
1950
+ * carries audit logic, only the deps + a one-liner that hands them
1951
+ * over.
1952
+ */
1953
+ _audit_deps;
1436
1954
  /** Logger resolved at construction time (after user port configuration) */
1437
1955
  _logger = log();
1438
1956
  /** Wraps a public-method body so internal `store()`/`cache()` resolve to the
@@ -1456,6 +1974,12 @@ var Act = class {
1456
1974
  /** Reaction dispatchers built once and handed to runDrainCycle each cycle. */
1457
1975
  _handle;
1458
1976
  _handle_batch;
1977
+ /** Declared drain lanes (ACT-1103). */
1978
+ _lanes;
1979
+ /** Drain lanes declared via `.withLane(...)`. Implicit default not included. */
1980
+ get lanes() {
1981
+ return this._lanes;
1982
+ }
1459
1983
  /** True after the first `shutdown()` call. Guards idempotency. */
1460
1984
  _shutdown_promise;
1461
1985
  /**
@@ -1474,6 +1998,7 @@ var Act = class {
1474
1998
  this._emitter.removeAllListeners();
1475
1999
  this.stop_correlations();
1476
2000
  this.stop_settling();
2001
+ for (const c of this._drain_controllers.values()) c.stop();
1477
2002
  const disposer = await this._notify_disposer;
1478
2003
  if (disposer) await disposer();
1479
2004
  })();
@@ -1493,13 +2018,10 @@ var Act = class {
1493
2018
  return await s.notify((notification) => {
1494
2019
  try {
1495
2020
  this.emit("notified", notification);
1496
- const hasReactive = notification.events.some(
1497
- (e) => this._reactive_events.has(e.name)
2021
+ const armed = this._armForEventNames(
2022
+ notification.events.map((e) => e.name)
1498
2023
  );
1499
- if (hasReactive) {
1500
- this._drain.arm();
1501
- this._settle.schedule({ debounceMs: 0 });
1502
- }
2024
+ if (armed) this._settle.schedule({ debounceMs: 0 });
1503
2025
  } catch (err) {
1504
2026
  this._logger.error(err, "notified handler threw");
1505
2027
  }
@@ -1600,14 +2122,10 @@ var Act = class {
1600
2122
  reactingTo,
1601
2123
  skipValidation
1602
2124
  );
1603
- if (this._reactive_events.size > 0) {
1604
- for (const snap2 of snapshots) {
1605
- if (snap2.event?.name && this._reactive_events.has(snap2.event.name)) {
1606
- this._drain.arm();
1607
- break;
1608
- }
1609
- }
1610
- }
2125
+ if (this._reactive_events.size > 0)
2126
+ this._armForEventNames(
2127
+ snapshots.map((s) => s.event.name)
2128
+ );
1611
2129
  this.emit("committed", snapshots);
1612
2130
  return snapshots;
1613
2131
  });
@@ -1754,7 +2272,59 @@ var Act = class {
1754
2272
  * @see {@link start_correlations} for automatic correlation
1755
2273
  */
1756
2274
  async drain(options = {}) {
1757
- return this._scoped(() => this._drain.drain(options));
2275
+ return this._scoped(() => this._drainAll(options));
2276
+ }
2277
+ /** Arm every active lane controller (ACT-1103). */
2278
+ _armAll() {
2279
+ for (const c of this._drain_controllers.values()) c.arm();
2280
+ }
2281
+ /**
2282
+ * Arm only the lane controllers whose reactions match the supplied
2283
+ * event names (ACT-1103 selective arming). Events with any dynamic
2284
+ * resolver fall back to `_armAll()` via the `"all"` sentinel — the
2285
+ * resolver's lane isn't known until correlate runs the function.
2286
+ * Events with no reactions are skipped; `_event_to_lanes` doesn't
2287
+ * carry them. Returns true when any controller was armed (used by
2288
+ * the notify handler to decide whether to schedule a settle).
2289
+ */
2290
+ _armForEventNames(names) {
2291
+ const to_arm = /* @__PURE__ */ new Set();
2292
+ for (const name of names) {
2293
+ const set = this._event_to_lanes.get(name);
2294
+ if (set === void 0) continue;
2295
+ if (set === ALL_LANES) {
2296
+ this._armAll();
2297
+ return true;
2298
+ }
2299
+ for (const lane of set) to_arm.add(lane);
2300
+ }
2301
+ if (to_arm.size === 0) return false;
2302
+ for (const lane of to_arm) this._drain_controllers.get(lane)?.arm();
2303
+ return true;
2304
+ }
2305
+ /** Drain every active lane controller in parallel and aggregate.
2306
+ *
2307
+ * Parallel — not sequential — so a slow lane's in-flight handler does
2308
+ * not block a fast lane's claim/dispatch/ack cycle. Each controller's
2309
+ * `claim()` is independent (filtered by lane); the store's
2310
+ * `SKIP LOCKED` keeps cross-controller races safe. Lifecycle events
2311
+ * (`acked`, `blocked`) may interleave by lane — listeners filter via
2312
+ * `lease.lane`. */
2313
+ async _drainAll(options) {
2314
+ const results = await Promise.all(
2315
+ [...this._drain_controllers.values()].map((c) => c.drain(options))
2316
+ );
2317
+ const fetched = [];
2318
+ const leased = [];
2319
+ const acked = [];
2320
+ const blocked = [];
2321
+ for (const r of results) {
2322
+ fetched.push(...r.fetched);
2323
+ leased.push(...r.leased);
2324
+ acked.push(...r.acked);
2325
+ blocked.push(...r.blocked);
2326
+ }
2327
+ return { fetched, leased, acked, blocked };
1758
2328
  }
1759
2329
  /**
1760
2330
  * Discovers and registers new streams dynamically based on reaction resolvers.
@@ -1925,7 +2495,7 @@ var Act = class {
1925
2495
  async reset(input) {
1926
2496
  return this._scoped(async () => {
1927
2497
  const count = await store().reset(input);
1928
- if (count > 0 && this._reactive_events.size > 0) this._drain.arm();
2498
+ if (count > 0 && this._reactive_events.size > 0) this._armAll();
1929
2499
  return count;
1930
2500
  });
1931
2501
  }
@@ -1959,7 +2529,7 @@ var Act = class {
1959
2529
  async unblock(input) {
1960
2530
  return this._scoped(async () => {
1961
2531
  const count = await store().unblock(input);
1962
- if (count > 0 && this._reactive_events.size > 0) this._drain.arm();
2532
+ if (count > 0 && this._reactive_events.size > 0) this._armAll();
1963
2533
  return count;
1964
2534
  });
1965
2535
  }
@@ -1997,6 +2567,50 @@ var Act = class {
1997
2567
  return positions;
1998
2568
  });
1999
2569
  }
2570
+ /**
2571
+ * Operator-driven store audit (#723).
2572
+ *
2573
+ * Walks the connected store and yields per-category findings —
2574
+ * each tagged with the remediation it suggests. Same operator-
2575
+ * driven category as `app.close()` / `app.reset()` /
2576
+ * `app.unblock()` / `app.blocked_streams()`: never auto-invoked by
2577
+ * the framework; the operator decides when to run it (CI gate,
2578
+ * scheduled job, ad-hoc forensics) and what to do with the
2579
+ * findings.
2580
+ *
2581
+ * Categories are independent — pass a subset to scope the work,
2582
+ * or omit to run everything:
2583
+ *
2584
+ * ```typescript
2585
+ * // Targeted: schema drift + deprecated-event load only
2586
+ * for await (const f of app.audit(["schema", "deprecated-load"], {
2587
+ * query: { created_after: lastScan },
2588
+ * thresholds: { deprecatedLoadShareMin: 0.10 },
2589
+ * })) {
2590
+ * await escalate(f);
2591
+ * }
2592
+ *
2593
+ * // Full audit, default thresholds
2594
+ * for await (const f of app.audit()) console.log(f);
2595
+ * ```
2596
+ *
2597
+ * Returns an `AsyncIterable` so callers can `break` early — the
2598
+ * underlying store paginations respect the iterator protocol and
2599
+ * stop cleanly. Each finding is emitted independently, so
2600
+ * pipelining into Slack / persistence / further analysis works
2601
+ * without buffering the full report in memory.
2602
+ *
2603
+ * Findings shape — see {@link AuditFinding}. The discriminated
2604
+ * union carries enough context for the operator to act on each
2605
+ * finding directly: stream id, event id, recommendation hints.
2606
+ *
2607
+ * @param categories - Subset of categories to run (default: all).
2608
+ * @param options - Query window + per-category thresholds.
2609
+ * @returns Async iterable of {@link AuditFinding}.
2610
+ */
2611
+ audit(categories, options) {
2612
+ return audit(this._audit_deps, categories, options);
2613
+ }
2000
2614
  /**
2001
2615
  * Bulk-update scheduling priority for streams matching `filter`.
2002
2616
  *
@@ -2132,6 +2746,22 @@ function registerBatchHandler(proj, batchHandlers) {
2132
2746
  }
2133
2747
  batchHandlers.set(proj.target, proj.batchHandler);
2134
2748
  }
2749
+ function validateLaneReferences(registry, lanes) {
2750
+ const declared = /* @__PURE__ */ new Set([DEFAULT_LANE, ...lanes.map((l) => l.name)]);
2751
+ for (const [eventName, def] of Object.entries(registry.events)) {
2752
+ const entry = def;
2753
+ for (const [handlerName, reaction] of entry.reactions) {
2754
+ const resolver = reaction.resolver;
2755
+ if (typeof resolver === "function") continue;
2756
+ const lane = resolver.lane;
2757
+ if (lane && !declared.has(lane)) {
2758
+ throw new Error(
2759
+ `Reaction "${handlerName}" on "${eventName}" targets undeclared lane "${lane}". Declared lanes: ${[...declared].map((l) => `"${l}"`).join(", ")}. Add \`.withLane({ name: "${lane}", ... })\` to act() or correct the .to() declaration.`
2760
+ );
2761
+ }
2762
+ }
2763
+ }
2764
+ }
2135
2765
  function act() {
2136
2766
  const states = /* @__PURE__ */ new Map();
2137
2767
  const registry = {
@@ -2140,6 +2770,7 @@ function act() {
2140
2770
  };
2141
2771
  const pendingProjections = [];
2142
2772
  const batchHandlers = /* @__PURE__ */ new Map();
2773
+ const lanes = [];
2143
2774
  let _built = false;
2144
2775
  const finalizeDeprecations = () => {
2145
2776
  const deprecationSummary = [];
@@ -2186,6 +2817,18 @@ function act() {
2186
2817
  }
2187
2818
  mergeEventRegister(registry.events, input.events);
2188
2819
  pendingProjections.push(...input.projections);
2820
+ for (const sliceLane of input.lanes) {
2821
+ const existing = lanes.find((l) => l.name === sliceLane.name);
2822
+ if (!existing) {
2823
+ lanes.push(sliceLane);
2824
+ continue;
2825
+ }
2826
+ if (existing.leaseMillis !== sliceLane.leaseMillis || existing.streamLimit !== sliceLane.streamLimit || existing.cycleMs !== sliceLane.cycleMs) {
2827
+ throw new Error(
2828
+ `Lane "${sliceLane.name}" was already declared with a different config`
2829
+ );
2830
+ }
2831
+ }
2189
2832
  return builder;
2190
2833
  },
2191
2834
  withProjection: (proj) => {
@@ -2194,6 +2837,14 @@ function act() {
2194
2837
  return builder;
2195
2838
  },
2196
2839
  withActor: () => builder,
2840
+ withLane: (config2) => {
2841
+ if (config2.name === DEFAULT_LANE)
2842
+ throw new Error(`Lane "${DEFAULT_LANE}" is reserved`);
2843
+ if (lanes.some((l) => l.name === config2.name))
2844
+ throw new Error(`Lane "${config2.name}" was already declared`);
2845
+ lanes.push(config2);
2846
+ return builder;
2847
+ },
2197
2848
  on: (event) => ({
2198
2849
  do: (handler, options) => {
2199
2850
  const reaction = {
@@ -2225,13 +2876,15 @@ function act() {
2225
2876
  registerBatchHandler(proj, batchHandlers);
2226
2877
  }
2227
2878
  finalizeDeprecations();
2879
+ validateLaneReferences(registry, lanes);
2228
2880
  _built = true;
2229
2881
  }
2230
2882
  return new Act(
2231
2883
  registry,
2232
2884
  states,
2233
2885
  batchHandlers,
2234
- options
2886
+ options,
2887
+ lanes
2235
2888
  );
2236
2889
  },
2237
2890
  events: registry.events
@@ -2312,6 +2965,7 @@ function slice() {
2312
2965
  const actions = {};
2313
2966
  const events = {};
2314
2967
  const projections = [];
2968
+ const lanes = [];
2315
2969
  const builder = {
2316
2970
  withState: (state2) => {
2317
2971
  registerState(state2, states, actions, events);
@@ -2321,6 +2975,14 @@ function slice() {
2321
2975
  projections.push(proj);
2322
2976
  return builder;
2323
2977
  },
2978
+ withLane: (config2) => {
2979
+ if (config2.name === DEFAULT_LANE)
2980
+ throw new Error(`Lane "${DEFAULT_LANE}" is reserved`);
2981
+ if (lanes.some((l) => l.name === config2.name))
2982
+ throw new Error(`Lane "${config2.name}" was already declared`);
2983
+ lanes.push(config2);
2984
+ return builder;
2985
+ },
2324
2986
  on: (event) => ({
2325
2987
  do: (handler, options) => {
2326
2988
  const reaction = {
@@ -2349,7 +3011,8 @@ function slice() {
2349
3011
  _tag: "Slice",
2350
3012
  states,
2351
3013
  events,
2352
- projections
3014
+ projections,
3015
+ lanes
2353
3016
  }),
2354
3017
  events
2355
3018
  };
@@ -2442,6 +3105,7 @@ export {
2442
3105
  CommittedMetaSchema,
2443
3106
  ConcurrencyError,
2444
3107
  ConsoleLogger,
3108
+ DEFAULT_LANE,
2445
3109
  DEFAULT_MAX_SUBSCRIBED_STREAMS,
2446
3110
  DEFAULT_SETTLE_DEBOUNCE_MS,
2447
3111
  Environments,