@rotorsoft/act 0.43.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 +22 -2
- 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 +203 -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-QAB4SDOS.js → chunk-PGTC7VOC.js} +117 -11
- package/dist/chunk-PGTC7VOC.js.map +1 -0
- package/dist/index.cjs +1217 -897
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1108 -893
- package/dist/index.js.map +1 -1
- package/dist/test/index.cjs +116 -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-QAB4SDOS.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
|
|
|
@@ -133,24 +151,18 @@ async function runCloseCycle(targets, deps) {
|
|
|
133
151
|
return { truncated, skipped };
|
|
134
152
|
}
|
|
135
153
|
async function scanStreamHeads(streams) {
|
|
154
|
+
const stats = await store().query_stats(streams, {
|
|
155
|
+
exclude: [SNAP_EVENT]
|
|
156
|
+
});
|
|
136
157
|
const out = /* @__PURE__ */ new Map();
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
maxId = e.id;
|
|
146
|
-
version = e.version;
|
|
147
|
-
lastEventName = e.name;
|
|
148
|
-
},
|
|
149
|
-
{ stream: s, stream_exact: true, backward: true, limit: 1 }
|
|
150
|
-
);
|
|
151
|
-
if (maxId >= 0) out.set(s, { maxId, version, lastEventName });
|
|
152
|
-
})
|
|
153
|
-
);
|
|
158
|
+
for (const [stream, { head }] of stats) {
|
|
159
|
+
if (head.name === TOMBSTONE_EVENT) continue;
|
|
160
|
+
out.set(stream, {
|
|
161
|
+
maxId: head.id,
|
|
162
|
+
version: head.version,
|
|
163
|
+
lastEventName: head.name
|
|
164
|
+
});
|
|
165
|
+
}
|
|
154
166
|
return out;
|
|
155
167
|
}
|
|
156
168
|
async function partitionBySafety(streamInfo, reactiveEventsSize, skipped) {
|
|
@@ -308,6 +320,7 @@ var CorrelateCycle = class {
|
|
|
308
320
|
const entry = correlated.get(resolved.target) || {
|
|
309
321
|
source: resolved.source,
|
|
310
322
|
priority: incomingPriority,
|
|
323
|
+
lane: resolved.lane,
|
|
311
324
|
payloads: []
|
|
312
325
|
};
|
|
313
326
|
if (incomingPriority > entry.priority)
|
|
@@ -326,10 +339,11 @@ var CorrelateCycle = class {
|
|
|
326
339
|
);
|
|
327
340
|
if (correlated.size) {
|
|
328
341
|
const streams = [...correlated.entries()].map(
|
|
329
|
-
([stream, { source, priority }]) => ({
|
|
342
|
+
([stream, { source, priority, lane }]) => ({
|
|
330
343
|
stream,
|
|
331
344
|
source,
|
|
332
|
-
priority
|
|
345
|
+
priority,
|
|
346
|
+
lane
|
|
333
347
|
})
|
|
334
348
|
);
|
|
335
349
|
const { subscribed } = await this.cd.subscribe(streams);
|
|
@@ -414,904 +428,955 @@ function computeLagLeadRatio(handled, lagging, leading) {
|
|
|
414
428
|
return Math.max(RATIO_MIN, Math.min(RATIO_MAX, lagging_avg / total));
|
|
415
429
|
}
|
|
416
430
|
|
|
417
|
-
// src/internal/drain
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
const fetched = await ops.fetch(active, eventLimit);
|
|
432
|
-
const fetchMap = /* @__PURE__ */ new Map();
|
|
433
|
-
const fetch_window_at = fetched.reduce(
|
|
434
|
-
(max, { at, events }) => Math.max(max, events.at(-1)?.id || at),
|
|
435
|
-
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
|
+
})
|
|
436
444
|
);
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
}
|
|
447
|
-
|
|
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);
|
|
448
467
|
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
+
);
|
|
457
510
|
}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
+
}
|
|
466
526
|
);
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
};
|
|
475
|
-
var DrainController = class {
|
|
476
|
-
constructor(deps) {
|
|
477
|
-
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
|
+
});
|
|
478
535
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
+
});
|
|
498
558
|
}
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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];
|
|
502
563
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
if (this._backoffTimer) clearTimeout(this._backoffTimer);
|
|
516
|
-
let earliest = Number.POSITIVE_INFINITY;
|
|
517
|
-
for (const t of this._backoff.values()) if (t < earliest) earliest = t;
|
|
518
|
-
const delay = Math.max(0, earliest - Date.now());
|
|
519
|
-
this._backoffTimer = setTimeout(() => {
|
|
520
|
-
this._backoffTimer = void 0;
|
|
521
|
-
const now = Date.now();
|
|
522
|
-
for (const [stream, at] of this._backoff) {
|
|
523
|
-
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
|
+
);
|
|
524
576
|
}
|
|
525
|
-
|
|
526
|
-
}, delay);
|
|
527
|
-
this._backoffTimer.unref();
|
|
577
|
+
}
|
|
528
578
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
);
|
|
553
|
-
if (!cycle) {
|
|
554
|
-
this._armed = false;
|
|
555
|
-
return EMPTY_DRAIN;
|
|
556
|
-
}
|
|
557
|
-
const { leased, fetched, handled, acked, blocked } = cycle;
|
|
558
|
-
this._ratio = computeLagLeadRatio(handled, lagging, leading);
|
|
559
|
-
for (const lease of acked) this._backoff.delete(lease.stream);
|
|
560
|
-
for (const lease of blocked) this._backoff.delete(lease.stream);
|
|
561
|
-
for (const h of handled) {
|
|
562
|
-
if (h.nextAttemptAt !== void 0 && !h.block) {
|
|
563
|
-
this._backoff.set(h.lease.stream, h.nextAttemptAt);
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
if (this._backoff.size > 0) this.scheduleBackoffWake();
|
|
567
|
-
if (acked.length) this.deps.onAcked(acked);
|
|
568
|
-
if (blocked.length) this.deps.onBlocked(blocked);
|
|
569
|
-
const hasErrors = handled.some(({ error }) => error);
|
|
570
|
-
if (!acked.length && !blocked.length && !hasErrors) this._armed = false;
|
|
571
|
-
return { fetched, leased, acked, blocked };
|
|
572
|
-
} catch (error) {
|
|
573
|
-
this.deps.logger.error(error);
|
|
574
|
-
return EMPTY_DRAIN;
|
|
575
|
-
} finally {
|
|
576
|
-
this._locked = false;
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
};
|
|
580
|
-
|
|
581
|
-
// src/internal/event-versions.ts
|
|
582
|
-
var VERSION_SUFFIX = /^(.+?)_v(\d+)$/;
|
|
583
|
-
function parse(name) {
|
|
584
|
-
const m = name.match(VERSION_SUFFIX);
|
|
585
|
-
if (m) {
|
|
586
|
-
const v = Number.parseInt(m[2], 10);
|
|
587
|
-
if (v >= 2) return { base: m[1], version: v };
|
|
588
|
-
}
|
|
589
|
-
return { base: name, version: 1 };
|
|
590
|
-
}
|
|
591
|
-
function deprecatedEventNames(names) {
|
|
592
|
-
const groups = /* @__PURE__ */ new Map();
|
|
593
|
-
for (const name of names) {
|
|
594
|
-
const { base, version } = parse(name);
|
|
595
|
-
const list = groups.get(base);
|
|
596
|
-
if (list) list.push({ version, name });
|
|
597
|
-
else groups.set(base, [{ version, name }]);
|
|
598
|
-
}
|
|
599
|
-
const deprecated = /* @__PURE__ */ new Set();
|
|
600
|
-
for (const list of groups.values()) {
|
|
601
|
-
if (list.length < 2) continue;
|
|
602
|
-
list.sort((a, b) => b.version - a.version);
|
|
603
|
-
for (let i = 1; i < list.length; i++) deprecated.add(list[i].name);
|
|
604
|
-
}
|
|
605
|
-
return deprecated;
|
|
606
|
-
}
|
|
607
|
-
function currentVersionOf(deprecatedName, allNames) {
|
|
608
|
-
const target = parse(deprecatedName);
|
|
609
|
-
let highest;
|
|
610
|
-
for (const name of allNames) {
|
|
611
|
-
const { base, version } = parse(name);
|
|
612
|
-
if (base !== target.base) continue;
|
|
613
|
-
if (!highest || version > highest.version) highest = { version, name };
|
|
614
|
-
}
|
|
615
|
-
return highest && highest.version > target.version ? highest.name : void 0;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
// src/internal/merge.ts
|
|
619
|
-
import { ZodObject } from "zod";
|
|
620
|
-
function baseTypeName(zodType) {
|
|
621
|
-
let t = zodType;
|
|
622
|
-
while (typeof t.unwrap === "function") {
|
|
623
|
-
t = t.unwrap();
|
|
624
|
-
}
|
|
625
|
-
return t.constructor.name;
|
|
626
|
-
}
|
|
627
|
-
function mergeSchemas(existing, incoming, stateName) {
|
|
628
|
-
if (existing instanceof ZodObject && incoming instanceof ZodObject) {
|
|
629
|
-
const existingShape = existing.shape;
|
|
630
|
-
const incomingShape = incoming.shape;
|
|
631
|
-
for (const key of Object.keys(incomingShape)) {
|
|
632
|
-
if (key in existingShape) {
|
|
633
|
-
const existingBase = baseTypeName(existingShape[key]);
|
|
634
|
-
const incomingBase = baseTypeName(incomingShape[key]);
|
|
635
|
-
if (existingBase !== incomingBase) {
|
|
636
|
-
throw new Error(
|
|
637
|
-
`Schema conflict in "${stateName}": key "${key}" has type "${existingBase}" but incoming partial declares "${incomingBase}"`
|
|
638
|
-
);
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
return existing.extend(incomingShape);
|
|
643
|
-
}
|
|
644
|
-
return existing;
|
|
645
|
-
}
|
|
646
|
-
function mergeInits(existing, incoming) {
|
|
647
|
-
return () => ({ ...existing(), ...incoming() });
|
|
648
|
-
}
|
|
649
|
-
function registerState(state2, states, actions, events) {
|
|
650
|
-
const existing = states.get(state2.name);
|
|
651
|
-
if (existing) {
|
|
652
|
-
mergeIntoExisting(state2, existing, states, actions, events);
|
|
653
|
-
} else {
|
|
654
|
-
registerNewState(state2, states, actions, events);
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
function registerNewState(state2, states, actions, events) {
|
|
658
|
-
states.set(state2.name, state2);
|
|
659
|
-
for (const name of Object.keys(state2.actions)) {
|
|
660
|
-
if (actions[name]) throw new Error(`Duplicate action "${name}"`);
|
|
661
|
-
actions[name] = state2;
|
|
662
|
-
}
|
|
663
|
-
for (const name of Object.keys(state2.events)) {
|
|
664
|
-
if (events[name]) throw new Error(`Duplicate event "${name}"`);
|
|
665
|
-
events[name] = { schema: state2.events[name], reactions: /* @__PURE__ */ new Map() };
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
function mergeIntoExisting(state2, existing, states, actions, events) {
|
|
669
|
-
for (const name of Object.keys(state2.actions)) {
|
|
670
|
-
if (existing.actions[name] === state2.actions[name]) continue;
|
|
671
|
-
if (actions[name]) throw new Error(`Duplicate action "${name}"`);
|
|
672
|
-
}
|
|
673
|
-
for (const name of Object.keys(state2.events)) {
|
|
674
|
-
if (existing.events[name] === state2.events[name]) continue;
|
|
675
|
-
if (existing.events[name]) {
|
|
676
|
-
throw new Error(
|
|
677
|
-
`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.`
|
|
678
|
-
);
|
|
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
|
|
679
602
|
}
|
|
680
|
-
if (events[name]) throw new Error(`Duplicate event "${name}"`);
|
|
681
|
-
}
|
|
682
|
-
const mergedPatch = mergePatches(existing.patch, state2.patch, state2.name);
|
|
683
|
-
const merged = {
|
|
684
|
-
...existing,
|
|
685
|
-
state: mergeSchemas(existing.state, state2.state, state2.name),
|
|
686
|
-
init: mergeInits(existing.init, state2.init),
|
|
687
|
-
events: { ...existing.events, ...state2.events },
|
|
688
|
-
actions: { ...existing.actions, ...state2.actions },
|
|
689
|
-
patch: mergedPatch,
|
|
690
|
-
on: { ...existing.on, ...state2.on },
|
|
691
|
-
given: { ...existing.given, ...state2.given },
|
|
692
|
-
snap: state2.snap && existing.snap && state2.snap !== existing.snap ? (() => {
|
|
693
|
-
throw new Error(
|
|
694
|
-
`Duplicate snap strategy for state "${state2.name}"`
|
|
695
|
-
);
|
|
696
|
-
})() : state2.snap || existing.snap
|
|
697
603
|
};
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
merged[name] = incomingP;
|
|
714
|
-
continue;
|
|
715
|
-
}
|
|
716
|
-
const existingIsDefault = existingP._passthrough;
|
|
717
|
-
const incomingIsDefault = incomingP._passthrough;
|
|
718
|
-
if (!existingIsDefault && !incomingIsDefault && existingP !== incomingP) {
|
|
719
|
-
throw new Error(
|
|
720
|
-
`Duplicate custom patch for event "${name}" in state "${stateName}"`
|
|
721
|
-
);
|
|
722
|
-
}
|
|
723
|
-
if (existingIsDefault && !incomingIsDefault) {
|
|
724
|
-
merged[name] = incomingP;
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
return merged;
|
|
728
|
-
}
|
|
729
|
-
function mergeEventRegister(target, source) {
|
|
730
|
-
for (const [eventName, sourceReg] of Object.entries(source)) {
|
|
731
|
-
const targetReg = target[eventName];
|
|
732
|
-
if (!targetReg) continue;
|
|
733
|
-
for (const [name, reaction] of sourceReg.reactions) {
|
|
734
|
-
targetReg.reactions.set(name, reaction);
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
function mergeProjection(proj, events) {
|
|
739
|
-
for (const eventName of Object.keys(proj.events)) {
|
|
740
|
-
const projRegister = proj.events[eventName];
|
|
741
|
-
const existing = events[eventName];
|
|
742
|
-
if (!existing) {
|
|
743
|
-
events[eventName] = {
|
|
744
|
-
schema: projRegister.schema,
|
|
745
|
-
reactions: new Map(projRegister.reactions)
|
|
746
|
-
};
|
|
747
|
-
} else {
|
|
748
|
-
for (const [name, reaction] of projRegister.reactions) {
|
|
749
|
-
let key = name;
|
|
750
|
-
while (existing.reactions.has(key)) key = `${key}_p`;
|
|
751
|
-
existing.reactions.set(key, reaction);
|
|
752
|
-
}
|
|
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);
|
|
753
619
|
}
|
|
620
|
+
throw error;
|
|
754
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;
|
|
755
649
|
}
|
|
756
|
-
var _this_ = ({ stream }) => ({
|
|
757
|
-
source: stream,
|
|
758
|
-
target: stream
|
|
759
|
-
});
|
|
760
650
|
|
|
761
|
-
// src/internal/
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
+
};
|
|
777
720
|
}
|
|
778
|
-
if (opts.jitter) delay = delay * (0.5 + Math.random());
|
|
779
|
-
return Math.max(0, Math.floor(delay));
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
// src/internal/reactions.ts
|
|
783
|
-
function finalize(lease, handled, at, error, options, logger) {
|
|
784
|
-
if (!error) return { lease, handled, at };
|
|
785
|
-
logger.error(error);
|
|
786
|
-
const nonRetryable = error instanceof NonRetryableError;
|
|
787
|
-
const block2 = options.blockOnError && (nonRetryable || lease.retry >= options.maxRetries);
|
|
788
|
-
if (block2)
|
|
789
|
-
logger.error(
|
|
790
|
-
nonRetryable ? `Blocking ${lease.stream} on non-retryable error.` : `Blocking ${lease.stream} after ${lease.retry} retries.`
|
|
791
|
-
);
|
|
792
|
-
const nextAttemptAt = !block2 && options.backoff ? Date.now() + computeBackoffDelay(lease.retry, options.backoff) : void 0;
|
|
793
721
|
return {
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
}
|
|
802
|
-
function buildHandle(deps) {
|
|
803
|
-
const { logger, boundDo, boundLoad, boundQuery, boundQueryArray } = deps;
|
|
804
|
-
return async (lease, payloads) => {
|
|
805
|
-
if (payloads.length === 0) return { lease, handled: 0, at: lease.at };
|
|
806
|
-
const stream = lease.stream;
|
|
807
|
-
let at = payloads.at(0).event.id;
|
|
808
|
-
let handled = 0;
|
|
809
|
-
if (lease.retry > 0)
|
|
810
|
-
logger.warn(`Retrying ${stream}@${at} (${lease.retry}).`);
|
|
811
|
-
const scopedApp = {
|
|
812
|
-
do: boundDo,
|
|
813
|
-
load: boundLoad,
|
|
814
|
-
query: boundQuery,
|
|
815
|
-
query_array: boundQueryArray
|
|
816
|
-
};
|
|
817
|
-
for (const payload of payloads) {
|
|
818
|
-
const { event, handler } = payload;
|
|
819
|
-
scopedApp.do = (action2, target, actionPayload, reactingTo, skipValidation) => boundDo(
|
|
820
|
-
action2,
|
|
821
|
-
target,
|
|
822
|
-
actionPayload,
|
|
823
|
-
reactingTo ?? event,
|
|
824
|
-
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
|
+
)
|
|
825
729
|
);
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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}`)
|
|
838
765
|
);
|
|
839
766
|
}
|
|
840
|
-
|
|
841
|
-
|
|
767
|
+
),
|
|
768
|
+
tombstone: traced(tombstone, (committed, stream) => {
|
|
769
|
+
if (committed)
|
|
770
|
+
logger.trace(
|
|
771
|
+
es_caption("tombstoned", C_ORANGE, `${stream}@${committed.version}`)
|
|
772
|
+
);
|
|
773
|
+
})
|
|
842
774
|
};
|
|
843
775
|
}
|
|
844
|
-
function
|
|
845
|
-
return
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
logger
|
|
860
|
-
);
|
|
861
|
-
} catch (error) {
|
|
862
|
-
return finalize(lease, 0, lease.at, error, options, logger);
|
|
863
|
-
}
|
|
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
|
+
})
|
|
864
791
|
};
|
|
865
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
|
+
}
|
|
866
830
|
|
|
867
|
-
// src/internal/
|
|
868
|
-
|
|
869
|
-
|
|
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) {
|
|
870
897
|
this.deps = deps;
|
|
871
|
-
this.defaultDebounceMs = defaultDebounceMs;
|
|
872
898
|
}
|
|
873
|
-
|
|
874
|
-
|
|
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
|
+
}
|
|
875
956
|
/**
|
|
876
|
-
*
|
|
877
|
-
*
|
|
878
|
-
*
|
|
879
|
-
*
|
|
880
|
-
*
|
|
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.
|
|
881
964
|
*/
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
this.
|
|
891
|
-
|
|
892
|
-
if (this._running) return;
|
|
893
|
-
this._running = true;
|
|
894
|
-
(async () => {
|
|
895
|
-
await this.deps.init();
|
|
896
|
-
let lastDrain;
|
|
897
|
-
for (let i = 0; i < maxPasses; i++) {
|
|
898
|
-
const { subscribed } = await this.deps.correlate({
|
|
899
|
-
...correlateQuery,
|
|
900
|
-
after: this.deps.checkpoint()
|
|
901
|
-
});
|
|
902
|
-
lastDrain = await this.deps.drain(drainOptions);
|
|
903
|
-
const made_progress = subscribed > 0 || lastDrain.acked.length > 0 || lastDrain.blocked.length > 0;
|
|
904
|
-
if (!made_progress) break;
|
|
905
|
-
}
|
|
906
|
-
if (lastDrain) this.deps.onSettled(lastDrain);
|
|
907
|
-
})().catch((err) => this.deps.logger.error(err)).finally(() => {
|
|
908
|
-
this._running = false;
|
|
909
|
-
});
|
|
910
|
-
}, 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();
|
|
911
975
|
}
|
|
912
|
-
/**
|
|
976
|
+
/** Stop the per-lane worker. Idempotent. */
|
|
913
977
|
stop() {
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
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;
|
|
917
1034
|
}
|
|
918
1035
|
}
|
|
919
1036
|
};
|
|
920
1037
|
|
|
921
|
-
// src/internal/
|
|
922
|
-
var
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
limit: eventLimit
|
|
931
|
-
});
|
|
932
|
-
return { stream, source, at, lagging, events };
|
|
933
|
-
})
|
|
934
|
-
);
|
|
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 };
|
|
935
1047
|
}
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
correlation: meta.correlation,
|
|
950
|
-
causation: { event: { id, name, stream } }
|
|
951
|
-
},
|
|
952
|
-
version
|
|
953
|
-
// IMPORTANT! - state events are committed right after the snapshot event
|
|
954
|
-
);
|
|
955
|
-
} catch (error) {
|
|
956
|
-
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);
|
|
957
1061
|
}
|
|
1062
|
+
return deprecated;
|
|
958
1063
|
}
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
);
|
|
967
|
-
return committed;
|
|
968
|
-
} catch (error) {
|
|
969
|
-
if (error instanceof ConcurrencyError) return void 0;
|
|
970
|
-
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 };
|
|
971
1071
|
}
|
|
1072
|
+
return highest && highest.version > target.version ? highest.name : void 0;
|
|
972
1073
|
}
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
let
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
} else if (e.name !== TOMBSTONE_EVENT) {
|
|
997
|
-
log().warn(
|
|
998
|
-
`Skipping unknown event "${String(e.name)}" on stream "${stream}" (id=${e.id}) \u2014 no reducer in state "${me.name}"`
|
|
999
|
-
);
|
|
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
|
+
}
|
|
1000
1097
|
}
|
|
1001
|
-
callback?.({
|
|
1002
|
-
event,
|
|
1003
|
-
state: state2,
|
|
1004
|
-
version,
|
|
1005
|
-
patches,
|
|
1006
|
-
snaps,
|
|
1007
|
-
cache_hit,
|
|
1008
|
-
replayed
|
|
1009
|
-
});
|
|
1010
|
-
},
|
|
1011
|
-
{
|
|
1012
|
-
stream,
|
|
1013
|
-
stream_exact: true,
|
|
1014
|
-
...cached ? { after: cached.event_id } : { with_snaps: true, ...asOf }
|
|
1015
1098
|
}
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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);
|
|
1025
1112
|
}
|
|
1026
|
-
return { event, state: state2, version, patches, snaps, cache_hit, replayed };
|
|
1027
1113
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
if (snapshot.event?.name === TOMBSTONE_EVENT)
|
|
1034
|
-
throw new StreamClosedError(stream);
|
|
1035
|
-
const expected = expectedVersion ?? snapshot.event?.version;
|
|
1036
|
-
if (me.given) {
|
|
1037
|
-
const invariants = me.given[action2] || [];
|
|
1038
|
-
invariants.forEach(({ valid, description }) => {
|
|
1039
|
-
if (!valid(snapshot.state, actor))
|
|
1040
|
-
throw new InvariantError(
|
|
1041
|
-
action2,
|
|
1042
|
-
validated,
|
|
1043
|
-
target,
|
|
1044
|
-
snapshot,
|
|
1045
|
-
description
|
|
1046
|
-
);
|
|
1047
|
-
});
|
|
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;
|
|
1048
1119
|
}
|
|
1049
|
-
const
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
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() };
|
|
1053
1123
|
}
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
for (const [name] of tuples) {
|
|
1060
|
-
const evt = name;
|
|
1061
|
-
if (deprecated.has(evt) && !warned.has(evt)) {
|
|
1062
|
-
warned.add(evt);
|
|
1063
|
-
log().warn(
|
|
1064
|
-
`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)`
|
|
1065
|
-
);
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
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}"`);
|
|
1068
1129
|
}
|
|
1069
|
-
const
|
|
1070
|
-
name
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
action: action2,
|
|
1076
|
-
state: me.name,
|
|
1077
|
-
stream,
|
|
1078
|
-
actor: target.actor
|
|
1079
|
-
}),
|
|
1080
|
-
causation: {
|
|
1081
|
-
action: {
|
|
1082
|
-
name: action2,
|
|
1083
|
-
...target
|
|
1084
|
-
// payload intentionally omitted: it can be large or contain PII,
|
|
1085
|
-
// and callers correlate via the correlation id when they need it.
|
|
1086
|
-
},
|
|
1087
|
-
event: reactingTo ? {
|
|
1088
|
-
id: reactingTo.id,
|
|
1089
|
-
name: reactingTo.name,
|
|
1090
|
-
stream: reactingTo.stream
|
|
1091
|
-
} : 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
|
+
);
|
|
1092
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
|
|
1093
1154
|
};
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
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;
|
|
1109
1182
|
}
|
|
1110
|
-
throw error;
|
|
1111
1183
|
}
|
|
1112
|
-
|
|
1113
|
-
const snapshots = committed.map((event) => {
|
|
1114
|
-
const p = me.patch[event.name](event, state2);
|
|
1115
|
-
state2 = patch(state2, p);
|
|
1116
|
-
patches++;
|
|
1117
|
-
return {
|
|
1118
|
-
event,
|
|
1119
|
-
state: state2,
|
|
1120
|
-
version: event.version,
|
|
1121
|
-
patches,
|
|
1122
|
-
snaps: snapshot.snaps,
|
|
1123
|
-
patch: p,
|
|
1124
|
-
cache_hit: snapshot.cache_hit,
|
|
1125
|
-
replayed: snapshot.replayed
|
|
1126
|
-
};
|
|
1127
|
-
});
|
|
1128
|
-
const last = snapshots.at(-1);
|
|
1129
|
-
const snapped = me.snap?.(last);
|
|
1130
|
-
cache().set(stream, {
|
|
1131
|
-
state: last.state,
|
|
1132
|
-
version: last.event.version,
|
|
1133
|
-
event_id: last.event.id,
|
|
1134
|
-
patches: snapped ? 0 : last.patches,
|
|
1135
|
-
snaps: snapped ? last.snaps + 1 : last.snaps
|
|
1136
|
-
}).catch((err) => log().error(err));
|
|
1137
|
-
if (snapped) void snap(last);
|
|
1138
|
-
return snapshots;
|
|
1184
|
+
return merged;
|
|
1139
1185
|
}
|
|
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
|
-
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
if (asOf.created_before !== void 0)
|
|
1171
|
-
parts.push(`created_before=${asOf.created_before.toISOString()}`);
|
|
1172
|
-
if (asOf.created_after !== void 0)
|
|
1173
|
-
parts.push(`created_after=${asOf.created_after.toISOString()}`);
|
|
1174
|
-
if (asOf.limit !== void 0) parts.push(`limit=${asOf.limit}`);
|
|
1175
|
-
return parts.length ? ` (as-of ${parts.join(" ")})` : " (as-of)";
|
|
1176
|
-
};
|
|
1177
|
-
var traced = (inner, exit, entry) => (async (...args) => {
|
|
1178
|
-
entry?.(...args);
|
|
1179
|
-
const result = await inner(...args);
|
|
1180
|
-
exit?.(result, ...args);
|
|
1181
|
-
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
|
|
1182
1216
|
});
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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;
|
|
1200
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;
|
|
1201
1250
|
return {
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
);
|
|
1210
|
-
}),
|
|
1211
|
-
load: traced(load, (result, _me, stream, _cb, asOf) => {
|
|
1212
|
-
const stats = stats_marker(
|
|
1213
|
-
result.version,
|
|
1214
|
-
result.replayed,
|
|
1215
|
-
result.snaps,
|
|
1216
|
-
result.patches
|
|
1217
|
-
);
|
|
1218
|
-
logger.trace(
|
|
1219
|
-
es_caption(
|
|
1220
|
-
"load",
|
|
1221
|
-
C_GREEN,
|
|
1222
|
-
`${stream}${as_of_marker(asOf)} ${cache_marker(result.cache_hit)} ${stats}`
|
|
1223
|
-
)
|
|
1224
|
-
);
|
|
1225
|
-
}),
|
|
1226
|
-
action: traced(
|
|
1227
|
-
boundAction,
|
|
1228
|
-
(snapshots, _me, _action, target) => {
|
|
1229
|
-
const committed = snapshots.filter((s) => s.event);
|
|
1230
|
-
if (committed.length) {
|
|
1231
|
-
logger.trace(
|
|
1232
|
-
committed.map((s) => s.event.data),
|
|
1233
|
-
es_caption(
|
|
1234
|
-
"committed",
|
|
1235
|
-
C_ORANGE,
|
|
1236
|
-
`${target.stream}.${committed.map((s) => s.event.name).join(", ")}`
|
|
1237
|
-
)
|
|
1238
|
-
);
|
|
1239
|
-
}
|
|
1240
|
-
},
|
|
1241
|
-
(_me, action2, target, payload) => {
|
|
1242
|
-
logger.trace(
|
|
1243
|
-
payload,
|
|
1244
|
-
es_caption("action", C_BLUE, `${target.stream}.${action2}`)
|
|
1245
|
-
);
|
|
1246
|
-
}
|
|
1247
|
-
),
|
|
1248
|
-
tombstone: traced(tombstone, (committed, stream) => {
|
|
1249
|
-
if (committed)
|
|
1250
|
-
logger.trace(
|
|
1251
|
-
es_caption("tombstoned", C_ORANGE, `${stream}@${committed.version}`)
|
|
1252
|
-
);
|
|
1253
|
-
})
|
|
1251
|
+
lease,
|
|
1252
|
+
handled,
|
|
1253
|
+
acked_at: at,
|
|
1254
|
+
error: error.message,
|
|
1255
|
+
block: block2,
|
|
1256
|
+
nextAttemptAt,
|
|
1257
|
+
failed_at
|
|
1254
1258
|
};
|
|
1255
1259
|
}
|
|
1256
|
-
function
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
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
|
|
1264
1274
|
};
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
}
|
|
1274
|
-
}),
|
|
1275
|
-
fetch: traced(fetch, (fetched) => {
|
|
1276
|
-
const data = Object.fromEntries(
|
|
1277
|
-
fetched.map(({ stream, source, events }) => {
|
|
1278
|
-
const key = source ? `${stream}<-${source}` : stream;
|
|
1279
|
-
const value = Object.fromEntries(
|
|
1280
|
-
events.map(({ id, stream: stream2, name }) => [id, { [stream2]: name }])
|
|
1281
|
-
);
|
|
1282
|
-
return [key, value];
|
|
1283
|
-
})
|
|
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
|
|
1284
1283
|
);
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
blocked.map(({ stream, at, retry, error }) => [
|
|
1299
|
-
stream,
|
|
1300
|
-
{ at, retry, error }
|
|
1301
|
-
])
|
|
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
|
|
1302
1297
|
);
|
|
1303
|
-
logger.trace(data, drain_caption("blocked"));
|
|
1304
|
-
}
|
|
1305
|
-
}),
|
|
1306
|
-
subscribe: traced(subscribe, (result, streams) => {
|
|
1307
|
-
if (result.subscribed) {
|
|
1308
|
-
const data = streams.map(({ stream }) => stream).join(" ");
|
|
1309
|
-
logger.trace(`${drain_caption("correlated")} ${data}`);
|
|
1310
1298
|
}
|
|
1311
|
-
}
|
|
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
|
+
}
|
|
1312
1323
|
};
|
|
1313
1324
|
}
|
|
1314
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
|
+
|
|
1315
1380
|
// src/act.ts
|
|
1316
1381
|
var DEFAULT_MAX_SUBSCRIBED_STREAMS = 1e3;
|
|
1317
1382
|
var DEFAULT_SETTLE_DEBOUNCE_MS = 10;
|
|
@@ -1326,11 +1391,26 @@ var Act = class {
|
|
|
1326
1391
|
* @param _states Merged map of state name → state definition
|
|
1327
1392
|
* @param batchHandlers Static-target projection batch handlers (target → handler)
|
|
1328
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.
|
|
1329
1397
|
*/
|
|
1330
|
-
constructor(registry, _states = /* @__PURE__ */ new Map(), batchHandlers = /* @__PURE__ */ new Map(), options = {}) {
|
|
1398
|
+
constructor(registry, _states = /* @__PURE__ */ new Map(), batchHandlers = /* @__PURE__ */ new Map(), options = {}, lanes = []) {
|
|
1331
1399
|
this.registry = registry;
|
|
1332
1400
|
this._states = _states;
|
|
1333
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
|
+
}
|
|
1334
1414
|
this._scoped = options.scoped ? (fn) => scoped.run(options.scoped, fn) : (fn) => fn();
|
|
1335
1415
|
this._correlator = options.correlator ?? defaultCorrelator;
|
|
1336
1416
|
this._es = buildEs(this._logger, this._correlator);
|
|
@@ -1343,19 +1423,44 @@ var Act = class {
|
|
|
1343
1423
|
boundQueryArray: this._bound_query_array
|
|
1344
1424
|
});
|
|
1345
1425
|
this._handle_batch = buildHandleBatch(this._logger);
|
|
1346
|
-
const {
|
|
1426
|
+
const {
|
|
1427
|
+
staticTargets,
|
|
1428
|
+
hasDynamicResolvers,
|
|
1429
|
+
reactiveEvents,
|
|
1430
|
+
eventToState,
|
|
1431
|
+
eventToLanes
|
|
1432
|
+
} = classifyRegistry(this.registry, this._states);
|
|
1347
1433
|
this._reactive_events = reactiveEvents;
|
|
1348
1434
|
this._event_to_state = eventToState;
|
|
1349
|
-
this.
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
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
|
+
}
|
|
1359
1464
|
this._correlate = new CorrelateCycle(
|
|
1360
1465
|
this.registry,
|
|
1361
1466
|
staticTargets,
|
|
@@ -1364,7 +1469,7 @@ var Act = class {
|
|
|
1364
1469
|
options.maxSubscribedStreams ?? DEFAULT_MAX_SUBSCRIBED_STREAMS,
|
|
1365
1470
|
// Cold start: assume drain is needed (historical events may need processing)
|
|
1366
1471
|
() => {
|
|
1367
|
-
if (this._reactive_events.size > 0) this.
|
|
1472
|
+
if (this._reactive_events.size > 0) this._armAll();
|
|
1368
1473
|
}
|
|
1369
1474
|
);
|
|
1370
1475
|
this._settle = new SettleLoop(
|
|
@@ -1384,8 +1489,8 @@ var Act = class {
|
|
|
1384
1489
|
_emitter = new EventEmitter();
|
|
1385
1490
|
/** Event names with at least one registered reaction (computed at build time) */
|
|
1386
1491
|
_reactive_events;
|
|
1387
|
-
/**
|
|
1388
|
-
|
|
1492
|
+
/** One DrainController per active lane, keyed by lane name. */
|
|
1493
|
+
_drain_controllers;
|
|
1389
1494
|
/** Correlation state machine: lazy init, dynamic-resolver scan, periodic worker. */
|
|
1390
1495
|
_correlate;
|
|
1391
1496
|
/** Debounced correlate→drain catch-up loop. */
|
|
@@ -1439,6 +1544,14 @@ var Act = class {
|
|
|
1439
1544
|
* set when seeding a `restart` snapshot in multi-state apps.
|
|
1440
1545
|
*/
|
|
1441
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;
|
|
1442
1555
|
/** Logger resolved at construction time (after user port configuration) */
|
|
1443
1556
|
_logger = log();
|
|
1444
1557
|
/** Wraps a public-method body so internal `store()`/`cache()` resolve to the
|
|
@@ -1462,6 +1575,12 @@ var Act = class {
|
|
|
1462
1575
|
/** Reaction dispatchers built once and handed to runDrainCycle each cycle. */
|
|
1463
1576
|
_handle;
|
|
1464
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
|
+
}
|
|
1465
1584
|
/** True after the first `shutdown()` call. Guards idempotency. */
|
|
1466
1585
|
_shutdown_promise;
|
|
1467
1586
|
/**
|
|
@@ -1480,6 +1599,7 @@ var Act = class {
|
|
|
1480
1599
|
this._emitter.removeAllListeners();
|
|
1481
1600
|
this.stop_correlations();
|
|
1482
1601
|
this.stop_settling();
|
|
1602
|
+
for (const c of this._drain_controllers.values()) c.stop();
|
|
1483
1603
|
const disposer = await this._notify_disposer;
|
|
1484
1604
|
if (disposer) await disposer();
|
|
1485
1605
|
})();
|
|
@@ -1499,13 +1619,10 @@ var Act = class {
|
|
|
1499
1619
|
return await s.notify((notification) => {
|
|
1500
1620
|
try {
|
|
1501
1621
|
this.emit("notified", notification);
|
|
1502
|
-
const
|
|
1503
|
-
(e) =>
|
|
1622
|
+
const armed = this._armForEventNames(
|
|
1623
|
+
notification.events.map((e) => e.name)
|
|
1504
1624
|
);
|
|
1505
|
-
if (
|
|
1506
|
-
this._drain.arm();
|
|
1507
|
-
this._settle.schedule({ debounceMs: 0 });
|
|
1508
|
-
}
|
|
1625
|
+
if (armed) this._settle.schedule({ debounceMs: 0 });
|
|
1509
1626
|
} catch (err) {
|
|
1510
1627
|
this._logger.error(err, "notified handler threw");
|
|
1511
1628
|
}
|
|
@@ -1606,14 +1723,10 @@ var Act = class {
|
|
|
1606
1723
|
reactingTo,
|
|
1607
1724
|
skipValidation
|
|
1608
1725
|
);
|
|
1609
|
-
if (this._reactive_events.size > 0)
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
break;
|
|
1614
|
-
}
|
|
1615
|
-
}
|
|
1616
|
-
}
|
|
1726
|
+
if (this._reactive_events.size > 0)
|
|
1727
|
+
this._armForEventNames(
|
|
1728
|
+
snapshots.map((s) => s.event.name)
|
|
1729
|
+
);
|
|
1617
1730
|
this.emit("committed", snapshots);
|
|
1618
1731
|
return snapshots;
|
|
1619
1732
|
});
|
|
@@ -1760,7 +1873,59 @@ var Act = class {
|
|
|
1760
1873
|
* @see {@link start_correlations} for automatic correlation
|
|
1761
1874
|
*/
|
|
1762
1875
|
async drain(options = {}) {
|
|
1763
|
-
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 };
|
|
1764
1929
|
}
|
|
1765
1930
|
/**
|
|
1766
1931
|
* Discovers and registers new streams dynamically based on reaction resolvers.
|
|
@@ -1931,7 +2096,7 @@ var Act = class {
|
|
|
1931
2096
|
async reset(input) {
|
|
1932
2097
|
return this._scoped(async () => {
|
|
1933
2098
|
const count = await store().reset(input);
|
|
1934
|
-
if (count > 0 && this._reactive_events.size > 0) this.
|
|
2099
|
+
if (count > 0 && this._reactive_events.size > 0) this._armAll();
|
|
1935
2100
|
return count;
|
|
1936
2101
|
});
|
|
1937
2102
|
}
|
|
@@ -1965,7 +2130,7 @@ var Act = class {
|
|
|
1965
2130
|
async unblock(input) {
|
|
1966
2131
|
return this._scoped(async () => {
|
|
1967
2132
|
const count = await store().unblock(input);
|
|
1968
|
-
if (count > 0 && this._reactive_events.size > 0) this.
|
|
2133
|
+
if (count > 0 && this._reactive_events.size > 0) this._armAll();
|
|
1969
2134
|
return count;
|
|
1970
2135
|
});
|
|
1971
2136
|
}
|
|
@@ -2138,6 +2303,22 @@ function registerBatchHandler(proj, batchHandlers) {
|
|
|
2138
2303
|
}
|
|
2139
2304
|
batchHandlers.set(proj.target, proj.batchHandler);
|
|
2140
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
|
+
}
|
|
2141
2322
|
function act() {
|
|
2142
2323
|
const states = /* @__PURE__ */ new Map();
|
|
2143
2324
|
const registry = {
|
|
@@ -2146,6 +2327,7 @@ function act() {
|
|
|
2146
2327
|
};
|
|
2147
2328
|
const pendingProjections = [];
|
|
2148
2329
|
const batchHandlers = /* @__PURE__ */ new Map();
|
|
2330
|
+
const lanes = [];
|
|
2149
2331
|
let _built = false;
|
|
2150
2332
|
const finalizeDeprecations = () => {
|
|
2151
2333
|
const deprecationSummary = [];
|
|
@@ -2192,6 +2374,18 @@ function act() {
|
|
|
2192
2374
|
}
|
|
2193
2375
|
mergeEventRegister(registry.events, input.events);
|
|
2194
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
|
+
}
|
|
2195
2389
|
return builder;
|
|
2196
2390
|
},
|
|
2197
2391
|
withProjection: (proj) => {
|
|
@@ -2200,6 +2394,14 @@ function act() {
|
|
|
2200
2394
|
return builder;
|
|
2201
2395
|
},
|
|
2202
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
|
+
},
|
|
2203
2405
|
on: (event) => ({
|
|
2204
2406
|
do: (handler, options) => {
|
|
2205
2407
|
const reaction = {
|
|
@@ -2231,13 +2433,15 @@ function act() {
|
|
|
2231
2433
|
registerBatchHandler(proj, batchHandlers);
|
|
2232
2434
|
}
|
|
2233
2435
|
finalizeDeprecations();
|
|
2436
|
+
validateLaneReferences(registry, lanes);
|
|
2234
2437
|
_built = true;
|
|
2235
2438
|
}
|
|
2236
2439
|
return new Act(
|
|
2237
2440
|
registry,
|
|
2238
2441
|
states,
|
|
2239
2442
|
batchHandlers,
|
|
2240
|
-
options
|
|
2443
|
+
options,
|
|
2444
|
+
lanes
|
|
2241
2445
|
);
|
|
2242
2446
|
},
|
|
2243
2447
|
events: registry.events
|
|
@@ -2318,6 +2522,7 @@ function slice() {
|
|
|
2318
2522
|
const actions = {};
|
|
2319
2523
|
const events = {};
|
|
2320
2524
|
const projections = [];
|
|
2525
|
+
const lanes = [];
|
|
2321
2526
|
const builder = {
|
|
2322
2527
|
withState: (state2) => {
|
|
2323
2528
|
registerState(state2, states, actions, events);
|
|
@@ -2327,6 +2532,14 @@ function slice() {
|
|
|
2327
2532
|
projections.push(proj);
|
|
2328
2533
|
return builder;
|
|
2329
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
|
+
},
|
|
2330
2543
|
on: (event) => ({
|
|
2331
2544
|
do: (handler, options) => {
|
|
2332
2545
|
const reaction = {
|
|
@@ -2355,7 +2568,8 @@ function slice() {
|
|
|
2355
2568
|
_tag: "Slice",
|
|
2356
2569
|
states,
|
|
2357
2570
|
events,
|
|
2358
|
-
projections
|
|
2571
|
+
projections,
|
|
2572
|
+
lanes
|
|
2359
2573
|
}),
|
|
2360
2574
|
events
|
|
2361
2575
|
};
|
|
@@ -2448,6 +2662,7 @@ export {
|
|
|
2448
2662
|
CommittedMetaSchema,
|
|
2449
2663
|
ConcurrencyError,
|
|
2450
2664
|
ConsoleLogger,
|
|
2665
|
+
DEFAULT_LANE,
|
|
2451
2666
|
DEFAULT_MAX_SUBSCRIBED_STREAMS,
|
|
2452
2667
|
DEFAULT_SETTLE_DEBOUNCE_MS,
|
|
2453
2668
|
Environments,
|