@nifrajs/core 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +27 -0
  2. package/dist/capabilities.d.ts +54 -2
  3. package/dist/capabilities.d.ts.map +1 -1
  4. package/dist/capabilities.js +240 -5
  5. package/dist/capabilities.js.map +1 -1
  6. package/dist/durable-adapters.d.ts +241 -0
  7. package/dist/durable-adapters.d.ts.map +1 -0
  8. package/dist/durable-adapters.js +697 -0
  9. package/dist/durable-adapters.js.map +1 -0
  10. package/dist/durable-execution.d.ts +349 -0
  11. package/dist/durable-execution.d.ts.map +1 -0
  12. package/dist/durable-execution.js +1173 -0
  13. package/dist/durable-execution.js.map +1 -0
  14. package/dist/effect-lifecycle.d.ts +42 -0
  15. package/dist/effect-lifecycle.d.ts.map +1 -0
  16. package/dist/effect-lifecycle.js +66 -0
  17. package/dist/effect-lifecycle.js.map +1 -0
  18. package/dist/effect-scope.d.ts +75 -0
  19. package/dist/effect-scope.d.ts.map +1 -0
  20. package/dist/effect-scope.js +177 -0
  21. package/dist/effect-scope.js.map +1 -0
  22. package/dist/idempotency-plugin.d.ts +5 -0
  23. package/dist/idempotency-plugin.d.ts.map +1 -1
  24. package/dist/idempotency-plugin.js +8 -0
  25. package/dist/idempotency-plugin.js.map +1 -1
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.js +1 -1
  28. package/dist/internal/capability-runtime.d.ts +62 -1
  29. package/dist/internal/capability-runtime.d.ts.map +1 -1
  30. package/dist/internal/capability-runtime.js +11 -2
  31. package/dist/internal/capability-runtime.js.map +1 -1
  32. package/dist/internal/effect-execution.d.ts +16 -0
  33. package/dist/internal/effect-execution.d.ts.map +1 -0
  34. package/dist/internal/effect-execution.js +50 -0
  35. package/dist/internal/effect-execution.js.map +1 -0
  36. package/dist/internal/safe-equal.d.ts +20 -0
  37. package/dist/internal/safe-equal.d.ts.map +1 -0
  38. package/dist/internal/safe-equal.js +31 -0
  39. package/dist/internal/safe-equal.js.map +1 -0
  40. package/dist/ledger.d.ts +14 -6
  41. package/dist/ledger.d.ts.map +1 -1
  42. package/dist/ledger.js +31 -5
  43. package/dist/ledger.js.map +1 -1
  44. package/dist/reconciliation-worker.d.ts +108 -0
  45. package/dist/reconciliation-worker.d.ts.map +1 -0
  46. package/dist/reconciliation-worker.js +239 -0
  47. package/dist/reconciliation-worker.js.map +1 -0
  48. package/dist/router/pattern.d.ts +26 -1
  49. package/dist/router/pattern.d.ts.map +1 -1
  50. package/dist/router/pattern.js +164 -8
  51. package/dist/router/pattern.js.map +1 -1
  52. package/dist/router/router.d.ts.map +1 -1
  53. package/dist/router/router.js +49 -1
  54. package/dist/router/router.js.map +1 -1
  55. package/dist/server/idempotency-lane.d.ts +1 -0
  56. package/dist/server/idempotency-lane.d.ts.map +1 -1
  57. package/dist/server/idempotency-lane.js +61 -37
  58. package/dist/server/idempotency-lane.js.map +1 -1
  59. package/dist/server/respond.d.ts.map +1 -1
  60. package/dist/server/respond.js.map +1 -1
  61. package/dist/server/server.d.ts +12 -0
  62. package/dist/server/server.d.ts.map +1 -1
  63. package/dist/server/server.js +33 -2
  64. package/dist/server/server.js.map +1 -1
  65. package/dist/server/transport-lane.d.ts +28 -0
  66. package/dist/server/transport-lane.d.ts.map +1 -0
  67. package/dist/server/transport-lane.js +106 -0
  68. package/dist/server/transport-lane.js.map +1 -0
  69. package/dist/server/websocket.d.ts +6 -0
  70. package/dist/server/websocket.d.ts.map +1 -1
  71. package/dist/server/websocket.js +10 -1
  72. package/dist/server/websocket.js.map +1 -1
  73. package/dist/transport-codec-rich.d.ts +8 -0
  74. package/dist/transport-codec-rich.d.ts.map +1 -0
  75. package/dist/transport-codec-rich.js +14 -0
  76. package/dist/transport-codec-rich.js.map +1 -0
  77. package/dist/transport-codec.d.ts +31 -0
  78. package/dist/transport-codec.d.ts.map +1 -0
  79. package/dist/transport-codec.js +214 -0
  80. package/dist/transport-codec.js.map +1 -0
  81. package/dist/transport-plugin.d.ts +3 -0
  82. package/dist/transport-plugin.d.ts.map +1 -0
  83. package/dist/transport-plugin.js +3 -0
  84. package/dist/transport-plugin.js.map +1 -0
  85. package/dist/wire.d.ts +58 -0
  86. package/dist/wire.d.ts.map +1 -0
  87. package/dist/wire.js +445 -0
  88. package/dist/wire.js.map +1 -0
  89. package/package.json +46 -1
  90. package/src/capabilities.ts +306 -5
  91. package/src/durable-adapters.ts +1039 -0
  92. package/src/durable-execution.ts +1676 -0
  93. package/src/effect-lifecycle.ts +120 -0
  94. package/src/effect-scope.ts +267 -0
  95. package/src/idempotency-plugin.ts +10 -0
  96. package/src/index.ts +1 -1
  97. package/src/internal/capability-runtime.ts +87 -1
  98. package/src/internal/effect-execution.ts +70 -0
  99. package/src/internal/safe-equal.ts +27 -0
  100. package/src/ledger.ts +45 -11
  101. package/src/reconciliation-worker.ts +372 -0
  102. package/src/router/pattern.ts +179 -8
  103. package/src/router/router.ts +75 -1
  104. package/src/server/idempotency-lane.ts +68 -34
  105. package/src/server/respond.ts +1 -0
  106. package/src/server/server.ts +46 -0
  107. package/src/server/transport-lane.ts +141 -0
  108. package/src/server/websocket.ts +15 -1
  109. package/src/transport-codec-rich.ts +20 -0
  110. package/src/transport-codec.ts +284 -0
  111. package/src/transport-plugin.ts +2 -0
  112. package/src/wire.ts +474 -0
@@ -0,0 +1,1173 @@
1
+ /**
2
+ * Durable capability execution primitives. This subpath is opt-in so the bare HTTP runtime stays
3
+ * lean. Approval and journal records are token-only. Saga input, results, and compensation arguments
4
+ * intentionally live only in the caller-supplied durable saga store — never in the effect ledger or
5
+ * telemetry — and production stores should encrypt them according to the application's data policy.
6
+ */
7
+ import { emitEffectLifecycle } from "./effect-lifecycle.js";
8
+ import { createEffectScope } from "./effect-scope.js";
9
+ import { validCapabilityId } from "./internal/capability-runtime.js";
10
+ import { safeEqual } from "./internal/safe-equal.js";
11
+ const TOKEN = /^[!-~]+$/;
12
+ const ERROR_CODE = /^[a-z][a-z0-9_.-]{0,63}$/;
13
+ const encoder = new TextEncoder();
14
+ const decoder = new TextDecoder();
15
+ function assertToken(value, label, maxLength = 255) {
16
+ if (typeof value !== "string" ||
17
+ value.length === 0 ||
18
+ value.length > maxLength ||
19
+ !TOKEN.test(value)) {
20
+ throw new TypeError(`${label} must be a bounded printable token`);
21
+ }
22
+ }
23
+ function assertPositiveMs(value, label) {
24
+ if (!Number.isSafeInteger(value) || value < 1)
25
+ throw new RangeError(`${label} must be a positive safe integer`);
26
+ }
27
+ function assertTimestamp(value, label) {
28
+ if (!Number.isSafeInteger(value) || value < 0)
29
+ throw new RangeError(`${label} must be a non-negative safe integer timestamp`);
30
+ }
31
+ function readClock(clock, label) {
32
+ const value = clock();
33
+ assertTimestamp(value, label);
34
+ return value;
35
+ }
36
+ function addDuration(timestamp, duration, label) {
37
+ const result = timestamp + duration;
38
+ assertTimestamp(result, label);
39
+ return result;
40
+ }
41
+ function bytesToBase64Url(bytes) {
42
+ let binary = "";
43
+ for (const byte of bytes)
44
+ binary += String.fromCharCode(byte);
45
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
46
+ }
47
+ function base64UrlToBytes(value) {
48
+ if (!/^[A-Za-z0-9_-]+$/u.test(value))
49
+ throw new ApprovalTokenInvalidError();
50
+ const padded = value
51
+ .replaceAll("-", "+")
52
+ .replaceAll("_", "/")
53
+ .padEnd(Math.ceil(value.length / 4) * 4, "=");
54
+ let binary;
55
+ try {
56
+ binary = atob(padded);
57
+ }
58
+ catch {
59
+ throw new ApprovalTokenInvalidError();
60
+ }
61
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
62
+ }
63
+ async function sha256(value) {
64
+ return bytesToBase64Url(new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value))));
65
+ }
66
+ function cloneValue(value) {
67
+ return structuredClone(value);
68
+ }
69
+ export class DurableEffectTransitionError extends Error {
70
+ effectId;
71
+ transition;
72
+ constructor(effectId, transition) {
73
+ super(`durable effect ${effectId}: ${transition} transition was rejected`);
74
+ this.effectId = effectId;
75
+ this.transition = transition;
76
+ this.name = "DurableEffectTransitionError";
77
+ }
78
+ }
79
+ export function createDurableEffectJournal(options) {
80
+ if (options.store.durability !== "durable" && options.allowMemoryStore !== true) {
81
+ throw new TypeError('durable effect journal requires store.durability === "durable"');
82
+ }
83
+ const clock = options.now ?? Date.now;
84
+ const now = () => readClock(clock, "durable effect clock");
85
+ const current = async (effectId) => {
86
+ assertToken(effectId, "durable effect id", 64);
87
+ const record = await options.store.get(effectId);
88
+ if (record === undefined)
89
+ throw new DurableEffectTransitionError(effectId, "missing");
90
+ if (record.effectId !== effectId ||
91
+ !validCapabilityId(record.capability) ||
92
+ !["admission", "executing", "committed", "failed", "unknown"].includes(record.state) ||
93
+ !Number.isSafeInteger(record.version) ||
94
+ record.version < 1) {
95
+ throw new TypeError(`durable effect ${effectId}: store returned an invalid record`);
96
+ }
97
+ assertTimestamp(record.createdAt, "durable effect createdAt");
98
+ assertTimestamp(record.updatedAt, "durable effect updatedAt");
99
+ if (record.createdAt > record.updatedAt)
100
+ throw new TypeError(`durable effect ${effectId}: updatedAt precedes createdAt`);
101
+ if (record.target !== undefined)
102
+ assertToken(record.target, "stored durable effect target", 128);
103
+ if (record.digest !== undefined && !/^[0-9a-f]{64}$/u.test(record.digest))
104
+ throw new TypeError(`durable effect ${effectId}: store returned an invalid digest`);
105
+ if (record.tenantId !== undefined)
106
+ assertToken(record.tenantId, "stored durable effect tenantId");
107
+ if (record.principalId !== undefined)
108
+ assertToken(record.principalId, "stored durable effect principalId");
109
+ if ((record.tenantId === undefined) !== (record.principalId === undefined))
110
+ throw new TypeError(`durable effect ${effectId}: store returned a partial identity`);
111
+ if (record.errorCode !== undefined && !ERROR_CODE.test(record.errorCode))
112
+ throw new TypeError(`durable effect ${effectId}: store returned an invalid error code`);
113
+ return record;
114
+ };
115
+ const transition = async (effectId, from, to, errorCode) => {
116
+ const record = await current(effectId);
117
+ if (record.state !== from)
118
+ throw new DurableEffectTransitionError(effectId, `${from}->${to}`);
119
+ const accepted = await options.store.transition({
120
+ effectId,
121
+ version: record.version,
122
+ from,
123
+ to,
124
+ updatedAt: now(),
125
+ ...(errorCode === undefined ? {} : { errorCode }),
126
+ });
127
+ if (!accepted)
128
+ throw new DurableEffectTransitionError(effectId, `${from}->${to}`);
129
+ };
130
+ const journal = {
131
+ async intent(input) {
132
+ assertToken(input.effectId, "durable effect id", 64);
133
+ if (!validCapabilityId(input.capability))
134
+ throw new TypeError("durable effect capability is invalid");
135
+ if (input.target !== undefined)
136
+ assertToken(input.target, "durable effect target", 128);
137
+ if (input.digest !== undefined && !/^[0-9a-f]{64}$/u.test(input.digest))
138
+ throw new TypeError("durable effect digest is invalid");
139
+ if (input.identity !== undefined) {
140
+ assertToken(input.identity.tenantId, "durable effect tenantId");
141
+ assertToken(input.identity.principalId, "durable effect principalId");
142
+ }
143
+ const at = now();
144
+ const accepted = await options.store.create(Object.freeze({
145
+ effectId: input.effectId,
146
+ capability: input.capability,
147
+ ...(input.target === undefined ? {} : { target: input.target }),
148
+ ...(input.digest === undefined ? {} : { digest: input.digest }),
149
+ ...(input.identity === undefined
150
+ ? {}
151
+ : { tenantId: input.identity.tenantId, principalId: input.identity.principalId }),
152
+ state: "admission",
153
+ createdAt: at,
154
+ updatedAt: at,
155
+ version: 1,
156
+ }));
157
+ if (!accepted)
158
+ throw new DurableEffectTransitionError(input.effectId, "create");
159
+ },
160
+ async executing(effectId) {
161
+ await transition(effectId, "admission", "executing");
162
+ },
163
+ async committed(effectId) {
164
+ await transition(effectId, "executing", "committed");
165
+ },
166
+ async failed(effectId, input) {
167
+ if (!ERROR_CODE.test(input.errorCode))
168
+ throw new TypeError("durable effect errorCode is invalid");
169
+ const record = await current(effectId);
170
+ const to = input.began ? "unknown" : "failed";
171
+ const accepted = await options.store.transition({
172
+ effectId,
173
+ version: record.version,
174
+ from: record.state,
175
+ to,
176
+ updatedAt: now(),
177
+ errorCode: input.errorCode,
178
+ });
179
+ if (!accepted)
180
+ throw new DurableEffectTransitionError(effectId, `${record.state}->${to}`);
181
+ },
182
+ };
183
+ return Object.freeze(journal);
184
+ }
185
+ export class MemoryDurableEffectStore {
186
+ durability = "memory";
187
+ records = new Map();
188
+ // Secondary index: effectIds grouped by state, so reconciliation scans only the requested (few,
189
+ // non-terminal) states instead of walking every retained record. See {@link indexedScan}.
190
+ byState = new Map();
191
+ create(record) {
192
+ if (this.records.has(record.effectId))
193
+ return false;
194
+ this.records.set(record.effectId, Object.freeze(cloneValue(record)));
195
+ bucketAdd(this.byState, record.state, record.effectId);
196
+ return true;
197
+ }
198
+ get(effectId) {
199
+ const record = this.records.get(effectId);
200
+ return record === undefined ? undefined : Object.freeze(cloneValue(record));
201
+ }
202
+ transition(input) {
203
+ const current = this.records.get(input.effectId);
204
+ if (current === undefined || current.version !== input.version || current.state !== input.from)
205
+ return false;
206
+ this.records.set(input.effectId, Object.freeze({
207
+ ...current,
208
+ state: input.to,
209
+ updatedAt: input.updatedAt,
210
+ version: current.version + 1,
211
+ ...(input.errorCode === undefined ? {} : { errorCode: input.errorCode }),
212
+ }));
213
+ bucketMove(this.byState, current.state, input.to, input.effectId);
214
+ return true;
215
+ }
216
+ list() {
217
+ return Object.freeze([...this.records.values()].map((record) => Object.freeze(cloneValue(record))));
218
+ }
219
+ scan(input) {
220
+ return indexedScan(this.byState, (id) => this.records.get(id), input);
221
+ }
222
+ }
223
+ const DEFAULT_RECONCILIATION_LIMIT = 100;
224
+ const MAX_RECONCILIATION_LIMIT = 1_000;
225
+ function reconciliationLimit(value = DEFAULT_RECONCILIATION_LIMIT) {
226
+ if (!Number.isSafeInteger(value) || value < 1 || value > MAX_RECONCILIATION_LIMIT) {
227
+ throw new RangeError(`reconciliation limit must be between 1 and ${MAX_RECONCILIATION_LIMIT}`);
228
+ }
229
+ return value;
230
+ }
231
+ function cursorOffset(cursor) {
232
+ if (cursor === undefined)
233
+ return 0;
234
+ if (!/^(?:0|[1-9][0-9]*)$/u.test(cursor))
235
+ throw new TypeError("invalid reconciliation cursor");
236
+ const offset = Number(cursor);
237
+ if (!Number.isSafeInteger(offset))
238
+ throw new TypeError("invalid reconciliation cursor");
239
+ return offset;
240
+ }
241
+ function memoryScan(source, input) {
242
+ const states = new Set(input.states);
243
+ const offset = cursorOffset(input.cursor);
244
+ const end = offset + input.limit;
245
+ const records = [];
246
+ // Stream the source: skip the first `offset` eligible records, collect up to `limit`, and stop as
247
+ // soon as one more eligible record proves a next page exists. Bounds allocation to the page and
248
+ // avoids materializing the whole store when the page fills early. (An unindexed in-memory store still
249
+ // walks its records to find sparse matches; a production store implements `scan()` as an indexed
250
+ // query - state + updatedAt predicate, keyset cursor - instead of relying on this reference scan.)
251
+ let seen = 0;
252
+ let more = false;
253
+ for (const record of source) {
254
+ if (!states.has(record.state))
255
+ continue;
256
+ if (input.updatedBefore !== undefined && record.updatedAt > input.updatedBefore)
257
+ continue;
258
+ if (seen >= end) {
259
+ more = true;
260
+ break;
261
+ }
262
+ if (seen >= offset)
263
+ records.push(cloneValue(record));
264
+ seen++;
265
+ }
266
+ return Object.freeze({
267
+ records: Object.freeze(records),
268
+ ...(more ? { cursor: String(offset + records.length) } : {}),
269
+ });
270
+ }
271
+ function bucketAdd(byState, state, id) {
272
+ let bucket = byState.get(state);
273
+ if (bucket === undefined) {
274
+ bucket = new Set();
275
+ byState.set(state, bucket);
276
+ }
277
+ bucket.add(id);
278
+ }
279
+ function bucketMove(byState, from, to, id) {
280
+ if (from === to)
281
+ return;
282
+ byState.get(from)?.delete(id);
283
+ bucketAdd(byState, to, id);
284
+ }
285
+ /**
286
+ * Paginate a state-bucketed in-memory index: walk only the requested states' buckets (each a Set of
287
+ * ids in insertion order), resolve + `updatedBefore`-filter each record, and page by offset cursor.
288
+ * O(sum of the requested buckets) instead of O(total records) - the store still retains terminal
289
+ * records, but reconciliation never walks them. Matches {@link memoryScan}'s paging semantics.
290
+ */
291
+ function indexedScan(byState, resolve, input) {
292
+ const offset = cursorOffset(input.cursor);
293
+ const end = offset + input.limit;
294
+ const records = [];
295
+ let seen = 0;
296
+ let more = false;
297
+ for (const state of new Set(input.states)) {
298
+ if (more)
299
+ break;
300
+ const bucket = byState.get(state);
301
+ if (bucket === undefined)
302
+ continue;
303
+ for (const id of bucket) {
304
+ const record = resolve(id);
305
+ if (record === undefined)
306
+ continue;
307
+ if (input.updatedBefore !== undefined && record.updatedAt > input.updatedBefore)
308
+ continue;
309
+ if (seen >= end) {
310
+ more = true;
311
+ break;
312
+ }
313
+ if (seen >= offset)
314
+ records.push(cloneValue(record));
315
+ seen++;
316
+ }
317
+ }
318
+ return Object.freeze({
319
+ records: Object.freeze(records),
320
+ ...(more ? { cursor: String(offset + records.length) } : {}),
321
+ });
322
+ }
323
+ async function scanEffectRecords(store, input) {
324
+ if (store.scan !== undefined)
325
+ return await store.scan(input);
326
+ if (store.list === undefined)
327
+ throw new TypeError("effect store does not support reconciliation");
328
+ return memoryScan(await store.list(), input);
329
+ }
330
+ export async function reconcileEffectsPage(store, options) {
331
+ const findings = [];
332
+ const page = await scanEffectRecords(store, {
333
+ states: ["admission", "executing", "unknown"],
334
+ updatedBefore: options.staleBefore,
335
+ ...(options.cursor === undefined ? {} : { cursor: options.cursor }),
336
+ limit: reconciliationLimit(options.limit),
337
+ });
338
+ for (const record of page.records) {
339
+ if (!validCapabilityId(record.capability) || !/^[!-~]{1,64}$/u.test(record.effectId))
340
+ continue;
341
+ if (record.updatedAt > options.staleBefore)
342
+ continue;
343
+ const state = record.state === "admission"
344
+ ? "incomplete"
345
+ : record.state === "executing" || record.state === "unknown"
346
+ ? "ambiguous"
347
+ : undefined;
348
+ if (state === undefined)
349
+ continue;
350
+ findings.push(Object.freeze({
351
+ effectId: record.effectId,
352
+ capability: record.capability,
353
+ state,
354
+ updatedAt: record.updatedAt,
355
+ }));
356
+ if (options.observer !== undefined) {
357
+ emitEffectLifecycle([options.observer], {
358
+ effectId: record.effectId,
359
+ capability: record.capability,
360
+ stage: "reconciliation",
361
+ phase: state === "ambiguous" ? "ambiguous" : "failed",
362
+ errorCode: state === "ambiguous" ? "effect_ambiguous" : "effect_incomplete",
363
+ });
364
+ }
365
+ }
366
+ return Object.freeze({
367
+ findings: Object.freeze(findings),
368
+ ...(page.cursor === undefined ? {} : { cursor: page.cursor }),
369
+ });
370
+ }
371
+ export async function reconcileEffects(store, options) {
372
+ const page = await reconcileEffectsPage(store, {
373
+ ...options,
374
+ limit: MAX_RECONCILIATION_LIMIT,
375
+ });
376
+ if (page.cursor !== undefined) {
377
+ throw new RangeError(`effect reconciliation exceeds ${MAX_RECONCILIATION_LIMIT} records; use reconcileEffectsPage()`);
378
+ }
379
+ return page.findings;
380
+ }
381
+ export class ApprovalRequiredError extends Error {
382
+ approvalId;
383
+ effectId;
384
+ resumeToken;
385
+ expiresAt;
386
+ constructor(approvalId, effectId, resumeToken, expiresAt) {
387
+ super("capability approval is required");
388
+ this.approvalId = approvalId;
389
+ this.effectId = effectId;
390
+ this.expiresAt = expiresAt;
391
+ Object.defineProperty(this, "resumeToken", {
392
+ value: resumeToken,
393
+ enumerable: false,
394
+ configurable: false,
395
+ writable: false,
396
+ });
397
+ this.name = "ApprovalRequiredError";
398
+ }
399
+ }
400
+ export class ApprovalTokenInvalidError extends Error {
401
+ constructor() {
402
+ super("approval resume token is invalid");
403
+ this.name = "ApprovalTokenInvalidError";
404
+ }
405
+ }
406
+ export class ApprovalTokenExpiredError extends Error {
407
+ constructor() {
408
+ super("approval resume token has expired");
409
+ this.name = "ApprovalTokenExpiredError";
410
+ }
411
+ }
412
+ export class ApprovalTokenReplayError extends Error {
413
+ constructor() {
414
+ super("approval resume token has already been consumed");
415
+ this.name = "ApprovalTokenReplayError";
416
+ }
417
+ }
418
+ export class ApprovalBindingError extends Error {
419
+ constructor() {
420
+ super("approval resume token is bound to another tenant or principal");
421
+ this.name = "ApprovalBindingError";
422
+ }
423
+ }
424
+ export class ApprovalPendingError extends Error {
425
+ constructor() {
426
+ super("capability approval is still pending");
427
+ this.name = "ApprovalPendingError";
428
+ }
429
+ }
430
+ export class ApprovalDeniedError extends Error {
431
+ constructor() {
432
+ super("capability approval was denied");
433
+ this.name = "ApprovalDeniedError";
434
+ }
435
+ }
436
+ export function createApprovalCoordinator(options) {
437
+ if (options.store.durability !== "durable" && options.allowMemoryStore !== true) {
438
+ throw new TypeError('approval coordinator requires store.durability === "durable"');
439
+ }
440
+ if (!(options.secret instanceof Uint8Array) || options.secret.byteLength < 32) {
441
+ throw new TypeError("approval secret must contain at least 32 random bytes");
442
+ }
443
+ const secret = new Uint8Array(options.secret);
444
+ const ttlMs = options.ttlMs ?? 15 * 60_000;
445
+ assertPositiveMs(ttlMs, "approval ttlMs");
446
+ const clock = options.now ?? Date.now;
447
+ const now = () => readClock(clock, "approval clock");
448
+ const key = crypto.subtle.importKey("raw", secret, { name: "HMAC", hash: "SHA-256" }, false, [
449
+ "sign",
450
+ "verify",
451
+ ]);
452
+ const sign = async (claims) => {
453
+ const payload = bytesToBase64Url(encoder.encode(JSON.stringify(claims)));
454
+ const signature = new Uint8Array(await crypto.subtle.sign("HMAC", await key, encoder.encode(payload)));
455
+ return `v1.${payload}.${bytesToBase64Url(signature)}`;
456
+ };
457
+ const verify = async (token) => {
458
+ if (typeof token !== "string" || token.length > 2048)
459
+ throw new ApprovalTokenInvalidError();
460
+ const parts = token.split(".");
461
+ if (parts.length !== 3 || parts[0] !== "v1")
462
+ throw new ApprovalTokenInvalidError();
463
+ const payload = parts[1];
464
+ const valid = await crypto.subtle.verify("HMAC", await key, base64UrlToBytes(parts[2]).buffer, encoder.encode(payload));
465
+ if (!valid)
466
+ throw new ApprovalTokenInvalidError();
467
+ let claims;
468
+ try {
469
+ claims = JSON.parse(decoder.decode(base64UrlToBytes(payload)));
470
+ }
471
+ catch {
472
+ throw new ApprovalTokenInvalidError();
473
+ }
474
+ if (typeof claims !== "object" || claims === null)
475
+ throw new ApprovalTokenInvalidError();
476
+ const candidate = claims;
477
+ if (candidate.v !== 1 ||
478
+ typeof candidate.id !== "string" ||
479
+ typeof candidate.nonce !== "string" ||
480
+ !Number.isSafeInteger(candidate.exp)) {
481
+ throw new ApprovalTokenInvalidError();
482
+ }
483
+ assertToken(candidate.id, "approval id", 128);
484
+ assertToken(candidate.nonce, "approval nonce", 128);
485
+ if (candidate.exp <= now())
486
+ throw new ApprovalTokenExpiredError();
487
+ return candidate;
488
+ };
489
+ const coordinator = {
490
+ async authorize(input) {
491
+ if (input.signal.aborted)
492
+ throw input.signal.reason;
493
+ assertToken(input.identity.tenantId, "approval tenantId");
494
+ assertToken(input.identity.principalId, "approval principalId");
495
+ if (input.resumeToken === undefined) {
496
+ const approvalId = crypto.randomUUID();
497
+ const nonce = bytesToBase64Url(crypto.getRandomValues(new Uint8Array(18)));
498
+ const issuedAt = now();
499
+ const expiresAt = addDuration(issuedAt, ttlMs, "approval expiry");
500
+ const resumeToken = await sign({ v: 1, id: approvalId, nonce, exp: expiresAt });
501
+ const accepted = await options.store.create(Object.freeze({
502
+ approvalId,
503
+ effectId: input.effectId,
504
+ capability: input.capability,
505
+ ...(input.target === undefined ? {} : { target: input.target }),
506
+ ...(input.digest === undefined ? {} : { digest: input.digest }),
507
+ tenantId: input.identity.tenantId,
508
+ principalId: input.identity.principalId,
509
+ tokenHash: await sha256(resumeToken),
510
+ state: "pending",
511
+ createdAt: issuedAt,
512
+ expiresAt,
513
+ updatedAt: issuedAt,
514
+ version: 1,
515
+ }));
516
+ if (!accepted)
517
+ throw new Error("approval store rejected a unique approval id");
518
+ throw new ApprovalRequiredError(approvalId, input.effectId, resumeToken, expiresAt);
519
+ }
520
+ const claims = await verify(input.resumeToken);
521
+ const consumed = await options.store.consume({
522
+ approvalId: claims.id,
523
+ tenantId: input.identity.tenantId,
524
+ principalId: input.identity.principalId,
525
+ capability: input.capability,
526
+ ...(input.target === undefined ? {} : { target: input.target }),
527
+ ...(input.digest === undefined ? {} : { digest: input.digest }),
528
+ tokenHash: await sha256(input.resumeToken),
529
+ now: now(),
530
+ });
531
+ if (consumed.state === "consumed")
532
+ return;
533
+ if (consumed.state === "replay")
534
+ throw new ApprovalTokenReplayError();
535
+ if (consumed.state === "binding")
536
+ throw new ApprovalBindingError();
537
+ if (consumed.state === "expired")
538
+ throw new ApprovalTokenExpiredError();
539
+ if (consumed.state === "pending")
540
+ throw new ApprovalPendingError();
541
+ if (consumed.state === "denied")
542
+ throw new ApprovalDeniedError();
543
+ throw new ApprovalTokenInvalidError();
544
+ },
545
+ async decide(input) {
546
+ assertToken(input.approvalId, "approval id", 128);
547
+ assertToken(input.tenantId, "approval tenantId");
548
+ assertToken(input.decidedBy, "approval decidedBy");
549
+ const accepted = await options.store.decide({ ...input, now: now() });
550
+ if (!accepted)
551
+ throw new Error("approval decision rejected");
552
+ },
553
+ async get(approvalId) {
554
+ assertToken(approvalId, "approval id", 128);
555
+ return await options.store.get(approvalId);
556
+ },
557
+ };
558
+ return Object.freeze(coordinator);
559
+ }
560
+ export class MemoryApprovalStore {
561
+ durability = "memory";
562
+ records = new Map();
563
+ create(record) {
564
+ if (this.records.has(record.approvalId))
565
+ return false;
566
+ this.records.set(record.approvalId, Object.freeze(cloneValue(record)));
567
+ return true;
568
+ }
569
+ get(approvalId) {
570
+ const record = this.records.get(approvalId);
571
+ return record === undefined ? undefined : Object.freeze(cloneValue(record));
572
+ }
573
+ decide(input) {
574
+ const record = this.records.get(input.approvalId);
575
+ if (record === undefined ||
576
+ record.tenantId !== input.tenantId ||
577
+ record.state !== "pending" ||
578
+ record.expiresAt <= input.now)
579
+ return false;
580
+ this.records.set(input.approvalId, Object.freeze({
581
+ ...record,
582
+ state: input.decision,
583
+ decidedBy: input.decidedBy,
584
+ updatedAt: input.now,
585
+ version: record.version + 1,
586
+ }));
587
+ return true;
588
+ }
589
+ consume(input) {
590
+ const record = this.records.get(input.approvalId);
591
+ if (record === undefined)
592
+ return { state: "missing" };
593
+ if (record.tenantId !== input.tenantId ||
594
+ record.principalId !== input.principalId ||
595
+ record.capability !== input.capability ||
596
+ record.target !== input.target ||
597
+ !safeEqual(record.digest, input.digest))
598
+ return { state: "binding" };
599
+ if (!safeEqual(record.tokenHash, input.tokenHash))
600
+ return { state: "token" };
601
+ if (record.state === "consumed")
602
+ return { state: "replay" };
603
+ if (record.expiresAt <= input.now || record.state === "expired") {
604
+ if (record.state !== "expired")
605
+ this.records.set(input.approvalId, Object.freeze({
606
+ ...record,
607
+ state: "expired",
608
+ updatedAt: input.now,
609
+ version: record.version + 1,
610
+ }));
611
+ return { state: "expired" };
612
+ }
613
+ if (record.state === "pending")
614
+ return { state: "pending" };
615
+ if (record.state === "denied")
616
+ return { state: "denied" };
617
+ if (record.state !== "approved")
618
+ return { state: "token" };
619
+ this.records.set(input.approvalId, Object.freeze({
620
+ ...record,
621
+ state: "consumed",
622
+ updatedAt: input.now,
623
+ version: record.version + 1,
624
+ }));
625
+ return { state: "consumed" };
626
+ }
627
+ list() {
628
+ return Object.freeze([...this.records.values()].map((record) => Object.freeze(cloneValue(record))));
629
+ }
630
+ }
631
+ function assertSagaDefinition(definition) {
632
+ if (typeof definition !== "object" || definition === null)
633
+ throw new TypeError("saga definition must be an object");
634
+ assertToken(definition.name, "saga definition", 128);
635
+ if (definition.capability !== undefined && !validCapabilityId(definition.capability))
636
+ throw new TypeError("saga capability is invalid");
637
+ if (typeof definition.run !== "function")
638
+ throw new TypeError("saga run must be a function");
639
+ if (typeof definition.compensators !== "object" ||
640
+ definition.compensators === null ||
641
+ Array.isArray(definition.compensators)) {
642
+ throw new TypeError("saga compensators must be an object");
643
+ }
644
+ for (const [name, compensator] of Object.entries(definition.compensators)) {
645
+ assertToken(name, "saga compensator name", 128);
646
+ if (typeof compensator !== "function")
647
+ throw new TypeError(`saga compensator ${name} must be a function`);
648
+ }
649
+ if (definition.retry !== undefined) {
650
+ if (typeof definition.retry !== "object" || definition.retry === null)
651
+ throw new TypeError("saga retry must be an object");
652
+ if (definition.retry.maxAttempts !== undefined)
653
+ assertPositiveMs(definition.retry.maxAttempts, "saga maxAttempts");
654
+ if (definition.retry.backoffMs !== undefined &&
655
+ typeof definition.retry.backoffMs !== "function")
656
+ throw new TypeError("saga backoffMs must be a function");
657
+ }
658
+ }
659
+ export function defineSaga(definition) {
660
+ assertSagaDefinition(definition);
661
+ return Object.freeze({
662
+ ...definition,
663
+ compensators: Object.freeze({ ...definition.compensators }),
664
+ ...(definition.retry === undefined ? {} : { retry: Object.freeze({ ...definition.retry }) }),
665
+ });
666
+ }
667
+ export class SagaConcurrencyError extends Error {
668
+ sagaId;
669
+ constructor(sagaId) {
670
+ super(`saga ${sagaId}: concurrent transition rejected`);
671
+ this.sagaId = sagaId;
672
+ this.name = "SagaConcurrencyError";
673
+ }
674
+ }
675
+ export class SagaAmbiguousStepError extends Error {
676
+ sagaId;
677
+ step;
678
+ effectId;
679
+ constructor(sagaId, step, effectId) {
680
+ super(`saga ${sagaId}: step ${step} has an ambiguous effect`);
681
+ this.sagaId = sagaId;
682
+ this.step = step;
683
+ this.effectId = effectId;
684
+ this.name = "SagaAmbiguousStepError";
685
+ }
686
+ }
687
+ export class SagaResolutionError extends Error {
688
+ sagaId;
689
+ step;
690
+ constructor(sagaId, step, message) {
691
+ super(`saga ${sagaId}: step ${step} ${message}`);
692
+ this.sagaId = sagaId;
693
+ this.step = step;
694
+ this.name = "SagaResolutionError";
695
+ }
696
+ }
697
+ /** Throw this only when a provider conclusively proves that no effect committed. */
698
+ export class SagaStepNotCommittedError extends Error {
699
+ code;
700
+ constructor(code = "not_committed") {
701
+ super("saga step conclusively did not commit");
702
+ this.code = code;
703
+ this.name = "SagaStepNotCommittedError";
704
+ if (!ERROR_CODE.test(code))
705
+ throw new TypeError("saga step error code is invalid");
706
+ }
707
+ }
708
+ let neverAbortSignal;
709
+ function signalOrNever(signal) {
710
+ neverAbortSignal ??= new AbortController().signal;
711
+ return signal ?? neverAbortSignal;
712
+ }
713
+ function throwIfAborted(signal) {
714
+ if (signal.aborted) {
715
+ throw signal.reason instanceof Error ? signal.reason : new Error("saga execution aborted");
716
+ }
717
+ }
718
+ export function createSagaEngine(options) {
719
+ if (options.store.durability !== "durable" && options.allowMemoryStore !== true)
720
+ throw new TypeError('saga engine requires store.durability === "durable"');
721
+ const clock = options.now ?? Date.now;
722
+ const now = () => readClock(clock, "saga clock");
723
+ const observer = options.observer;
724
+ const load = async (sagaId) => {
725
+ const record = await options.store.get(sagaId);
726
+ if (record === undefined)
727
+ throw new Error(`saga ${sagaId}: not found`);
728
+ if (record.sagaId !== sagaId ||
729
+ !["running", "compensating", "completed", "compensated", "manual-review"].includes(record.state) ||
730
+ !Array.isArray(record.steps) ||
731
+ !Number.isSafeInteger(record.version) ||
732
+ record.version < 1) {
733
+ throw new TypeError(`saga ${sagaId}: store returned an invalid record`);
734
+ }
735
+ assertToken(record.definition, "stored saga definition", 128);
736
+ assertTimestamp(record.createdAt, "stored saga createdAt");
737
+ assertTimestamp(record.updatedAt, "stored saga updatedAt");
738
+ if (record.createdAt > record.updatedAt)
739
+ throw new TypeError(`saga ${sagaId}: updatedAt precedes createdAt`);
740
+ const names = new Set();
741
+ const effectIds = new Set();
742
+ for (const step of record.steps) {
743
+ assertToken(step.name, "stored saga step", 128);
744
+ assertToken(step.effectId, "stored saga effect id", 64);
745
+ assertToken(step.compensationEffectId, "stored saga compensation effect id", 64);
746
+ if (![
747
+ "executing",
748
+ "committed",
749
+ "failed",
750
+ "ambiguous",
751
+ "compensating",
752
+ "compensation-failed",
753
+ "compensated",
754
+ ].includes(step.state) ||
755
+ !Number.isSafeInteger(step.attempts) ||
756
+ step.attempts < 0 ||
757
+ names.has(step.name) ||
758
+ effectIds.has(step.effectId) ||
759
+ effectIds.has(step.compensationEffectId)) {
760
+ throw new TypeError(`saga ${sagaId}: store returned an invalid step record`);
761
+ }
762
+ names.add(step.name);
763
+ effectIds.add(step.effectId);
764
+ effectIds.add(step.compensationEffectId);
765
+ if (step.nextAttemptAt !== undefined)
766
+ assertTimestamp(step.nextAttemptAt, "stored saga nextAttemptAt");
767
+ if (step.errorCode !== undefined && !ERROR_CODE.test(step.errorCode))
768
+ throw new TypeError(`saga ${sagaId}: store returned an invalid error code`);
769
+ }
770
+ return record;
771
+ };
772
+ const save = async (current, update) => {
773
+ const next = Object.freeze({ ...update, version: current.version + 1 });
774
+ if (!(await options.store.compareAndSet({
775
+ sagaId: current.sagaId,
776
+ version: current.version,
777
+ record: next,
778
+ })))
779
+ throw new SagaConcurrencyError(current.sagaId);
780
+ return next;
781
+ };
782
+ const withStep = (record, index, step, state = record.state) => ({
783
+ ...record,
784
+ state,
785
+ steps: Object.freeze(record.steps.map((candidate, candidateIndex) => candidateIndex === index ? Object.freeze(step) : candidate)),
786
+ updatedAt: now(),
787
+ });
788
+ const effectScope = createEffectScope({
789
+ ...(observer === undefined ? {} : { observers: [observer] }),
790
+ });
791
+ const compensate = async (definition, sagaId, runOptions = {}) => {
792
+ assertSagaDefinition(definition);
793
+ assertToken(sagaId, "saga id", 128);
794
+ const signal = signalOrNever(runOptions.signal);
795
+ throwIfAborted(signal);
796
+ let record = await load(sagaId);
797
+ if (record.definition !== definition.name)
798
+ throw new TypeError("saga definition mismatch");
799
+ if (record.state === "completed" || record.state === "compensated")
800
+ return record;
801
+ if (record.steps.some((step) => step.state === "ambiguous" || step.state === "executing" || step.state === "compensating")) {
802
+ if (record.state !== "manual-review")
803
+ record = await save(record, { ...record, state: "manual-review", updatedAt: now() });
804
+ const ambiguous = record.steps.find((step) => step.state === "ambiguous" || step.state === "executing" || step.state === "compensating");
805
+ throw new SagaAmbiguousStepError(sagaId, ambiguous.name, ambiguous.state === "compensating" ? ambiguous.compensationEffectId : ambiguous.effectId);
806
+ }
807
+ if (record.state !== "compensating")
808
+ record = await save(record, { ...record, state: "compensating", updatedAt: now() });
809
+ const maxAttempts = definition.retry?.maxAttempts ?? 3;
810
+ assertPositiveMs(maxAttempts, "saga maxAttempts");
811
+ const backoff = definition.retry?.backoffMs ??
812
+ ((attempt) => Math.min(60_000, 100 * 2 ** (attempt - 1)));
813
+ for (let index = record.steps.length - 1; index >= 0; index--) {
814
+ throwIfAborted(signal);
815
+ let step = record.steps[index];
816
+ if (step.state === "compensated" || step.state === "failed")
817
+ continue;
818
+ if (step.state !== "committed" && step.state !== "compensation-failed")
819
+ continue;
820
+ if (step.nextAttemptAt !== undefined && step.nextAttemptAt > now())
821
+ return record;
822
+ const compensator = definition.compensators[step.name];
823
+ if (typeof compensator !== "function") {
824
+ record = await save(record, { ...record, state: "manual-review", updatedAt: now() });
825
+ return record;
826
+ }
827
+ if (step.attempts >= maxAttempts) {
828
+ if (record.state !== "manual-review")
829
+ record = await save(record, { ...record, state: "manual-review", updatedAt: now() });
830
+ return record;
831
+ }
832
+ const attempt = step.attempts + 1;
833
+ const { errorCode: _errorCode, nextAttemptAt: _nextAttemptAt, ...stepBase } = step;
834
+ step = { ...stepBase, state: "compensating", attempts: attempt };
835
+ let transitionError;
836
+ try {
837
+ await effectScope.run({
838
+ effectId: step.compensationEffectId,
839
+ capability: definition.capability ?? "saga.compensate",
840
+ stage: "compensation",
841
+ signal,
842
+ attempt,
843
+ failurePhase: () => "failed",
844
+ errorCode: () => "compensation_failed",
845
+ transitions: {
846
+ async executing() {
847
+ record = await save(record, withStep(record, index, step));
848
+ },
849
+ async committed() {
850
+ step = { ...step, state: "compensated" };
851
+ record = await save(record, withStep(record, index, step));
852
+ },
853
+ async failed() {
854
+ const exhausted = attempt >= maxAttempts;
855
+ try {
856
+ const delay = exhausted ? undefined : backoff(attempt);
857
+ if (delay !== undefined && (!Number.isSafeInteger(delay) || delay < 0))
858
+ throw new RangeError("saga backoff must return a non-negative safe integer");
859
+ const failed = {
860
+ ...step,
861
+ state: "compensation-failed",
862
+ errorCode: "compensation_failed",
863
+ ...(delay === undefined
864
+ ? {}
865
+ : { nextAttemptAt: addDuration(now(), delay, "saga next retry") }),
866
+ };
867
+ record = await save(record, withStep(record, index, failed, exhausted ? "manual-review" : "compensating"));
868
+ }
869
+ catch (error) {
870
+ transitionError = error;
871
+ const failed = {
872
+ ...step,
873
+ state: "compensation-failed",
874
+ errorCode: "invalid_retry_policy",
875
+ };
876
+ record = await save(record, withStep(record, index, failed, "manual-review"));
877
+ }
878
+ },
879
+ },
880
+ }, async (owned) => {
881
+ await compensator(cloneValue(step.compensationArgs), {
882
+ effectId: owned.effectId,
883
+ sagaId,
884
+ attempt,
885
+ signal: owned.signal,
886
+ });
887
+ });
888
+ }
889
+ catch {
890
+ if (transitionError !== undefined)
891
+ throw transitionError;
892
+ return record;
893
+ }
894
+ }
895
+ return await save(record, { ...record, state: "compensated", updatedAt: now() });
896
+ };
897
+ const resume = async (definition, sagaId, runOptions = {}) => {
898
+ assertSagaDefinition(definition);
899
+ assertToken(sagaId, "saga id", 128);
900
+ const signal = signalOrNever(runOptions.signal);
901
+ throwIfAborted(signal);
902
+ let record = await load(sagaId);
903
+ if (record.definition !== definition.name)
904
+ throw new TypeError("saga definition mismatch");
905
+ if (record.state !== "running")
906
+ return record;
907
+ const context = {
908
+ async step(name, compensationArgs, execute) {
909
+ throwIfAborted(signal);
910
+ assertToken(name, "saga step", 128);
911
+ if (typeof execute !== "function")
912
+ throw new TypeError("saga step execute must be a function");
913
+ if (typeof definition.compensators[name] !== "function")
914
+ throw new TypeError(`saga step ${name} has no compensator`);
915
+ record = await load(sagaId);
916
+ const existingIndex = record.steps.findIndex((step) => step.name === name);
917
+ if (existingIndex >= 0) {
918
+ const existing = record.steps[existingIndex];
919
+ if (existing.state === "committed")
920
+ return cloneValue(existing.result);
921
+ if (existing.state === "executing" || existing.state === "ambiguous") {
922
+ if (record.state !== "manual-review")
923
+ record = await save(record, { ...record, state: "manual-review", updatedAt: now() });
924
+ throw new SagaAmbiguousStepError(sagaId, name, existing.effectId);
925
+ }
926
+ throw new Error(`saga ${sagaId}: step ${name} cannot resume from ${existing.state}`);
927
+ }
928
+ const effectId = crypto.randomUUID();
929
+ const step = Object.freeze({
930
+ name,
931
+ effectId,
932
+ compensationEffectId: crypto.randomUUID(),
933
+ state: "executing",
934
+ compensationArgs: cloneValue(compensationArgs),
935
+ attempts: 0,
936
+ });
937
+ return await effectScope.run({
938
+ effectId,
939
+ capability: definition.capability ?? "saga.execute",
940
+ stage: "execution",
941
+ signal,
942
+ failurePhase: (error) => error instanceof SagaStepNotCommittedError ? "failed" : "ambiguous",
943
+ errorCode: (error, began) => error instanceof SagaStepNotCommittedError
944
+ ? error.code
945
+ : began
946
+ ? "execution_unknown"
947
+ : "aborted_before_execution",
948
+ transitions: {
949
+ async intent() {
950
+ record = await save(record, {
951
+ ...record,
952
+ steps: Object.freeze([...record.steps, step]),
953
+ updatedAt: now(),
954
+ });
955
+ },
956
+ async committed(_owned, result) {
957
+ record = await load(sagaId);
958
+ const index = record.steps.findIndex((candidate) => candidate.effectId === effectId);
959
+ const committed = {
960
+ ...record.steps[index],
961
+ state: "committed",
962
+ result: cloneValue(result),
963
+ };
964
+ record = await save(record, withStep(record, index, committed));
965
+ },
966
+ async failed(_owned, failure) {
967
+ record = await load(sagaId);
968
+ const index = record.steps.findIndex((candidate) => candidate.effectId === effectId);
969
+ const known = failure.error instanceof SagaStepNotCommittedError || !failure.began;
970
+ const failed = {
971
+ ...record.steps[index],
972
+ state: known ? "failed" : "ambiguous",
973
+ errorCode: failure.error instanceof SagaStepNotCommittedError
974
+ ? failure.error.code
975
+ : failure.began
976
+ ? "execution_unknown"
977
+ : "aborted_before_execution",
978
+ };
979
+ record = await save(record, withStep(record, index, failed, known ? "compensating" : "manual-review"));
980
+ },
981
+ },
982
+ }, (owned) => execute({ effectId: owned.effectId, signal: owned.signal }));
983
+ },
984
+ };
985
+ try {
986
+ throwIfAborted(signal);
987
+ await definition.run(context, cloneValue(record.input));
988
+ record = await load(sagaId);
989
+ if (record.state === "running")
990
+ record = await save(record, { ...record, state: "completed", updatedAt: now() });
991
+ return record;
992
+ }
993
+ catch (error) {
994
+ record = await load(sagaId);
995
+ if (record.state === "manual-review")
996
+ throw error;
997
+ await compensate(definition, sagaId, runOptions);
998
+ throw error;
999
+ }
1000
+ };
1001
+ const resolveAmbiguity = async (definition, sagaId, resolution) => {
1002
+ assertSagaDefinition(definition);
1003
+ assertToken(sagaId, "saga id", 128);
1004
+ if (typeof resolution !== "object" || resolution === null)
1005
+ throw new TypeError("saga resolution must be an object");
1006
+ if ((resolution.kind === "execution" &&
1007
+ resolution.outcome !== "committed" &&
1008
+ resolution.outcome !== "not-committed") ||
1009
+ (resolution.kind === "compensation" &&
1010
+ resolution.outcome !== "compensated" &&
1011
+ resolution.outcome !== "not-compensated") ||
1012
+ (resolution.kind !== "execution" && resolution.kind !== "compensation")) {
1013
+ throw new TypeError("saga resolution kind or outcome is invalid");
1014
+ }
1015
+ assertToken(resolution.step, "saga resolution step", 128);
1016
+ assertToken(resolution.effectId, "saga resolution effect id", 64);
1017
+ let record = await load(sagaId);
1018
+ if (record.definition !== definition.name)
1019
+ throw new TypeError("saga definition mismatch");
1020
+ const index = record.steps.findIndex((step) => step.name === resolution.step);
1021
+ if (index < 0)
1022
+ throw new SagaResolutionError(sagaId, resolution.step, "does not exist");
1023
+ const current = record.steps[index];
1024
+ let step;
1025
+ let state;
1026
+ if (resolution.kind === "execution") {
1027
+ if (current.effectId !== resolution.effectId)
1028
+ throw new SagaResolutionError(sagaId, resolution.step, "execution effect id does not match");
1029
+ if (current.state !== "executing" && current.state !== "ambiguous")
1030
+ throw new SagaResolutionError(sagaId, resolution.step, `is not awaiting an execution resolution (${current.state})`);
1031
+ const { errorCode: _errorCode, result: _result, ...base } = current;
1032
+ if (resolution.outcome === "committed") {
1033
+ step = { ...base, state: "committed", result: cloneValue(resolution.result) };
1034
+ state = "running";
1035
+ }
1036
+ else {
1037
+ const errorCode = resolution.errorCode ?? "not_committed";
1038
+ if (!ERROR_CODE.test(errorCode))
1039
+ throw new TypeError("saga resolution errorCode is invalid");
1040
+ step = { ...base, state: "failed", errorCode };
1041
+ state = "compensating";
1042
+ }
1043
+ }
1044
+ else {
1045
+ if (current.compensationEffectId !== resolution.effectId)
1046
+ throw new SagaResolutionError(sagaId, resolution.step, "compensation effect id does not match");
1047
+ if (current.state !== "compensating")
1048
+ throw new SagaResolutionError(sagaId, resolution.step, `is not awaiting a compensation resolution (${current.state})`);
1049
+ const { errorCode: _errorCode, nextAttemptAt: _nextAttemptAt, ...base } = current;
1050
+ step = {
1051
+ ...base,
1052
+ state: resolution.outcome === "compensated" ? "compensated" : "compensation-failed",
1053
+ };
1054
+ state =
1055
+ resolution.outcome === "not-compensated" &&
1056
+ current.attempts >= (definition.retry?.maxAttempts ?? 3)
1057
+ ? "manual-review"
1058
+ : "compensating";
1059
+ }
1060
+ record = await save(record, withStep(record, index, Object.freeze(step), state));
1061
+ return record;
1062
+ };
1063
+ const engine = {
1064
+ async execute(definition, sagaId, input, runOptions = {}) {
1065
+ assertSagaDefinition(definition);
1066
+ assertToken(sagaId, "saga id", 128);
1067
+ throwIfAborted(signalOrNever(runOptions.signal));
1068
+ const at = now();
1069
+ const accepted = await options.store.create(Object.freeze({
1070
+ sagaId,
1071
+ definition: definition.name,
1072
+ state: "running",
1073
+ input: cloneValue(input),
1074
+ steps: Object.freeze([]),
1075
+ createdAt: at,
1076
+ updatedAt: at,
1077
+ version: 1,
1078
+ }));
1079
+ if (!accepted)
1080
+ throw new Error(`saga ${sagaId}: already exists`);
1081
+ return await resume(definition, sagaId, runOptions);
1082
+ },
1083
+ resume,
1084
+ compensate,
1085
+ resolveAmbiguity,
1086
+ };
1087
+ return Object.freeze(engine);
1088
+ }
1089
+ export class MemorySagaStore {
1090
+ durability = "memory";
1091
+ records = new Map();
1092
+ // Secondary index: sagaIds grouped by state, so reconciliation scans only the requested (few,
1093
+ // non-terminal) states instead of walking every retained record. See {@link indexedScan}.
1094
+ byState = new Map();
1095
+ create(record) {
1096
+ if (this.records.has(record.sagaId))
1097
+ return false;
1098
+ this.records.set(record.sagaId, Object.freeze(cloneValue(record)));
1099
+ bucketAdd(this.byState, record.state, record.sagaId);
1100
+ return true;
1101
+ }
1102
+ get(sagaId) {
1103
+ const record = this.records.get(sagaId);
1104
+ return record === undefined ? undefined : Object.freeze(cloneValue(record));
1105
+ }
1106
+ compareAndSet(input) {
1107
+ const current = this.records.get(input.sagaId);
1108
+ if (current === undefined ||
1109
+ current.version !== input.version ||
1110
+ input.record.version !== input.version + 1)
1111
+ return false;
1112
+ this.records.set(input.sagaId, Object.freeze(cloneValue(input.record)));
1113
+ bucketMove(this.byState, current.state, input.record.state, input.sagaId);
1114
+ return true;
1115
+ }
1116
+ list() {
1117
+ return Object.freeze([...this.records.values()].map((record) => Object.freeze(cloneValue(record))));
1118
+ }
1119
+ scan(input) {
1120
+ return indexedScan(this.byState, (id) => this.records.get(id), input);
1121
+ }
1122
+ }
1123
+ async function scanSagaRecords(store, input) {
1124
+ if (store.scan !== undefined)
1125
+ return await store.scan(input);
1126
+ if (store.list === undefined)
1127
+ throw new TypeError("saga store does not support reconciliation");
1128
+ return memoryScan(await store.list(), input);
1129
+ }
1130
+ export async function reconcileSagasPage(store, options) {
1131
+ const findings = [];
1132
+ const page = await scanSagaRecords(store, {
1133
+ states: ["running", "compensating", "manual-review"],
1134
+ ...(options.cursor === undefined ? {} : { cursor: options.cursor }),
1135
+ limit: reconciliationLimit(options.limit),
1136
+ });
1137
+ for (const record of page.records) {
1138
+ const ambiguous = record.steps.find((step) => step.state === "executing" || step.state === "ambiguous" || step.state === "compensating");
1139
+ if (ambiguous !== undefined) {
1140
+ findings.push(Object.freeze({
1141
+ sagaId: record.sagaId,
1142
+ state: record.state,
1143
+ step: ambiguous.name,
1144
+ effectId: ambiguous.state === "compensating"
1145
+ ? ambiguous.compensationEffectId
1146
+ : ambiguous.effectId,
1147
+ reason: ambiguous.state === "compensating" ? "ambiguous-compensation" : "ambiguous-execution",
1148
+ }));
1149
+ }
1150
+ else if (record.state === "manual-review") {
1151
+ findings.push(Object.freeze({ sagaId: record.sagaId, state: record.state, reason: "manual-review" }));
1152
+ }
1153
+ else if ((record.state === "running" || record.state === "compensating") &&
1154
+ record.updatedAt <= options.staleBefore) {
1155
+ findings.push(Object.freeze({ sagaId: record.sagaId, state: record.state, reason: "stale-running" }));
1156
+ }
1157
+ }
1158
+ return Object.freeze({
1159
+ findings: Object.freeze(findings),
1160
+ ...(page.cursor === undefined ? {} : { cursor: page.cursor }),
1161
+ });
1162
+ }
1163
+ export async function reconcileSagas(store, options) {
1164
+ const page = await reconcileSagasPage(store, {
1165
+ ...options,
1166
+ limit: MAX_RECONCILIATION_LIMIT,
1167
+ });
1168
+ if (page.cursor !== undefined) {
1169
+ throw new RangeError(`saga reconciliation exceeds ${MAX_RECONCILIATION_LIMIT} records; use reconcileSagasPage()`);
1170
+ }
1171
+ return page.findings;
1172
+ }
1173
+ //# sourceMappingURL=durable-execution.js.map