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