@rotorsoft/act 0.44.0 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -379
- package/dist/.tsbuildinfo +1 -1
- package/dist/@types/act.d.ts +43 -5
- package/dist/@types/act.d.ts.map +1 -1
- package/dist/@types/adapters/console-logger.d.ts.map +1 -1
- package/dist/@types/adapters/in-memory-store.d.ts +4 -1
- package/dist/@types/adapters/in-memory-store.d.ts.map +1 -1
- package/dist/@types/builders/act-builder.d.ts +33 -9
- package/dist/@types/builders/act-builder.d.ts.map +1 -1
- package/dist/@types/builders/slice-builder.d.ts +23 -8
- package/dist/@types/builders/slice-builder.d.ts.map +1 -1
- package/dist/@types/internal/build-classify.d.ts +20 -0
- package/dist/@types/internal/build-classify.d.ts.map +1 -1
- package/dist/@types/internal/correlate-cycle.d.ts +1 -0
- package/dist/@types/internal/correlate-cycle.d.ts.map +1 -1
- package/dist/@types/internal/drain-cycle.d.ts +43 -3
- package/dist/@types/internal/drain-cycle.d.ts.map +1 -1
- package/dist/@types/internal/drain.d.ts +3 -1
- package/dist/@types/internal/drain.d.ts.map +1 -1
- package/dist/@types/internal/index.d.ts +3 -2
- package/dist/@types/internal/index.d.ts.map +1 -1
- package/dist/@types/internal/reactions.d.ts.map +1 -1
- package/dist/@types/internal/tracing.d.ts +51 -0
- package/dist/@types/internal/tracing.d.ts.map +1 -1
- package/dist/@types/ports.d.ts +10 -0
- package/dist/@types/ports.d.ts.map +1 -1
- package/dist/@types/test/sandbox.d.ts +1 -1
- package/dist/@types/test/sandbox.d.ts.map +1 -1
- package/dist/@types/types/ports.d.ts +9 -2
- package/dist/@types/types/ports.d.ts.map +1 -1
- package/dist/@types/types/reaction.d.ts +20 -2
- package/dist/@types/types/reaction.d.ts.map +1 -1
- package/dist/{chunk-LKRNWD7C.js → chunk-PGTC7VOC.js} +46 -11
- package/dist/chunk-PGTC7VOC.js.map +1 -0
- package/dist/index.cjs +1139 -884
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1097 -876
- package/dist/index.js.map +1 -1
- package/dist/test/index.cjs +45 -11
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.js +3 -3
- package/dist/test/index.js.map +1 -1
- package/package.json +2 -2
- 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-
|
|
22
|
+
} from "./chunk-PGTC7VOC.js";
|
|
22
23
|
import {
|
|
23
24
|
ActorSchema,
|
|
24
25
|
CausationEventSchema,
|
|
@@ -60,23 +61,39 @@ process.once("unhandledRejection", async (arg) => {
|
|
|
60
61
|
import EventEmitter from "events";
|
|
61
62
|
|
|
62
63
|
// src/internal/build-classify.ts
|
|
64
|
+
var ALL_LANES = /* @__PURE__ */ Symbol("act-1103/all-lanes");
|
|
63
65
|
function classifyRegistry(registry, states) {
|
|
64
66
|
const statics = /* @__PURE__ */ new Map();
|
|
65
67
|
const reactiveEvents = /* @__PURE__ */ new Set();
|
|
68
|
+
const eventToLanes = /* @__PURE__ */ new Map();
|
|
66
69
|
let hasDynamicResolvers = false;
|
|
67
70
|
for (const [name, register] of Object.entries(registry.events)) {
|
|
68
71
|
if (register.reactions.size > 0) reactiveEvents.add(name);
|
|
69
72
|
for (const reaction of register.reactions.values()) {
|
|
70
73
|
if (typeof reaction.resolver === "function") {
|
|
71
74
|
hasDynamicResolvers = true;
|
|
75
|
+
eventToLanes.set(name, ALL_LANES);
|
|
72
76
|
} else {
|
|
73
|
-
const { target, source, priority = 0 } = reaction.resolver;
|
|
77
|
+
const { target, source, priority = 0, lane } = reaction.resolver;
|
|
78
|
+
const lane_name = lane ?? "default";
|
|
79
|
+
const existing_lanes = eventToLanes.get(name);
|
|
80
|
+
if (existing_lanes !== ALL_LANES) {
|
|
81
|
+
const set = existing_lanes ?? /* @__PURE__ */ new Set();
|
|
82
|
+
set.add(lane_name);
|
|
83
|
+
eventToLanes.set(name, set);
|
|
84
|
+
}
|
|
74
85
|
const key = `${target}|${source ?? ""}`;
|
|
75
86
|
const existing = statics.get(key);
|
|
76
87
|
if (!existing) {
|
|
77
|
-
statics.set(key, { stream: target, source, priority });
|
|
78
|
-
} else
|
|
79
|
-
|
|
88
|
+
statics.set(key, { stream: target, source, priority, lane });
|
|
89
|
+
} else {
|
|
90
|
+
if ((existing.lane ?? void 0) !== (lane ?? void 0))
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Stream "${target}" has conflicting lane assignments ("${existing.lane ?? "default"}" vs "${lane ?? "default"}")`
|
|
93
|
+
);
|
|
94
|
+
if (priority > existing.priority) {
|
|
95
|
+
statics.set(key, { ...existing, priority });
|
|
96
|
+
}
|
|
80
97
|
}
|
|
81
98
|
}
|
|
82
99
|
}
|
|
@@ -91,7 +108,8 @@ function classifyRegistry(registry, states) {
|
|
|
91
108
|
staticTargets: [...statics.values()],
|
|
92
109
|
hasDynamicResolvers,
|
|
93
110
|
reactiveEvents,
|
|
94
|
-
eventToState
|
|
111
|
+
eventToState,
|
|
112
|
+
eventToLanes
|
|
95
113
|
};
|
|
96
114
|
}
|
|
97
115
|
|
|
@@ -302,6 +320,7 @@ var CorrelateCycle = class {
|
|
|
302
320
|
const entry = correlated.get(resolved.target) || {
|
|
303
321
|
source: resolved.source,
|
|
304
322
|
priority: incomingPriority,
|
|
323
|
+
lane: resolved.lane,
|
|
305
324
|
payloads: []
|
|
306
325
|
};
|
|
307
326
|
if (incomingPriority > entry.priority)
|
|
@@ -320,10 +339,11 @@ var CorrelateCycle = class {
|
|
|
320
339
|
);
|
|
321
340
|
if (correlated.size) {
|
|
322
341
|
const streams = [...correlated.entries()].map(
|
|
323
|
-
([stream, { source, priority }]) => ({
|
|
342
|
+
([stream, { source, priority, lane }]) => ({
|
|
324
343
|
stream,
|
|
325
344
|
source,
|
|
326
|
-
priority
|
|
345
|
+
priority,
|
|
346
|
+
lane
|
|
327
347
|
})
|
|
328
348
|
);
|
|
329
349
|
const { subscribed } = await this.cd.subscribe(streams);
|
|
@@ -408,904 +428,955 @@ function computeLagLeadRatio(handled, lagging, leading) {
|
|
|
408
428
|
return Math.max(RATIO_MIN, Math.min(RATIO_MAX, lagging_avg / total));
|
|
409
429
|
}
|
|
410
430
|
|
|
411
|
-
// src/internal/drain
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
|
431
|
+
// src/internal/drain.ts
|
|
432
|
+
var claim = (lagging, leading, by, millis, lane) => store().claim(lagging, leading, by, millis, lane);
|
|
433
|
+
async function fetch(leased, eventLimit) {
|
|
434
|
+
return Promise.all(
|
|
435
|
+
leased.map(async ({ stream, source, at, lagging }) => {
|
|
436
|
+
const events = [];
|
|
437
|
+
await store().query((e) => events.push(e), {
|
|
438
|
+
stream: source,
|
|
439
|
+
after: at,
|
|
440
|
+
limit: eventLimit
|
|
441
|
+
});
|
|
442
|
+
return { stream, source, at, lagging, events };
|
|
443
|
+
})
|
|
430
444
|
);
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
}
|
|
441
|
-
|
|
445
|
+
}
|
|
446
|
+
var ack = (leases) => store().ack(leases);
|
|
447
|
+
var block = (leases) => store().block(leases);
|
|
448
|
+
var subscribe = (streams) => store().subscribe(streams);
|
|
449
|
+
|
|
450
|
+
// src/internal/event-sourcing.ts
|
|
451
|
+
import { patch } from "@rotorsoft/act-patch";
|
|
452
|
+
async function snap(snapshot) {
|
|
453
|
+
try {
|
|
454
|
+
const { id, stream, name, meta, version } = snapshot.event;
|
|
455
|
+
await store().commit(
|
|
456
|
+
stream,
|
|
457
|
+
[{ name: SNAP_EVENT, data: snapshot.state }],
|
|
458
|
+
{
|
|
459
|
+
correlation: meta.correlation,
|
|
460
|
+
causation: { event: { id, name, stream } }
|
|
461
|
+
},
|
|
462
|
+
version
|
|
463
|
+
// IMPORTANT! - state events are committed right after the snapshot event
|
|
464
|
+
);
|
|
465
|
+
} catch (error) {
|
|
466
|
+
log().error(error);
|
|
442
467
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
468
|
+
}
|
|
469
|
+
async function tombstone(stream, expectedVersion, correlation) {
|
|
470
|
+
try {
|
|
471
|
+
const [committed] = await store().commit(
|
|
472
|
+
stream,
|
|
473
|
+
[{ name: TOMBSTONE_EVENT, data: {} }],
|
|
474
|
+
{ correlation, causation: {} },
|
|
475
|
+
expectedVersion
|
|
476
|
+
);
|
|
477
|
+
return committed;
|
|
478
|
+
} catch (error) {
|
|
479
|
+
if (error instanceof ConcurrencyError) return void 0;
|
|
480
|
+
throw error;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
async function load(me, stream, callback, asOf) {
|
|
484
|
+
const timeTravel = !!asOf && Object.values(asOf).some((v) => v !== void 0);
|
|
485
|
+
const cached = timeTravel ? void 0 : await cache().get(stream);
|
|
486
|
+
const cache_hit = !!cached;
|
|
487
|
+
let state2 = cached?.state ?? (me.init ? me.init() : {});
|
|
488
|
+
let patches = cached?.patches ?? 0;
|
|
489
|
+
let snaps = cached?.snaps ?? 0;
|
|
490
|
+
let version = cached?.version ?? -1;
|
|
491
|
+
let replayed = 0;
|
|
492
|
+
let event;
|
|
493
|
+
await store().query(
|
|
494
|
+
(e) => {
|
|
495
|
+
event = e;
|
|
496
|
+
version = e.version;
|
|
497
|
+
if (e.name === SNAP_EVENT) {
|
|
498
|
+
state2 = e.data;
|
|
499
|
+
snaps++;
|
|
500
|
+
patches = 0;
|
|
501
|
+
replayed++;
|
|
502
|
+
} else if (me.patch[e.name]) {
|
|
503
|
+
state2 = patch(state2, me.patch[e.name](event, state2));
|
|
504
|
+
patches++;
|
|
505
|
+
replayed++;
|
|
506
|
+
} else if (e.name !== TOMBSTONE_EVENT) {
|
|
507
|
+
log().warn(
|
|
508
|
+
`Skipping unknown event "${String(e.name)}" on stream "${stream}" (id=${e.id}) \u2014 no reducer in state "${me.name}"`
|
|
509
|
+
);
|
|
451
510
|
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
511
|
+
callback?.({
|
|
512
|
+
event,
|
|
513
|
+
state: state2,
|
|
514
|
+
version,
|
|
515
|
+
patches,
|
|
516
|
+
snaps,
|
|
517
|
+
cache_hit,
|
|
518
|
+
replayed
|
|
519
|
+
});
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
stream,
|
|
523
|
+
stream_exact: true,
|
|
524
|
+
...cached ? { after: cached.event_id } : { with_snaps: true, ...asOf }
|
|
525
|
+
}
|
|
460
526
|
);
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
};
|
|
469
|
-
var DrainController = class {
|
|
470
|
-
constructor(deps) {
|
|
471
|
-
this.deps = deps;
|
|
527
|
+
if (replayed > 0 && !timeTravel && event) {
|
|
528
|
+
await cache().set(stream, {
|
|
529
|
+
state: state2,
|
|
530
|
+
version,
|
|
531
|
+
event_id: event.id,
|
|
532
|
+
patches,
|
|
533
|
+
snaps
|
|
534
|
+
});
|
|
472
535
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
536
|
+
return { event, state: state2, version, patches, snaps, cache_hit, replayed };
|
|
537
|
+
}
|
|
538
|
+
async function action(me, action2, target, payload, reactingTo, skipValidation = false, correlator = defaultCorrelator) {
|
|
539
|
+
const { stream, expectedVersion, actor } = target;
|
|
540
|
+
if (!stream) throw new Error("Missing target stream");
|
|
541
|
+
const validated = skipValidation ? payload : validate(action2, payload, me.actions[action2]);
|
|
542
|
+
const snapshot = await load(me, stream);
|
|
543
|
+
if (snapshot.event?.name === TOMBSTONE_EVENT)
|
|
544
|
+
throw new StreamClosedError(stream);
|
|
545
|
+
const expected = expectedVersion ?? snapshot.event?.version;
|
|
546
|
+
if (me.given) {
|
|
547
|
+
const invariants = me.given[action2] || [];
|
|
548
|
+
invariants.forEach(({ valid, description }) => {
|
|
549
|
+
if (!valid(snapshot.state, actor))
|
|
550
|
+
throw new InvariantError(
|
|
551
|
+
action2,
|
|
552
|
+
validated,
|
|
553
|
+
target,
|
|
554
|
+
snapshot,
|
|
555
|
+
description
|
|
556
|
+
);
|
|
557
|
+
});
|
|
492
558
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
559
|
+
const result = me.on[action2](validated, snapshot, target);
|
|
560
|
+
if (!result) return [snapshot];
|
|
561
|
+
if (Array.isArray(result) && result.length === 0) {
|
|
562
|
+
return [snapshot];
|
|
496
563
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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);
|
|
564
|
+
const tuples = Array.isArray(result[0]) ? result : [result];
|
|
565
|
+
const deprecated = me._deprecated;
|
|
566
|
+
if (deprecated && deprecated.size > 0) {
|
|
567
|
+
const me_ = me;
|
|
568
|
+
const warned = me_._warned ?? (me_._warned = /* @__PURE__ */ new Set());
|
|
569
|
+
for (const [name] of tuples) {
|
|
570
|
+
const evt = name;
|
|
571
|
+
if (deprecated.has(evt) && !warned.has(evt)) {
|
|
572
|
+
warned.add(evt);
|
|
573
|
+
log().warn(
|
|
574
|
+
`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)`
|
|
575
|
+
);
|
|
518
576
|
}
|
|
519
|
-
|
|
520
|
-
}, delay);
|
|
521
|
-
this._backoffTimer.unref();
|
|
577
|
+
}
|
|
522
578
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
}
|
|
559
|
-
}
|
|
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;
|
|
571
|
-
}
|
|
572
|
-
}
|
|
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;
|
|
600
|
-
}
|
|
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 };
|
|
608
|
-
}
|
|
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();
|
|
618
|
-
}
|
|
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
|
-
}
|
|
634
|
-
}
|
|
635
|
-
}
|
|
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
|
-
}
|
|
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
|
-
);
|
|
579
|
+
const emitted = tuples.map(([name, data]) => ({
|
|
580
|
+
name,
|
|
581
|
+
data: skipValidation ? data : validate(name, data, me.events[name])
|
|
582
|
+
}));
|
|
583
|
+
const meta = {
|
|
584
|
+
correlation: reactingTo?.meta.correlation || correlator({
|
|
585
|
+
action: action2,
|
|
586
|
+
state: me.name,
|
|
587
|
+
stream,
|
|
588
|
+
actor: target.actor
|
|
589
|
+
}),
|
|
590
|
+
causation: {
|
|
591
|
+
action: {
|
|
592
|
+
name: action2,
|
|
593
|
+
...target
|
|
594
|
+
// payload intentionally omitted: it can be large or contain PII,
|
|
595
|
+
// and callers correlate via the correlation id when they need it.
|
|
596
|
+
},
|
|
597
|
+
event: reactingTo ? {
|
|
598
|
+
id: reactingTo.id,
|
|
599
|
+
name: reactingTo.name,
|
|
600
|
+
stream: reactingTo.stream
|
|
601
|
+
} : void 0
|
|
673
602
|
}
|
|
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
603
|
};
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
}
|
|
604
|
+
let committed;
|
|
605
|
+
try {
|
|
606
|
+
committed = await store().commit(
|
|
607
|
+
stream,
|
|
608
|
+
emitted,
|
|
609
|
+
meta,
|
|
610
|
+
// Reactions skip optimistic concurrency: they always append against the
|
|
611
|
+
// current head. Stream leasing already serializes concurrent reactions,
|
|
612
|
+
// and forcing version checks here would turn ordinary catch-up into
|
|
613
|
+
// spurious retries.
|
|
614
|
+
reactingTo ? void 0 : expected
|
|
615
|
+
);
|
|
616
|
+
} catch (error) {
|
|
617
|
+
if (error instanceof ConcurrencyError) {
|
|
618
|
+
await cache().invalidate(stream);
|
|
747
619
|
}
|
|
620
|
+
throw error;
|
|
748
621
|
}
|
|
622
|
+
let { state: state2, patches } = snapshot;
|
|
623
|
+
const snapshots = committed.map((event) => {
|
|
624
|
+
const p = me.patch[event.name](event, state2);
|
|
625
|
+
state2 = patch(state2, p);
|
|
626
|
+
patches++;
|
|
627
|
+
return {
|
|
628
|
+
event,
|
|
629
|
+
state: state2,
|
|
630
|
+
version: event.version,
|
|
631
|
+
patches,
|
|
632
|
+
snaps: snapshot.snaps,
|
|
633
|
+
patch: p,
|
|
634
|
+
cache_hit: snapshot.cache_hit,
|
|
635
|
+
replayed: snapshot.replayed
|
|
636
|
+
};
|
|
637
|
+
});
|
|
638
|
+
const last = snapshots.at(-1);
|
|
639
|
+
const snapped = me.snap?.(last);
|
|
640
|
+
cache().set(stream, {
|
|
641
|
+
state: last.state,
|
|
642
|
+
version: last.event.version,
|
|
643
|
+
event_id: last.event.id,
|
|
644
|
+
patches: snapped ? 0 : last.patches,
|
|
645
|
+
snaps: snapped ? last.snaps + 1 : last.snaps
|
|
646
|
+
}).catch((err) => log().error(err));
|
|
647
|
+
if (snapped) void snap(last);
|
|
648
|
+
return snapshots;
|
|
749
649
|
}
|
|
750
|
-
var _this_ = ({ stream }) => ({
|
|
751
|
-
source: stream,
|
|
752
|
-
target: stream
|
|
753
|
-
});
|
|
754
650
|
|
|
755
|
-
// src/internal/
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
651
|
+
// src/internal/tracing.ts
|
|
652
|
+
var PRETTY = config().env !== "production";
|
|
653
|
+
var C_BLUE = "\x1B[38;5;39m";
|
|
654
|
+
var C_ORANGE = "\x1B[38;5;208m";
|
|
655
|
+
var C_GREEN = "\x1B[38;5;42m";
|
|
656
|
+
var C_MAGENTA = "\x1B[38;5;165m";
|
|
657
|
+
var C_DRAIN = "\x1B[38;5;244m";
|
|
658
|
+
var C_HIT = "\x1B[38;5;82m";
|
|
659
|
+
var C_MISS = "\x1B[38;5;220m";
|
|
660
|
+
var C_RESET = "\x1B[0m";
|
|
661
|
+
var es_caption = (caption, color, body) => PRETTY ? `${color}${body}${C_RESET}` : `${caption}: ${body}`;
|
|
662
|
+
var C_LANE = "\x1B[38;5;183m";
|
|
663
|
+
var C_DIM = "\x1B[38;5;240m";
|
|
664
|
+
var C_ERR = "\x1B[38;5;196m";
|
|
665
|
+
var C_STREAM = "\x1B[38;5;226m";
|
|
666
|
+
var dim = (text) => PRETTY ? `${C_DIM}${text}${C_RESET}` : text;
|
|
667
|
+
var hue = (color, text) => PRETTY ? `${color}${text}${C_RESET}` : text;
|
|
668
|
+
var drain_caption = (caption, lane) => {
|
|
669
|
+
const showLane = lane && lane !== "default";
|
|
670
|
+
if (PRETTY) {
|
|
671
|
+
const tag = `${C_DRAIN}>> ${caption}${C_RESET}`;
|
|
672
|
+
return showLane ? `${tag} ${C_LANE}${lane}${C_RESET}` : tag;
|
|
673
|
+
}
|
|
674
|
+
return showLane ? `>> ${caption} ${lane}` : `>> ${caption}`;
|
|
675
|
+
};
|
|
676
|
+
var cache_marker = (hit) => {
|
|
677
|
+
const word = hit ? "hit" : "miss";
|
|
678
|
+
if (!PRETTY) return word;
|
|
679
|
+
return `${hit ? C_HIT : C_MISS}${word}${C_RESET}${C_GREEN}`;
|
|
680
|
+
};
|
|
681
|
+
var stats_marker = (version, replayed, snaps, patches) => {
|
|
682
|
+
const text = `v=${version} replayed=${replayed} snaps=${snaps} patches=${patches}`;
|
|
683
|
+
if (!PRETTY) return text;
|
|
684
|
+
return `${C_DRAIN}${text}${C_RESET}${C_GREEN}`;
|
|
685
|
+
};
|
|
686
|
+
var as_of_marker = (asOf) => {
|
|
687
|
+
if (!asOf) return "";
|
|
688
|
+
const parts = [];
|
|
689
|
+
if (asOf.before !== void 0) parts.push(`before=${asOf.before}`);
|
|
690
|
+
if (asOf.created_before !== void 0)
|
|
691
|
+
parts.push(`created_before=${asOf.created_before.toISOString()}`);
|
|
692
|
+
if (asOf.created_after !== void 0)
|
|
693
|
+
parts.push(`created_after=${asOf.created_after.toISOString()}`);
|
|
694
|
+
if (asOf.limit !== void 0) parts.push(`limit=${asOf.limit}`);
|
|
695
|
+
return parts.length ? ` (as-of ${parts.join(" ")})` : " (as-of)";
|
|
696
|
+
};
|
|
697
|
+
var traced = (inner, exit, entry) => (async (...args) => {
|
|
698
|
+
entry?.(...args);
|
|
699
|
+
const result = await inner(...args);
|
|
700
|
+
exit?.(result, ...args);
|
|
701
|
+
return result;
|
|
702
|
+
});
|
|
703
|
+
function buildEs(logger, correlator = defaultCorrelator) {
|
|
704
|
+
const boundAction = (me, actionName, target, payload, reactingTo, skipValidation = false) => action(
|
|
705
|
+
me,
|
|
706
|
+
actionName,
|
|
707
|
+
target,
|
|
708
|
+
payload,
|
|
709
|
+
reactingTo,
|
|
710
|
+
skipValidation,
|
|
711
|
+
correlator
|
|
712
|
+
);
|
|
713
|
+
if (logger.level !== "trace") {
|
|
714
|
+
return {
|
|
715
|
+
snap,
|
|
716
|
+
load,
|
|
717
|
+
action: boundAction,
|
|
718
|
+
tombstone
|
|
719
|
+
};
|
|
771
720
|
}
|
|
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
721
|
return {
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
|
722
|
+
snap: traced(snap, void 0, (snapshot) => {
|
|
723
|
+
logger.trace(
|
|
724
|
+
es_caption(
|
|
725
|
+
"snap",
|
|
726
|
+
C_MAGENTA,
|
|
727
|
+
`${snapshot.event.stream}@${snapshot.event.version}`
|
|
728
|
+
)
|
|
819
729
|
);
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
730
|
+
}),
|
|
731
|
+
load: traced(load, (result, _me, stream, _cb, asOf) => {
|
|
732
|
+
const stats = stats_marker(
|
|
733
|
+
result.version,
|
|
734
|
+
result.replayed,
|
|
735
|
+
result.snaps,
|
|
736
|
+
result.patches
|
|
737
|
+
);
|
|
738
|
+
logger.trace(
|
|
739
|
+
es_caption(
|
|
740
|
+
"load",
|
|
741
|
+
C_GREEN,
|
|
742
|
+
`${stream}${as_of_marker(asOf)} ${cache_marker(result.cache_hit)} ${stats}`
|
|
743
|
+
)
|
|
744
|
+
);
|
|
745
|
+
}),
|
|
746
|
+
action: traced(
|
|
747
|
+
boundAction,
|
|
748
|
+
(snapshots, _me, _action, target) => {
|
|
749
|
+
const committed = snapshots.filter((s) => s.event);
|
|
750
|
+
if (committed.length) {
|
|
751
|
+
logger.trace(
|
|
752
|
+
committed.map((s) => s.event.data),
|
|
753
|
+
es_caption(
|
|
754
|
+
"committed",
|
|
755
|
+
C_ORANGE,
|
|
756
|
+
`${target.stream}.${committed.map((s) => s.event.name).join(", ")}`
|
|
757
|
+
)
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
},
|
|
761
|
+
(_me, action2, target, payload) => {
|
|
762
|
+
logger.trace(
|
|
763
|
+
payload,
|
|
764
|
+
es_caption("action", C_BLUE, `${target.stream}.${action2}`)
|
|
832
765
|
);
|
|
833
766
|
}
|
|
834
|
-
|
|
835
|
-
|
|
767
|
+
),
|
|
768
|
+
tombstone: traced(tombstone, (committed, stream) => {
|
|
769
|
+
if (committed)
|
|
770
|
+
logger.trace(
|
|
771
|
+
es_caption("tombstoned", C_ORANGE, `${stream}@${committed.version}`)
|
|
772
|
+
);
|
|
773
|
+
})
|
|
836
774
|
};
|
|
837
775
|
}
|
|
838
|
-
function
|
|
839
|
-
return
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
logger
|
|
854
|
-
);
|
|
855
|
-
} catch (error) {
|
|
856
|
-
return finalize(lease, 0, lease.at, error, options, logger);
|
|
857
|
-
}
|
|
776
|
+
function buildDrain(logger) {
|
|
777
|
+
return {
|
|
778
|
+
claim,
|
|
779
|
+
fetch,
|
|
780
|
+
ack,
|
|
781
|
+
block,
|
|
782
|
+
subscribe: logger.level !== "trace" ? subscribe : traced(subscribe, (result, streams) => {
|
|
783
|
+
if (!result.subscribed) return;
|
|
784
|
+
const lanes = new Set(streams.map((s) => s.lane ?? "default"));
|
|
785
|
+
const uniformLane = lanes.size === 1 ? streams[0]?.lane : void 0;
|
|
786
|
+
const data = streams.map(
|
|
787
|
+
({ stream, lane }) => uniformLane || !lane || lane === "default" ? hue(C_STREAM, stream) : `${hue(C_STREAM, stream)}${dim(`[${lane}]`)}`
|
|
788
|
+
).join(" ");
|
|
789
|
+
logger.trace(`${drain_caption("correlated", uniformLane)} ${data}`);
|
|
790
|
+
})
|
|
858
791
|
};
|
|
859
792
|
}
|
|
793
|
+
function traceCycle(logger, leased, fetched, handled, acked, blocked) {
|
|
794
|
+
if (logger.level !== "trace" || !leased.length) return;
|
|
795
|
+
const lane = leased[0]?.lane;
|
|
796
|
+
const fetchByStream = new Map(fetched.map((f) => [f.stream, f]));
|
|
797
|
+
const ackedByStream = new Map(acked.map((a) => [a.stream, a.at]));
|
|
798
|
+
const blockedByStream = new Map(blocked.map((b) => [b.stream, b.error]));
|
|
799
|
+
const failedByStream = new Map(
|
|
800
|
+
handled.filter((h) => h.error).map((h) => [h.lease.stream, h])
|
|
801
|
+
);
|
|
802
|
+
const detail = leased.map(({ stream, at, retry }) => {
|
|
803
|
+
const f = fetchByStream.get(stream);
|
|
804
|
+
const key = f?.source ? `${hue(C_STREAM, stream)}${dim(`<-${f.source}`)}` : hue(C_STREAM, stream);
|
|
805
|
+
const events = f && f.events.length ? ` ${dim(
|
|
806
|
+
`[${f.events.map(({ id, name }) => `#${id} ${String(name)}`).join(", ")}]`
|
|
807
|
+
)}` : "";
|
|
808
|
+
const ackedAt = ackedByStream.get(stream);
|
|
809
|
+
const ackPart = ackedAt !== void 0 ? hue(C_HIT, `\u2713 @${ackedAt}`) : "";
|
|
810
|
+
const failure = failedByStream.get(stream);
|
|
811
|
+
let failPart = "";
|
|
812
|
+
if (failure) {
|
|
813
|
+
const failedAt = failure.failed_at ?? at;
|
|
814
|
+
const blockedError = blockedByStream.get(stream);
|
|
815
|
+
if (blockedError !== void 0) {
|
|
816
|
+
failPart = `${hue(C_ERR, `\u2717 @${failedAt}/${retry}`)} ${dim(`(${blockedError})`)}`;
|
|
817
|
+
} else {
|
|
818
|
+
failPart = `${hue(C_MISS, `\u26A0 @${failedAt}/${retry}`)} ${dim(`(${failure.error})`)}`;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
let tail;
|
|
822
|
+
if (ackPart && failPart) tail = ` ${ackPart} ${failPart}`;
|
|
823
|
+
else if (ackPart) tail = ` ${ackPart}`;
|
|
824
|
+
else if (failPart) tail = ` ${failPart}`;
|
|
825
|
+
else tail = ` ${dim(`\u2298 @${at}/${retry}`)}`;
|
|
826
|
+
return `${key}${events}${tail}`;
|
|
827
|
+
}).join(", ");
|
|
828
|
+
logger.trace(`${drain_caption("drained", lane)} ${detail}`);
|
|
829
|
+
}
|
|
860
830
|
|
|
861
|
-
// src/internal/
|
|
862
|
-
|
|
863
|
-
|
|
831
|
+
// src/internal/drain-cycle.ts
|
|
832
|
+
async function runDrainCycle(ops, registry, batchHandlers, handle, handleBatch, lagging, leading, eventLimit, leaseMillis, isDeferred, lane) {
|
|
833
|
+
const leased = await ops.claim(
|
|
834
|
+
lagging,
|
|
835
|
+
leading,
|
|
836
|
+
randomUUID(),
|
|
837
|
+
leaseMillis,
|
|
838
|
+
lane
|
|
839
|
+
);
|
|
840
|
+
if (!leased.length) return void 0;
|
|
841
|
+
const active = isDeferred ? leased.filter((l) => !isDeferred(l.stream)) : leased;
|
|
842
|
+
if (!active.length) {
|
|
843
|
+
return {
|
|
844
|
+
leased,
|
|
845
|
+
fetched: [],
|
|
846
|
+
handled: [],
|
|
847
|
+
acked: [],
|
|
848
|
+
blocked: []
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
const fetched = await ops.fetch(active, eventLimit);
|
|
852
|
+
const fetchMap = /* @__PURE__ */ new Map();
|
|
853
|
+
const fetch_window_at = fetched.reduce(
|
|
854
|
+
(max, { at, events }) => Math.max(max, events.at(-1)?.id || at),
|
|
855
|
+
0
|
|
856
|
+
);
|
|
857
|
+
for (const f of fetched) {
|
|
858
|
+
const { stream, events } = f;
|
|
859
|
+
const payloads = events.flatMap((event) => {
|
|
860
|
+
const register = registry.events[event.name];
|
|
861
|
+
if (!register) return [];
|
|
862
|
+
return [...register.reactions.values()].filter((reaction) => {
|
|
863
|
+
const resolved = typeof reaction.resolver === "function" ? reaction.resolver(event) : reaction.resolver;
|
|
864
|
+
return resolved && resolved.target === stream;
|
|
865
|
+
}).map((reaction) => ({ ...reaction, event }));
|
|
866
|
+
});
|
|
867
|
+
fetchMap.set(stream, { fetch: f, payloads });
|
|
868
|
+
}
|
|
869
|
+
const handled = await Promise.all(
|
|
870
|
+
active.map((lease) => {
|
|
871
|
+
const entry = fetchMap.get(lease.stream);
|
|
872
|
+
const at = entry.fetch.events.at(-1)?.id || fetch_window_at;
|
|
873
|
+
const { payloads } = entry;
|
|
874
|
+
const batchHandler = batchHandlers.get(lease.stream);
|
|
875
|
+
if (batchHandler && payloads.length > 0) {
|
|
876
|
+
return handleBatch({ ...lease, at }, payloads, batchHandler);
|
|
877
|
+
}
|
|
878
|
+
return handle({ ...lease, at }, payloads);
|
|
879
|
+
})
|
|
880
|
+
);
|
|
881
|
+
const acked = await ops.ack(
|
|
882
|
+
handled.filter((h) => h.handled > 0 || !h.error).map((h) => ({ ...h.lease, at: h.acked_at }))
|
|
883
|
+
);
|
|
884
|
+
const blocked = await ops.block(
|
|
885
|
+
handled.filter(({ block: block2 }) => block2).map(({ lease, error }) => ({ ...lease, error }))
|
|
886
|
+
);
|
|
887
|
+
return { leased, fetched, handled, acked, blocked };
|
|
888
|
+
}
|
|
889
|
+
var EMPTY_DRAIN = {
|
|
890
|
+
fetched: [],
|
|
891
|
+
leased: [],
|
|
892
|
+
acked: [],
|
|
893
|
+
blocked: []
|
|
894
|
+
};
|
|
895
|
+
var DrainController = class {
|
|
896
|
+
constructor(deps) {
|
|
864
897
|
this.deps = deps;
|
|
865
|
-
this.defaultDebounceMs = defaultDebounceMs;
|
|
866
898
|
}
|
|
867
|
-
|
|
868
|
-
|
|
899
|
+
_armed = false;
|
|
900
|
+
_locked = false;
|
|
901
|
+
_ratio = 0.5;
|
|
902
|
+
/**
|
|
903
|
+
* Per-stream backoff: `stream → nextAttemptAt` (ms since epoch). Set by
|
|
904
|
+
* `_finalize` via `HandleResult.nextAttemptAt`; cleared on successful
|
|
905
|
+
* ack or terminal block. Lives in process memory — per-worker pacing
|
|
906
|
+
* by design (see {@link BackoffOptions} for the multi-worker trade-off).
|
|
907
|
+
*/
|
|
908
|
+
_backoff = /* @__PURE__ */ new Map();
|
|
909
|
+
/** Timer re-arming drain at the earliest pending `nextAttemptAt`. */
|
|
910
|
+
_backoffTimer;
|
|
911
|
+
/** Worker timer (ACT-1103). Set when `start()` is active, undefined otherwise. */
|
|
912
|
+
_worker;
|
|
913
|
+
_stopped = false;
|
|
914
|
+
/**
|
|
915
|
+
* Signal that a commit (or reset / cold-start) may have produced work.
|
|
916
|
+
* Subsequent `drain()` calls will run the pipeline; once the pipeline
|
|
917
|
+
* settles to no-progress, the controller disarms itself.
|
|
918
|
+
*/
|
|
919
|
+
arm() {
|
|
920
|
+
this._armed = true;
|
|
921
|
+
}
|
|
922
|
+
/** Read-only flag — true while a commit / reset is unprocessed. */
|
|
923
|
+
get armed() {
|
|
924
|
+
return this._armed;
|
|
925
|
+
}
|
|
926
|
+
/** Returns true when `stream` is currently within a backoff window. */
|
|
927
|
+
isDeferred = (stream) => {
|
|
928
|
+
const next = this._backoff.get(stream);
|
|
929
|
+
return next !== void 0 && next > Date.now();
|
|
930
|
+
};
|
|
931
|
+
/**
|
|
932
|
+
* Schedule the next drain re-arm at the earliest pending backoff
|
|
933
|
+
* expiry. Called only when the backoff map is non-empty (caller guard).
|
|
934
|
+
* Idempotent — collapses many simultaneously deferred streams into a
|
|
935
|
+
* single timer.
|
|
936
|
+
*/
|
|
937
|
+
scheduleBackoffWake() {
|
|
938
|
+
if (this._backoffTimer) clearTimeout(this._backoffTimer);
|
|
939
|
+
let earliest = Number.POSITIVE_INFINITY;
|
|
940
|
+
for (const t of this._backoff.values()) if (t < earliest) earliest = t;
|
|
941
|
+
const delay = Math.max(0, earliest - Date.now());
|
|
942
|
+
this._backoffTimer = setTimeout(() => {
|
|
943
|
+
this._backoffTimer = void 0;
|
|
944
|
+
const now = Date.now();
|
|
945
|
+
for (const [stream, at] of this._backoff) {
|
|
946
|
+
if (at <= now) this._backoff.delete(stream);
|
|
947
|
+
}
|
|
948
|
+
this._armed = true;
|
|
949
|
+
}, delay);
|
|
950
|
+
this._backoffTimer.unref();
|
|
951
|
+
}
|
|
952
|
+
/** Lane this controller drains (undefined = legacy single-lane span). */
|
|
953
|
+
get lane() {
|
|
954
|
+
return this.deps.lane;
|
|
955
|
+
}
|
|
869
956
|
/**
|
|
870
|
-
*
|
|
871
|
-
*
|
|
872
|
-
*
|
|
873
|
-
*
|
|
874
|
-
*
|
|
957
|
+
* Start a per-lane worker that drains at the lane's `cycleMs`
|
|
958
|
+
* cadence (ACT-1103). When armed, the worker calls `drain()` on every
|
|
959
|
+
* tick and re-schedules; when not armed, it still re-schedules at
|
|
960
|
+
* `cycleMs` so a future `arm()` is picked up on the next tick.
|
|
961
|
+
*
|
|
962
|
+
* The setTimeout chain uses `unref()` so it doesn't keep the process
|
|
963
|
+
* alive on its own.
|
|
875
964
|
*/
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
this.
|
|
885
|
-
|
|
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);
|
|
965
|
+
start(cycleMs) {
|
|
966
|
+
if (this._worker || this._stopped) return;
|
|
967
|
+
const tick = async () => {
|
|
968
|
+
if (this._armed) await this.drain();
|
|
969
|
+
if (this._stopped) return;
|
|
970
|
+
this._worker = setTimeout(tick, cycleMs);
|
|
971
|
+
this._worker.unref();
|
|
972
|
+
};
|
|
973
|
+
this._worker = setTimeout(tick, cycleMs);
|
|
974
|
+
this._worker.unref();
|
|
905
975
|
}
|
|
906
|
-
/**
|
|
976
|
+
/** Stop the per-lane worker. Idempotent. */
|
|
907
977
|
stop() {
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
this.
|
|
978
|
+
this._stopped = true;
|
|
979
|
+
if (this._worker) {
|
|
980
|
+
clearTimeout(this._worker);
|
|
981
|
+
this._worker = void 0;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
/** Run one drain pass. Short-circuits when not armed or already running. */
|
|
985
|
+
async drain(options = {}) {
|
|
986
|
+
if (!this._armed) return EMPTY_DRAIN;
|
|
987
|
+
if (this._locked) return EMPTY_DRAIN;
|
|
988
|
+
const d = this.deps.defaults ?? {};
|
|
989
|
+
const streamLimit = d.streamLimit ?? options.streamLimit ?? 10;
|
|
990
|
+
const eventLimit = d.eventLimit ?? options.eventLimit ?? 10;
|
|
991
|
+
const leaseMillis = d.leaseMillis ?? options.leaseMillis ?? 1e4;
|
|
992
|
+
try {
|
|
993
|
+
this._locked = true;
|
|
994
|
+
const lagging = Math.ceil(streamLimit * this._ratio);
|
|
995
|
+
const leading = streamLimit - lagging;
|
|
996
|
+
const cycle = await runDrainCycle(
|
|
997
|
+
this.deps.ops,
|
|
998
|
+
this.deps.registry,
|
|
999
|
+
this.deps.batchHandlers,
|
|
1000
|
+
this.deps.handle,
|
|
1001
|
+
this.deps.handleBatch,
|
|
1002
|
+
lagging,
|
|
1003
|
+
leading,
|
|
1004
|
+
eventLimit,
|
|
1005
|
+
leaseMillis,
|
|
1006
|
+
this._backoff.size > 0 ? this.isDeferred : void 0,
|
|
1007
|
+
this.deps.lane
|
|
1008
|
+
);
|
|
1009
|
+
if (!cycle) {
|
|
1010
|
+
this._armed = false;
|
|
1011
|
+
return EMPTY_DRAIN;
|
|
1012
|
+
}
|
|
1013
|
+
const { leased, fetched, handled, acked, blocked } = cycle;
|
|
1014
|
+
traceCycle(this.deps.logger, leased, fetched, handled, acked, blocked);
|
|
1015
|
+
this._ratio = computeLagLeadRatio(handled, lagging, leading);
|
|
1016
|
+
for (const lease of acked) this._backoff.delete(lease.stream);
|
|
1017
|
+
for (const lease of blocked) this._backoff.delete(lease.stream);
|
|
1018
|
+
for (const h of handled) {
|
|
1019
|
+
if (h.nextAttemptAt !== void 0 && !h.block) {
|
|
1020
|
+
this._backoff.set(h.lease.stream, h.nextAttemptAt);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
if (this._backoff.size > 0) this.scheduleBackoffWake();
|
|
1024
|
+
if (acked.length) this.deps.onAcked(acked);
|
|
1025
|
+
if (blocked.length) this.deps.onBlocked(blocked);
|
|
1026
|
+
const hasErrors = handled.some(({ error }) => error);
|
|
1027
|
+
if (!acked.length && !blocked.length && !hasErrors) this._armed = false;
|
|
1028
|
+
return { fetched, leased, acked, blocked };
|
|
1029
|
+
} catch (error) {
|
|
1030
|
+
this.deps.logger.error(error);
|
|
1031
|
+
return EMPTY_DRAIN;
|
|
1032
|
+
} finally {
|
|
1033
|
+
this._locked = false;
|
|
911
1034
|
}
|
|
912
1035
|
}
|
|
913
1036
|
};
|
|
914
1037
|
|
|
915
|
-
// src/internal/
|
|
916
|
-
var
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
limit: eventLimit
|
|
925
|
-
});
|
|
926
|
-
return { stream, source, at, lagging, events };
|
|
927
|
-
})
|
|
928
|
-
);
|
|
1038
|
+
// src/internal/event-versions.ts
|
|
1039
|
+
var VERSION_SUFFIX = /^(.+?)_v(\d+)$/;
|
|
1040
|
+
function parse(name) {
|
|
1041
|
+
const m = name.match(VERSION_SUFFIX);
|
|
1042
|
+
if (m) {
|
|
1043
|
+
const v = Number.parseInt(m[2], 10);
|
|
1044
|
+
if (v >= 2) return { base: m[1], version: v };
|
|
1045
|
+
}
|
|
1046
|
+
return { base: name, version: 1 };
|
|
929
1047
|
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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);
|
|
1048
|
+
function deprecatedEventNames(names) {
|
|
1049
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1050
|
+
for (const name of names) {
|
|
1051
|
+
const { base, version } = parse(name);
|
|
1052
|
+
const list = groups.get(base);
|
|
1053
|
+
if (list) list.push({ version, name });
|
|
1054
|
+
else groups.set(base, [{ version, name }]);
|
|
1055
|
+
}
|
|
1056
|
+
const deprecated = /* @__PURE__ */ new Set();
|
|
1057
|
+
for (const list of groups.values()) {
|
|
1058
|
+
if (list.length < 2) continue;
|
|
1059
|
+
list.sort((a, b) => b.version - a.version);
|
|
1060
|
+
for (let i = 1; i < list.length; i++) deprecated.add(list[i].name);
|
|
951
1061
|
}
|
|
1062
|
+
return deprecated;
|
|
952
1063
|
}
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
);
|
|
961
|
-
return committed;
|
|
962
|
-
} catch (error) {
|
|
963
|
-
if (error instanceof ConcurrencyError) return void 0;
|
|
964
|
-
throw error;
|
|
1064
|
+
function currentVersionOf(deprecatedName, allNames) {
|
|
1065
|
+
const target = parse(deprecatedName);
|
|
1066
|
+
let highest;
|
|
1067
|
+
for (const name of allNames) {
|
|
1068
|
+
const { base, version } = parse(name);
|
|
1069
|
+
if (base !== target.base) continue;
|
|
1070
|
+
if (!highest || version > highest.version) highest = { version, name };
|
|
965
1071
|
}
|
|
1072
|
+
return highest && highest.version > target.version ? highest.name : void 0;
|
|
966
1073
|
}
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
let
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
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
|
-
);
|
|
1074
|
+
|
|
1075
|
+
// src/internal/merge.ts
|
|
1076
|
+
import { ZodObject } from "zod";
|
|
1077
|
+
function baseTypeName(zodType) {
|
|
1078
|
+
let t = zodType;
|
|
1079
|
+
while (typeof t.unwrap === "function") {
|
|
1080
|
+
t = t.unwrap();
|
|
1081
|
+
}
|
|
1082
|
+
return t.constructor.name;
|
|
1083
|
+
}
|
|
1084
|
+
function mergeSchemas(existing, incoming, stateName) {
|
|
1085
|
+
if (existing instanceof ZodObject && incoming instanceof ZodObject) {
|
|
1086
|
+
const existingShape = existing.shape;
|
|
1087
|
+
const incomingShape = incoming.shape;
|
|
1088
|
+
for (const key of Object.keys(incomingShape)) {
|
|
1089
|
+
if (key in existingShape) {
|
|
1090
|
+
const existingBase = baseTypeName(existingShape[key]);
|
|
1091
|
+
const incomingBase = baseTypeName(incomingShape[key]);
|
|
1092
|
+
if (existingBase !== incomingBase) {
|
|
1093
|
+
throw new Error(
|
|
1094
|
+
`Schema conflict in "${stateName}": key "${key}" has type "${existingBase}" but incoming partial declares "${incomingBase}"`
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
994
1097
|
}
|
|
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
1098
|
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1099
|
+
return existing.extend(incomingShape);
|
|
1100
|
+
}
|
|
1101
|
+
return existing;
|
|
1102
|
+
}
|
|
1103
|
+
function mergeInits(existing, incoming) {
|
|
1104
|
+
return () => ({ ...existing(), ...incoming() });
|
|
1105
|
+
}
|
|
1106
|
+
function registerState(state2, states, actions, events) {
|
|
1107
|
+
const existing = states.get(state2.name);
|
|
1108
|
+
if (existing) {
|
|
1109
|
+
mergeIntoExisting(state2, existing, states, actions, events);
|
|
1110
|
+
} else {
|
|
1111
|
+
registerNewState(state2, states, actions, events);
|
|
1019
1112
|
}
|
|
1020
|
-
return { event, state: state2, version, patches, snaps, cache_hit, replayed };
|
|
1021
1113
|
}
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
-
});
|
|
1114
|
+
function registerNewState(state2, states, actions, events) {
|
|
1115
|
+
states.set(state2.name, state2);
|
|
1116
|
+
for (const name of Object.keys(state2.actions)) {
|
|
1117
|
+
if (actions[name]) throw new Error(`Duplicate action "${name}"`);
|
|
1118
|
+
actions[name] = state2;
|
|
1042
1119
|
}
|
|
1043
|
-
const
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
return [snapshot];
|
|
1120
|
+
for (const name of Object.keys(state2.events)) {
|
|
1121
|
+
if (events[name]) throw new Error(`Duplicate event "${name}"`);
|
|
1122
|
+
events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
|
|
1047
1123
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
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
|
-
}
|
|
1061
|
-
}
|
|
1124
|
+
}
|
|
1125
|
+
function mergeIntoExisting(state2, existing, states, actions, events) {
|
|
1126
|
+
for (const name of Object.keys(state2.actions)) {
|
|
1127
|
+
if (existing.actions[name] === state2.actions[name]) continue;
|
|
1128
|
+
if (actions[name]) throw new Error(`Duplicate action "${name}"`);
|
|
1062
1129
|
}
|
|
1063
|
-
const
|
|
1064
|
-
name
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
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
|
|
1130
|
+
for (const name of Object.keys(state2.events)) {
|
|
1131
|
+
if (existing.events[name] === state2.events[name]) continue;
|
|
1132
|
+
if (existing.events[name]) {
|
|
1133
|
+
throw new Error(
|
|
1134
|
+
`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.`
|
|
1135
|
+
);
|
|
1086
1136
|
}
|
|
1137
|
+
if (events[name]) throw new Error(`Duplicate event "${name}"`);
|
|
1138
|
+
}
|
|
1139
|
+
const mergedPatch = mergePatches(existing.patch, state2.patch, state2.name);
|
|
1140
|
+
const merged = {
|
|
1141
|
+
...existing,
|
|
1142
|
+
state: mergeSchemas(existing.state, state2.state, state2.name),
|
|
1143
|
+
init: mergeInits(existing.init, state2.init),
|
|
1144
|
+
events: { ...existing.events, ...state2.events },
|
|
1145
|
+
actions: { ...existing.actions, ...state2.actions },
|
|
1146
|
+
patch: mergedPatch,
|
|
1147
|
+
on: { ...existing.on, ...state2.on },
|
|
1148
|
+
given: { ...existing.given, ...state2.given },
|
|
1149
|
+
snap: state2.snap && existing.snap && state2.snap !== existing.snap ? (() => {
|
|
1150
|
+
throw new Error(
|
|
1151
|
+
`Duplicate snap strategy for state "${state2.name}"`
|
|
1152
|
+
);
|
|
1153
|
+
})() : state2.snap || existing.snap
|
|
1087
1154
|
};
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1155
|
+
states.set(state2.name, merged);
|
|
1156
|
+
for (const name of Object.keys(merged.actions)) {
|
|
1157
|
+
actions[name] = merged;
|
|
1158
|
+
}
|
|
1159
|
+
for (const name of Object.keys(state2.events)) {
|
|
1160
|
+
if (events[name]) continue;
|
|
1161
|
+
events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
function mergePatches(existing, incoming, stateName) {
|
|
1165
|
+
const merged = { ...existing };
|
|
1166
|
+
for (const name of Object.keys(incoming)) {
|
|
1167
|
+
const existingP = existing[name];
|
|
1168
|
+
const incomingP = incoming[name];
|
|
1169
|
+
if (!existingP) {
|
|
1170
|
+
merged[name] = incomingP;
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1173
|
+
const existingIsDefault = existingP._passthrough;
|
|
1174
|
+
const incomingIsDefault = incomingP._passthrough;
|
|
1175
|
+
if (!existingIsDefault && !incomingIsDefault && existingP !== incomingP) {
|
|
1176
|
+
throw new Error(
|
|
1177
|
+
`Duplicate custom patch for event "${name}" in state "${stateName}"`
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
if (existingIsDefault && !incomingIsDefault) {
|
|
1181
|
+
merged[name] = incomingP;
|
|
1103
1182
|
}
|
|
1104
|
-
throw error;
|
|
1105
1183
|
}
|
|
1106
|
-
|
|
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;
|
|
1184
|
+
return merged;
|
|
1133
1185
|
}
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
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;
|
|
1186
|
+
function mergeEventRegister(target, source) {
|
|
1187
|
+
for (const [eventName, sourceReg] of Object.entries(source)) {
|
|
1188
|
+
const targetReg = target[eventName];
|
|
1189
|
+
if (!targetReg) continue;
|
|
1190
|
+
for (const [name, reaction] of sourceReg.reactions) {
|
|
1191
|
+
targetReg.reactions.set(name, reaction);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
function mergeProjection(proj, events) {
|
|
1196
|
+
for (const eventName of Object.keys(proj.events)) {
|
|
1197
|
+
const projRegister = proj.events[eventName];
|
|
1198
|
+
const existing = events[eventName];
|
|
1199
|
+
if (!existing) {
|
|
1200
|
+
events[eventName] = {
|
|
1201
|
+
schema: projRegister.schema,
|
|
1202
|
+
reactions: new Map(projRegister.reactions)
|
|
1203
|
+
};
|
|
1204
|
+
} else {
|
|
1205
|
+
for (const [name, reaction] of projRegister.reactions) {
|
|
1206
|
+
let key = name;
|
|
1207
|
+
while (existing.reactions.has(key)) key = `${key}_p`;
|
|
1208
|
+
existing.reactions.set(key, reaction);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
var _this_ = ({ stream }) => ({
|
|
1214
|
+
source: stream,
|
|
1215
|
+
target: stream
|
|
1176
1216
|
});
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1217
|
+
|
|
1218
|
+
// src/internal/backoff.ts
|
|
1219
|
+
function computeBackoffDelay(retry, opts) {
|
|
1220
|
+
if (!opts || opts.baseMs <= 0) return 0;
|
|
1221
|
+
const r = Math.max(0, retry);
|
|
1222
|
+
let delay;
|
|
1223
|
+
switch (opts.strategy) {
|
|
1224
|
+
case "fixed":
|
|
1225
|
+
delay = opts.baseMs;
|
|
1226
|
+
break;
|
|
1227
|
+
case "linear":
|
|
1228
|
+
delay = opts.baseMs * (r + 1);
|
|
1229
|
+
break;
|
|
1230
|
+
case "exponential":
|
|
1231
|
+
delay = opts.baseMs * 2 ** r;
|
|
1232
|
+
if (opts.maxMs !== void 0) delay = Math.min(delay, opts.maxMs);
|
|
1233
|
+
break;
|
|
1194
1234
|
}
|
|
1235
|
+
if (opts.jitter) delay = delay * (0.5 + Math.random());
|
|
1236
|
+
return Math.max(0, Math.floor(delay));
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// src/internal/reactions.ts
|
|
1240
|
+
function finalize(lease, handled, at, error, options, logger, failed_at) {
|
|
1241
|
+
if (!error) return { lease, handled, acked_at: at };
|
|
1242
|
+
logger.error(error);
|
|
1243
|
+
const nonRetryable = error instanceof NonRetryableError;
|
|
1244
|
+
const block2 = options.blockOnError && (nonRetryable || lease.retry >= options.maxRetries);
|
|
1245
|
+
if (block2)
|
|
1246
|
+
logger.error(
|
|
1247
|
+
nonRetryable ? `Blocking ${lease.stream} on non-retryable error.` : `Blocking ${lease.stream} after ${lease.retry} retries.`
|
|
1248
|
+
);
|
|
1249
|
+
const nextAttemptAt = !block2 && options.backoff ? Date.now() + computeBackoffDelay(lease.retry, options.backoff) : void 0;
|
|
1195
1250
|
return {
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
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
|
-
})
|
|
1251
|
+
lease,
|
|
1252
|
+
handled,
|
|
1253
|
+
acked_at: at,
|
|
1254
|
+
error: error.message,
|
|
1255
|
+
block: block2,
|
|
1256
|
+
nextAttemptAt,
|
|
1257
|
+
failed_at
|
|
1248
1258
|
};
|
|
1249
1259
|
}
|
|
1250
|
-
function
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1260
|
+
function buildHandle(deps) {
|
|
1261
|
+
const { logger, boundDo, boundLoad, boundQuery, boundQueryArray } = deps;
|
|
1262
|
+
return async (lease, payloads) => {
|
|
1263
|
+
if (payloads.length === 0) return { lease, handled: 0, acked_at: lease.at };
|
|
1264
|
+
const stream = lease.stream;
|
|
1265
|
+
let at = payloads.at(0).event.id;
|
|
1266
|
+
let handled = 0;
|
|
1267
|
+
if (lease.retry > 0)
|
|
1268
|
+
logger.warn(`Retrying ${stream}@${at} (${lease.retry}).`);
|
|
1269
|
+
const scopedApp = {
|
|
1270
|
+
do: boundDo,
|
|
1271
|
+
load: boundLoad,
|
|
1272
|
+
query: boundQuery,
|
|
1273
|
+
query_array: boundQueryArray
|
|
1258
1274
|
};
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
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
|
-
})
|
|
1275
|
+
for (const payload of payloads) {
|
|
1276
|
+
const { event, handler } = payload;
|
|
1277
|
+
scopedApp.do = (action2, target, actionPayload, reactingTo, skipValidation) => boundDo(
|
|
1278
|
+
action2,
|
|
1279
|
+
target,
|
|
1280
|
+
actionPayload,
|
|
1281
|
+
reactingTo ?? event,
|
|
1282
|
+
skipValidation
|
|
1278
1283
|
);
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
blocked.map(({ stream, at, retry, error }) => [
|
|
1293
|
-
stream,
|
|
1294
|
-
{ at, retry, error }
|
|
1295
|
-
])
|
|
1284
|
+
try {
|
|
1285
|
+
await handler(event, stream, scopedApp);
|
|
1286
|
+
at = event.id;
|
|
1287
|
+
handled++;
|
|
1288
|
+
} catch (error) {
|
|
1289
|
+
return finalize(
|
|
1290
|
+
lease,
|
|
1291
|
+
handled,
|
|
1292
|
+
at,
|
|
1293
|
+
error,
|
|
1294
|
+
payload.options,
|
|
1295
|
+
logger,
|
|
1296
|
+
event.id
|
|
1296
1297
|
);
|
|
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
1298
|
}
|
|
1305
|
-
}
|
|
1299
|
+
}
|
|
1300
|
+
return finalize(lease, handled, at, void 0, payloads[0].options, logger);
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
function buildHandleBatch(logger) {
|
|
1304
|
+
return async (lease, payloads, batchHandler) => {
|
|
1305
|
+
const stream = lease.stream;
|
|
1306
|
+
const events = payloads.map((p) => p.event);
|
|
1307
|
+
const options = payloads[0].options;
|
|
1308
|
+
if (lease.retry > 0)
|
|
1309
|
+
logger.warn(`Retrying batch ${stream}@${events[0].id} (${lease.retry}).`);
|
|
1310
|
+
try {
|
|
1311
|
+
await batchHandler(events, stream);
|
|
1312
|
+
return finalize(
|
|
1313
|
+
lease,
|
|
1314
|
+
events.length,
|
|
1315
|
+
events.at(-1).id,
|
|
1316
|
+
void 0,
|
|
1317
|
+
options,
|
|
1318
|
+
logger
|
|
1319
|
+
);
|
|
1320
|
+
} catch (error) {
|
|
1321
|
+
return finalize(lease, 0, lease.at, error, options, logger);
|
|
1322
|
+
}
|
|
1306
1323
|
};
|
|
1307
1324
|
}
|
|
1308
1325
|
|
|
1326
|
+
// src/internal/settle.ts
|
|
1327
|
+
var SettleLoop = class {
|
|
1328
|
+
constructor(deps, defaultDebounceMs) {
|
|
1329
|
+
this.deps = deps;
|
|
1330
|
+
this.defaultDebounceMs = defaultDebounceMs;
|
|
1331
|
+
}
|
|
1332
|
+
_timer = void 0;
|
|
1333
|
+
_running = false;
|
|
1334
|
+
/**
|
|
1335
|
+
* Schedule a settle pass. Multiple calls inside the debounce window
|
|
1336
|
+
* coalesce into one cycle. The cycle runs correlate→drain in a loop
|
|
1337
|
+
* until no progress is made (no new subscriptions, no acks, no blocks)
|
|
1338
|
+
* or `maxPasses` is reached, then emits the `"settled"` lifecycle event
|
|
1339
|
+
* via {@link SettleDeps.onSettled}.
|
|
1340
|
+
*/
|
|
1341
|
+
schedule(options = {}) {
|
|
1342
|
+
const {
|
|
1343
|
+
debounceMs = this.defaultDebounceMs,
|
|
1344
|
+
correlate: correlateQuery = { after: -1, limit: 100 },
|
|
1345
|
+
maxPasses = Infinity,
|
|
1346
|
+
...drainOptions
|
|
1347
|
+
} = options;
|
|
1348
|
+
if (this._timer) clearTimeout(this._timer);
|
|
1349
|
+
this._timer = setTimeout(() => {
|
|
1350
|
+
this._timer = void 0;
|
|
1351
|
+
if (this._running) return;
|
|
1352
|
+
this._running = true;
|
|
1353
|
+
(async () => {
|
|
1354
|
+
await this.deps.init();
|
|
1355
|
+
let lastDrain;
|
|
1356
|
+
for (let i = 0; i < maxPasses; i++) {
|
|
1357
|
+
const { subscribed } = await this.deps.correlate({
|
|
1358
|
+
...correlateQuery,
|
|
1359
|
+
after: this.deps.checkpoint()
|
|
1360
|
+
});
|
|
1361
|
+
lastDrain = await this.deps.drain(drainOptions);
|
|
1362
|
+
const made_progress = subscribed > 0 || lastDrain.acked.length > 0 || lastDrain.blocked.length > 0;
|
|
1363
|
+
if (!made_progress) break;
|
|
1364
|
+
}
|
|
1365
|
+
if (lastDrain) this.deps.onSettled(lastDrain);
|
|
1366
|
+
})().catch((err) => this.deps.logger.error(err)).finally(() => {
|
|
1367
|
+
this._running = false;
|
|
1368
|
+
});
|
|
1369
|
+
}, debounceMs);
|
|
1370
|
+
}
|
|
1371
|
+
/** Cancel any pending or active settle cycle. Idempotent. */
|
|
1372
|
+
stop() {
|
|
1373
|
+
if (this._timer) {
|
|
1374
|
+
clearTimeout(this._timer);
|
|
1375
|
+
this._timer = void 0;
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
};
|
|
1379
|
+
|
|
1309
1380
|
// src/act.ts
|
|
1310
1381
|
var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
|
|
1311
1382
|
var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
|
|
@@ -1320,11 +1391,26 @@ var Act = class {
|
|
|
1320
1391
|
* @param _states Merged map of state name → state definition
|
|
1321
1392
|
* @param batchHandlers Static-target projection batch handlers (target → handler)
|
|
1322
1393
|
* @param options Tuning knobs — see {@link ActOptions}
|
|
1394
|
+
* @param lanes Declared drain lanes (ACT-1103). The builder collects
|
|
1395
|
+
* these from `.withLane(...)` calls. Slice 1 records them on the
|
|
1396
|
+
* instance; later slices fan out one `DrainController` per lane.
|
|
1323
1397
|
*/
|
|
1324
|
-
constructor(registry, _states = /* @__PURE__ */ new Map(), batchHandlers = /* @__PURE__ */ new Map(), options = {}) {
|
|
1398
|
+
constructor(registry, _states = /* @__PURE__ */ new Map(), batchHandlers = /* @__PURE__ */ new Map(), options = {}, lanes = []) {
|
|
1325
1399
|
this.registry = registry;
|
|
1326
1400
|
this._states = _states;
|
|
1327
1401
|
this._batch_handlers = batchHandlers;
|
|
1402
|
+
this._lanes = lanes;
|
|
1403
|
+
if (options.onlyLanes && options.onlyLanes.length > 0) {
|
|
1404
|
+
const declared = /* @__PURE__ */ new Set([
|
|
1405
|
+
"default",
|
|
1406
|
+
...lanes.map((l) => l.name)
|
|
1407
|
+
]);
|
|
1408
|
+
const unknown = options.onlyLanes.filter((l) => !declared.has(l));
|
|
1409
|
+
if (unknown.length > 0)
|
|
1410
|
+
throw new Error(
|
|
1411
|
+
`ActOptions.onlyLanes references undeclared lane(s): ${unknown.map((l) => `"${l}"`).join(", ")}`
|
|
1412
|
+
);
|
|
1413
|
+
}
|
|
1328
1414
|
this._scoped = options.scoped ? (fn) => scoped.run(options.scoped, fn) : (fn) => fn();
|
|
1329
1415
|
this._correlator = options.correlator ?? defaultCorrelator;
|
|
1330
1416
|
this._es = buildEs(this._logger, this._correlator);
|
|
@@ -1337,19 +1423,44 @@ var Act = class {
|
|
|
1337
1423
|
boundQueryArray: this._bound_query_array
|
|
1338
1424
|
});
|
|
1339
1425
|
this._handle_batch = buildHandleBatch(this._logger);
|
|
1340
|
-
const {
|
|
1426
|
+
const {
|
|
1427
|
+
staticTargets,
|
|
1428
|
+
hasDynamicResolvers,
|
|
1429
|
+
reactiveEvents,
|
|
1430
|
+
eventToState,
|
|
1431
|
+
eventToLanes
|
|
1432
|
+
} = classifyRegistry(this.registry, this._states);
|
|
1341
1433
|
this._reactive_events = reactiveEvents;
|
|
1342
1434
|
this._event_to_state = eventToState;
|
|
1343
|
-
this.
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1435
|
+
this._event_to_lanes = eventToLanes;
|
|
1436
|
+
const allLanes = ["default", ...lanes.map((l) => l.name)];
|
|
1437
|
+
const onlySet = options.onlyLanes && options.onlyLanes.length > 0 ? new Set(options.onlyLanes) : void 0;
|
|
1438
|
+
const activeLanes = onlySet ? allLanes.filter((n) => onlySet.has(n)) : allLanes;
|
|
1439
|
+
const singleDefaultLane = activeLanes.length === 1 && activeLanes[0] === "default";
|
|
1440
|
+
this._drain_controllers = /* @__PURE__ */ new Map();
|
|
1441
|
+
for (const name of activeLanes) {
|
|
1442
|
+
const cfg = lanes.find((l) => l.name === name);
|
|
1443
|
+
const controller = new DrainController({
|
|
1444
|
+
logger: this._logger,
|
|
1445
|
+
ops: this._cd,
|
|
1446
|
+
registry: this.registry,
|
|
1447
|
+
batchHandlers: this._batch_handlers,
|
|
1448
|
+
handle: this._handle,
|
|
1449
|
+
handleBatch: this._handle_batch,
|
|
1450
|
+
onAcked: (acked) => this.emit("acked", acked),
|
|
1451
|
+
onBlocked: (blocked) => this.emit("blocked", blocked),
|
|
1452
|
+
// Pass lane only when a true per-lane controller is active.
|
|
1453
|
+
// The all-lanes (single default) case keeps lane=undefined so
|
|
1454
|
+
// adapter SQL collapses to the pre-1103 shape.
|
|
1455
|
+
lane: singleDefaultLane ? void 0 : name,
|
|
1456
|
+
defaults: cfg && {
|
|
1457
|
+
streamLimit: cfg.streamLimit,
|
|
1458
|
+
leaseMillis: cfg.leaseMillis
|
|
1459
|
+
}
|
|
1460
|
+
});
|
|
1461
|
+
if (cfg?.cycleMs !== void 0) controller.start(cfg.cycleMs);
|
|
1462
|
+
this._drain_controllers.set(name, controller);
|
|
1463
|
+
}
|
|
1353
1464
|
this._correlate = new CorrelateCycle(
|
|
1354
1465
|
this.registry,
|
|
1355
1466
|
staticTargets,
|
|
@@ -1358,7 +1469,7 @@ var Act = class {
|
|
|
1358
1469
|
options.maxSubscribedStreams ?? DEFAULT_MAX_SUBSCRIBED_STREAMS,
|
|
1359
1470
|
// Cold start: assume drain is needed (historical events may need processing)
|
|
1360
1471
|
() => {
|
|
1361
|
-
if (this._reactive_events.size > 0) this.
|
|
1472
|
+
if (this._reactive_events.size > 0) this._armAll();
|
|
1362
1473
|
}
|
|
1363
1474
|
);
|
|
1364
1475
|
this._settle = new SettleLoop(
|
|
@@ -1378,8 +1489,8 @@ var Act = class {
|
|
|
1378
1489
|
_emitter = new EventEmitter();
|
|
1379
1490
|
/** Event names with at least one registered reaction (computed at build time) */
|
|
1380
1491
|
_reactive_events;
|
|
1381
|
-
/**
|
|
1382
|
-
|
|
1492
|
+
/** One DrainController per active lane, keyed by lane name. */
|
|
1493
|
+
_drain_controllers;
|
|
1383
1494
|
/** Correlation state machine: lazy init, dynamic-resolver scan, periodic worker. */
|
|
1384
1495
|
_correlate;
|
|
1385
1496
|
/** Debounced correlate→drain catch-up loop. */
|
|
@@ -1433,6 +1544,14 @@ var Act = class {
|
|
|
1433
1544
|
* set when seeding a `restart` snapshot in multi-state apps.
|
|
1434
1545
|
*/
|
|
1435
1546
|
_event_to_state;
|
|
1547
|
+
/**
|
|
1548
|
+
* Event-name → lane fan-in for selective arming (ACT-1103). Built by
|
|
1549
|
+
* `classifyRegistry` once per build. `"all"` means at least one of
|
|
1550
|
+
* the event's reactions is a dynamic resolver (lane opaque until
|
|
1551
|
+
* runtime); a `Set<string>` lists the static lanes only that event's
|
|
1552
|
+
* reactions target.
|
|
1553
|
+
*/
|
|
1554
|
+
_event_to_lanes;
|
|
1436
1555
|
/** Logger resolved at construction time (after user port configuration) */
|
|
1437
1556
|
_logger = log();
|
|
1438
1557
|
/** Wraps a public-method body so internal `store()`/`cache()` resolve to the
|
|
@@ -1456,6 +1575,12 @@ var Act = class {
|
|
|
1456
1575
|
/** Reaction dispatchers built once and handed to runDrainCycle each cycle. */
|
|
1457
1576
|
_handle;
|
|
1458
1577
|
_handle_batch;
|
|
1578
|
+
/** Declared drain lanes (ACT-1103). */
|
|
1579
|
+
_lanes;
|
|
1580
|
+
/** Drain lanes declared via `.withLane(...)`. Implicit default not included. */
|
|
1581
|
+
get lanes() {
|
|
1582
|
+
return this._lanes;
|
|
1583
|
+
}
|
|
1459
1584
|
/** True after the first `shutdown()` call. Guards idempotency. */
|
|
1460
1585
|
_shutdown_promise;
|
|
1461
1586
|
/**
|
|
@@ -1474,6 +1599,7 @@ var Act = class {
|
|
|
1474
1599
|
this._emitter.removeAllListeners();
|
|
1475
1600
|
this.stop_correlations();
|
|
1476
1601
|
this.stop_settling();
|
|
1602
|
+
for (const c of this._drain_controllers.values()) c.stop();
|
|
1477
1603
|
const disposer = await this._notify_disposer;
|
|
1478
1604
|
if (disposer) await disposer();
|
|
1479
1605
|
})();
|
|
@@ -1493,13 +1619,10 @@ var Act = class {
|
|
|
1493
1619
|
return await s.notify((notification) => {
|
|
1494
1620
|
try {
|
|
1495
1621
|
this.emit("notified", notification);
|
|
1496
|
-
const
|
|
1497
|
-
(e) =>
|
|
1622
|
+
const armed = this._armForEventNames(
|
|
1623
|
+
notification.events.map((e) => e.name)
|
|
1498
1624
|
);
|
|
1499
|
-
if (
|
|
1500
|
-
this._drain.arm();
|
|
1501
|
-
this._settle.schedule({ debounceMs: 0 });
|
|
1502
|
-
}
|
|
1625
|
+
if (armed) this._settle.schedule({ debounceMs: 0 });
|
|
1503
1626
|
} catch (err) {
|
|
1504
1627
|
this._logger.error(err, "notified handler threw");
|
|
1505
1628
|
}
|
|
@@ -1600,14 +1723,10 @@ var Act = class {
|
|
|
1600
1723
|
reactingTo,
|
|
1601
1724
|
skipValidation
|
|
1602
1725
|
);
|
|
1603
|
-
if (this._reactive_events.size > 0)
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
break;
|
|
1608
|
-
}
|
|
1609
|
-
}
|
|
1610
|
-
}
|
|
1726
|
+
if (this._reactive_events.size > 0)
|
|
1727
|
+
this._armForEventNames(
|
|
1728
|
+
snapshots.map((s) => s.event.name)
|
|
1729
|
+
);
|
|
1611
1730
|
this.emit("committed", snapshots);
|
|
1612
1731
|
return snapshots;
|
|
1613
1732
|
});
|
|
@@ -1754,7 +1873,59 @@ var Act = class {
|
|
|
1754
1873
|
* @see {@link start_correlations} for automatic correlation
|
|
1755
1874
|
*/
|
|
1756
1875
|
async drain(options = {}) {
|
|
1757
|
-
return this._scoped(() => this.
|
|
1876
|
+
return this._scoped(() => this._drainAll(options));
|
|
1877
|
+
}
|
|
1878
|
+
/** Arm every active lane controller (ACT-1103). */
|
|
1879
|
+
_armAll() {
|
|
1880
|
+
for (const c of this._drain_controllers.values()) c.arm();
|
|
1881
|
+
}
|
|
1882
|
+
/**
|
|
1883
|
+
* Arm only the lane controllers whose reactions match the supplied
|
|
1884
|
+
* event names (ACT-1103 selective arming). Events with any dynamic
|
|
1885
|
+
* resolver fall back to `_armAll()` via the `"all"` sentinel — the
|
|
1886
|
+
* resolver's lane isn't known until correlate runs the function.
|
|
1887
|
+
* Events with no reactions are skipped; `_event_to_lanes` doesn't
|
|
1888
|
+
* carry them. Returns true when any controller was armed (used by
|
|
1889
|
+
* the notify handler to decide whether to schedule a settle).
|
|
1890
|
+
*/
|
|
1891
|
+
_armForEventNames(names) {
|
|
1892
|
+
const to_arm = /* @__PURE__ */ new Set();
|
|
1893
|
+
for (const name of names) {
|
|
1894
|
+
const set = this._event_to_lanes.get(name);
|
|
1895
|
+
if (set === void 0) continue;
|
|
1896
|
+
if (set === ALL_LANES) {
|
|
1897
|
+
this._armAll();
|
|
1898
|
+
return true;
|
|
1899
|
+
}
|
|
1900
|
+
for (const lane of set) to_arm.add(lane);
|
|
1901
|
+
}
|
|
1902
|
+
if (to_arm.size === 0) return false;
|
|
1903
|
+
for (const lane of to_arm) this._drain_controllers.get(lane)?.arm();
|
|
1904
|
+
return true;
|
|
1905
|
+
}
|
|
1906
|
+
/** Drain every active lane controller in parallel and aggregate.
|
|
1907
|
+
*
|
|
1908
|
+
* Parallel — not sequential — so a slow lane's in-flight handler does
|
|
1909
|
+
* not block a fast lane's claim/dispatch/ack cycle. Each controller's
|
|
1910
|
+
* `claim()` is independent (filtered by lane); the store's
|
|
1911
|
+
* `SKIP LOCKED` keeps cross-controller races safe. Lifecycle events
|
|
1912
|
+
* (`acked`, `blocked`) may interleave by lane — listeners filter via
|
|
1913
|
+
* `lease.lane`. */
|
|
1914
|
+
async _drainAll(options) {
|
|
1915
|
+
const results = await Promise.all(
|
|
1916
|
+
[...this._drain_controllers.values()].map((c) => c.drain(options))
|
|
1917
|
+
);
|
|
1918
|
+
const fetched = [];
|
|
1919
|
+
const leased = [];
|
|
1920
|
+
const acked = [];
|
|
1921
|
+
const blocked = [];
|
|
1922
|
+
for (const r of results) {
|
|
1923
|
+
fetched.push(...r.fetched);
|
|
1924
|
+
leased.push(...r.leased);
|
|
1925
|
+
acked.push(...r.acked);
|
|
1926
|
+
blocked.push(...r.blocked);
|
|
1927
|
+
}
|
|
1928
|
+
return { fetched, leased, acked, blocked };
|
|
1758
1929
|
}
|
|
1759
1930
|
/**
|
|
1760
1931
|
* Discovers and registers new streams dynamically based on reaction resolvers.
|
|
@@ -1925,7 +2096,7 @@ var Act = class {
|
|
|
1925
2096
|
async reset(input) {
|
|
1926
2097
|
return this._scoped(async () => {
|
|
1927
2098
|
const count = await store().reset(input);
|
|
1928
|
-
if (count > 0 && this._reactive_events.size > 0) this.
|
|
2099
|
+
if (count > 0 && this._reactive_events.size > 0) this._armAll();
|
|
1929
2100
|
return count;
|
|
1930
2101
|
});
|
|
1931
2102
|
}
|
|
@@ -1959,7 +2130,7 @@ var Act = class {
|
|
|
1959
2130
|
async unblock(input) {
|
|
1960
2131
|
return this._scoped(async () => {
|
|
1961
2132
|
const count = await store().unblock(input);
|
|
1962
|
-
if (count > 0 && this._reactive_events.size > 0) this.
|
|
2133
|
+
if (count > 0 && this._reactive_events.size > 0) this._armAll();
|
|
1963
2134
|
return count;
|
|
1964
2135
|
});
|
|
1965
2136
|
}
|
|
@@ -2132,6 +2303,22 @@ function registerBatchHandler(proj, batchHandlers) {
|
|
|
2132
2303
|
}
|
|
2133
2304
|
batchHandlers.set(proj.target, proj.batchHandler);
|
|
2134
2305
|
}
|
|
2306
|
+
function validateLaneReferences(registry, lanes) {
|
|
2307
|
+
const declared = /* @__PURE__ */ new Set([DEFAULT_LANE, ...lanes.map((l) => l.name)]);
|
|
2308
|
+
for (const [eventName, def] of Object.entries(registry.events)) {
|
|
2309
|
+
const entry = def;
|
|
2310
|
+
for (const [handlerName, reaction] of entry.reactions) {
|
|
2311
|
+
const resolver = reaction.resolver;
|
|
2312
|
+
if (typeof resolver === "function") continue;
|
|
2313
|
+
const lane = resolver.lane;
|
|
2314
|
+
if (lane && !declared.has(lane)) {
|
|
2315
|
+
throw new Error(
|
|
2316
|
+
`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.`
|
|
2317
|
+
);
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2135
2322
|
function act() {
|
|
2136
2323
|
const states = /* @__PURE__ */ new Map();
|
|
2137
2324
|
const registry = {
|
|
@@ -2140,6 +2327,7 @@ function act() {
|
|
|
2140
2327
|
};
|
|
2141
2328
|
const pendingProjections = [];
|
|
2142
2329
|
const batchHandlers = /* @__PURE__ */ new Map();
|
|
2330
|
+
const lanes = [];
|
|
2143
2331
|
let _built = false;
|
|
2144
2332
|
const finalizeDeprecations = () => {
|
|
2145
2333
|
const deprecationSummary = [];
|
|
@@ -2186,6 +2374,18 @@ function act() {
|
|
|
2186
2374
|
}
|
|
2187
2375
|
mergeEventRegister(registry.events, input.events);
|
|
2188
2376
|
pendingProjections.push(...input.projections);
|
|
2377
|
+
for (const sliceLane of input.lanes) {
|
|
2378
|
+
const existing = lanes.find((l) => l.name === sliceLane.name);
|
|
2379
|
+
if (!existing) {
|
|
2380
|
+
lanes.push(sliceLane);
|
|
2381
|
+
continue;
|
|
2382
|
+
}
|
|
2383
|
+
if (existing.leaseMillis !== sliceLane.leaseMillis || existing.streamLimit !== sliceLane.streamLimit || existing.cycleMs !== sliceLane.cycleMs) {
|
|
2384
|
+
throw new Error(
|
|
2385
|
+
`Lane "${sliceLane.name}" was already declared with a different config`
|
|
2386
|
+
);
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2189
2389
|
return builder;
|
|
2190
2390
|
},
|
|
2191
2391
|
withProjection: (proj) => {
|
|
@@ -2194,6 +2394,14 @@ function act() {
|
|
|
2194
2394
|
return builder;
|
|
2195
2395
|
},
|
|
2196
2396
|
withActor: () => builder,
|
|
2397
|
+
withLane: (config2) => {
|
|
2398
|
+
if (config2.name === DEFAULT_LANE)
|
|
2399
|
+
throw new Error(`Lane "${DEFAULT_LANE}" is reserved`);
|
|
2400
|
+
if (lanes.some((l) => l.name === config2.name))
|
|
2401
|
+
throw new Error(`Lane "${config2.name}" was already declared`);
|
|
2402
|
+
lanes.push(config2);
|
|
2403
|
+
return builder;
|
|
2404
|
+
},
|
|
2197
2405
|
on: (event) => ({
|
|
2198
2406
|
do: (handler, options) => {
|
|
2199
2407
|
const reaction = {
|
|
@@ -2225,13 +2433,15 @@ function act() {
|
|
|
2225
2433
|
registerBatchHandler(proj, batchHandlers);
|
|
2226
2434
|
}
|
|
2227
2435
|
finalizeDeprecations();
|
|
2436
|
+
validateLaneReferences(registry, lanes);
|
|
2228
2437
|
_built = true;
|
|
2229
2438
|
}
|
|
2230
2439
|
return new Act(
|
|
2231
2440
|
registry,
|
|
2232
2441
|
states,
|
|
2233
2442
|
batchHandlers,
|
|
2234
|
-
options
|
|
2443
|
+
options,
|
|
2444
|
+
lanes
|
|
2235
2445
|
);
|
|
2236
2446
|
},
|
|
2237
2447
|
events: registry.events
|
|
@@ -2312,6 +2522,7 @@ function slice() {
|
|
|
2312
2522
|
const actions = {};
|
|
2313
2523
|
const events = {};
|
|
2314
2524
|
const projections = [];
|
|
2525
|
+
const lanes = [];
|
|
2315
2526
|
const builder = {
|
|
2316
2527
|
withState: (state2) => {
|
|
2317
2528
|
registerState(state2, states, actions, events);
|
|
@@ -2321,6 +2532,14 @@ function slice() {
|
|
|
2321
2532
|
projections.push(proj);
|
|
2322
2533
|
return builder;
|
|
2323
2534
|
},
|
|
2535
|
+
withLane: (config2) => {
|
|
2536
|
+
if (config2.name === DEFAULT_LANE)
|
|
2537
|
+
throw new Error(`Lane "${DEFAULT_LANE}" is reserved`);
|
|
2538
|
+
if (lanes.some((l) => l.name === config2.name))
|
|
2539
|
+
throw new Error(`Lane "${config2.name}" was already declared`);
|
|
2540
|
+
lanes.push(config2);
|
|
2541
|
+
return builder;
|
|
2542
|
+
},
|
|
2324
2543
|
on: (event) => ({
|
|
2325
2544
|
do: (handler, options) => {
|
|
2326
2545
|
const reaction = {
|
|
@@ -2349,7 +2568,8 @@ function slice() {
|
|
|
2349
2568
|
_tag: "Slice",
|
|
2350
2569
|
states,
|
|
2351
2570
|
events,
|
|
2352
|
-
projections
|
|
2571
|
+
projections,
|
|
2572
|
+
lanes
|
|
2353
2573
|
}),
|
|
2354
2574
|
events
|
|
2355
2575
|
};
|
|
@@ -2442,6 +2662,7 @@ export {
|
|
|
2442
2662
|
CommittedMetaSchema,
|
|
2443
2663
|
ConcurrencyError,
|
|
2444
2664
|
ConsoleLogger,
|
|
2665
|
+
DEFAULT_LANE,
|
|
2445
2666
|
DEFAULT_MAX_SUBSCRIBED_STREAMS,
|
|
2446
2667
|
DEFAULT_SETTLE_DEBOUNCE_MS,
|
|
2447
2668
|
Environments,
|