@nifrajs/core 3.1.0 → 3.3.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 (146) hide show
  1. package/README.md +21 -0
  2. package/dist/durable-adapters.d.ts.map +1 -1
  3. package/dist/durable-adapters.js +26 -3
  4. package/dist/durable-adapters.js.map +1 -1
  5. package/dist/durable-approval.d.ts +56 -0
  6. package/dist/durable-approval.d.ts.map +1 -0
  7. package/dist/durable-approval.js +233 -0
  8. package/dist/durable-approval.js.map +1 -0
  9. package/dist/durable-effect.d.ts +42 -0
  10. package/dist/durable-effect.d.ts.map +1 -0
  11. package/dist/durable-effect.js +234 -0
  12. package/dist/durable-effect.js.map +1 -0
  13. package/dist/durable-execution.d.ts +8 -346
  14. package/dist/durable-execution.d.ts.map +1 -1
  15. package/dist/durable-execution.js +7 -1150
  16. package/dist/durable-execution.js.map +1 -1
  17. package/dist/durable-saga.d.ts +94 -0
  18. package/dist/durable-saga.d.ts.map +1 -0
  19. package/dist/durable-saga.js +550 -0
  20. package/dist/durable-saga.js.map +1 -0
  21. package/dist/edge-kit.d.ts +2 -2
  22. package/dist/edge-kit.d.ts.map +1 -1
  23. package/dist/edge-kit.js +2 -2
  24. package/dist/edge-kit.js.map +1 -1
  25. package/dist/index.d.ts +23 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +18 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/internal/durable-approval-protocol.d.ts +1 -1
  30. package/dist/internal/durable-approval-protocol.d.ts.map +1 -1
  31. package/dist/internal/durable-effect-protocol.d.ts +1 -1
  32. package/dist/internal/durable-effect-protocol.d.ts.map +1 -1
  33. package/dist/internal/durable-saga-protocol.d.ts +1 -1
  34. package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
  35. package/dist/internal/durable-shared.d.ts +26 -0
  36. package/dist/internal/durable-shared.d.ts.map +1 -0
  37. package/dist/internal/durable-shared.js +131 -0
  38. package/dist/internal/durable-shared.js.map +1 -0
  39. package/dist/internal/durable-types.d.ts +159 -0
  40. package/dist/internal/durable-types.d.ts.map +1 -0
  41. package/dist/internal/durable-types.js +2 -0
  42. package/dist/internal/durable-types.js.map +1 -0
  43. package/dist/internal/route-compiler.d.ts +61 -0
  44. package/dist/internal/route-compiler.d.ts.map +1 -0
  45. package/dist/internal/route-compiler.js +121 -0
  46. package/dist/internal/route-compiler.js.map +1 -0
  47. package/dist/internal/route-execution.d.ts +6 -4
  48. package/dist/internal/route-execution.d.ts.map +1 -1
  49. package/dist/internal/route-execution.js +2 -17
  50. package/dist/internal/route-execution.js.map +1 -1
  51. package/dist/internal/route-lanes.d.ts +35 -0
  52. package/dist/internal/route-lanes.d.ts.map +1 -0
  53. package/dist/internal/route-lanes.js +150 -0
  54. package/dist/internal/route-lanes.js.map +1 -0
  55. package/dist/internal/route-program.d.ts +67 -0
  56. package/dist/internal/route-program.d.ts.map +1 -0
  57. package/dist/internal/route-program.js +199 -0
  58. package/dist/internal/route-program.js.map +1 -0
  59. package/dist/server/bare-error-lane.d.ts +1 -1
  60. package/dist/server/bare-error-lane.d.ts.map +1 -1
  61. package/dist/server/bare-error-lane.js +3 -3
  62. package/dist/server/bare-error-lane.js.map +1 -1
  63. package/dist/server/body-lane.d.ts +13 -0
  64. package/dist/server/body-lane.d.ts.map +1 -0
  65. package/dist/server/body-lane.js +134 -0
  66. package/dist/server/body-lane.js.map +1 -0
  67. package/dist/server/contract.d.ts +40 -14
  68. package/dist/server/contract.d.ts.map +1 -1
  69. package/dist/server/contract.js +7 -0
  70. package/dist/server/contract.js.map +1 -1
  71. package/dist/server/cookies.d.ts.map +1 -1
  72. package/dist/server/cookies.js +16 -2
  73. package/dist/server/cookies.js.map +1 -1
  74. package/dist/server/plugin.d.ts +2 -2
  75. package/dist/server/plugin.d.ts.map +1 -1
  76. package/dist/server/plugin.js +6 -1
  77. package/dist/server/plugin.js.map +1 -1
  78. package/dist/server/registry.d.ts +40 -12
  79. package/dist/server/registry.d.ts.map +1 -1
  80. package/dist/server/request-context.d.ts +4 -28
  81. package/dist/server/request-context.d.ts.map +1 -1
  82. package/dist/server/request-context.js +10 -185
  83. package/dist/server/request-context.js.map +1 -1
  84. package/dist/server/respond.d.ts.map +1 -1
  85. package/dist/server/respond.js +39 -2
  86. package/dist/server/respond.js.map +1 -1
  87. package/dist/server/runtime-core.d.ts +18 -2
  88. package/dist/server/runtime-core.d.ts.map +1 -1
  89. package/dist/server/runtime-core.js +0 -23
  90. package/dist/server/runtime-core.js.map +1 -1
  91. package/dist/server/server-types.d.ts +3 -0
  92. package/dist/server/server-types.d.ts.map +1 -1
  93. package/dist/server/server.d.ts +113 -84
  94. package/dist/server/server.d.ts.map +1 -1
  95. package/dist/server/server.js +506 -639
  96. package/dist/server/server.js.map +1 -1
  97. package/dist/server/validation.d.ts +5 -0
  98. package/dist/server/validation.d.ts.map +1 -0
  99. package/dist/server/validation.js +13 -0
  100. package/dist/server/validation.js.map +1 -0
  101. package/dist/server/ws-bun.d.ts +11 -3
  102. package/dist/server/ws-bun.d.ts.map +1 -1
  103. package/dist/server/ws-bun.js +42 -12
  104. package/dist/server/ws-bun.js.map +1 -1
  105. package/dist/server/ws-hook.d.ts +3 -2
  106. package/dist/server/ws-hook.d.ts.map +1 -1
  107. package/dist/server.d.ts +2 -2
  108. package/dist/server.d.ts.map +1 -1
  109. package/dist/server.js +1 -1
  110. package/dist/server.js.map +1 -1
  111. package/dist/single-copy.d.ts.map +1 -1
  112. package/dist/single-copy.js +3 -1
  113. package/dist/single-copy.js.map +1 -1
  114. package/package.json +6 -3
  115. package/src/durable-adapters.ts +20 -0
  116. package/src/durable-approval.ts +281 -0
  117. package/src/durable-effect.ts +300 -0
  118. package/src/durable-execution.ts +8 -1627
  119. package/src/durable-saga.ts +744 -0
  120. package/src/edge-kit.ts +2 -2
  121. package/src/index.ts +129 -2
  122. package/src/internal/durable-approval-protocol.ts +1 -1
  123. package/src/internal/durable-effect-protocol.ts +1 -1
  124. package/src/internal/durable-saga-protocol.ts +1 -1
  125. package/src/internal/durable-shared.ts +157 -0
  126. package/src/internal/durable-types.ts +194 -0
  127. package/src/internal/route-compiler.ts +234 -0
  128. package/src/internal/route-execution.ts +34 -84
  129. package/src/internal/route-lanes.ts +221 -0
  130. package/src/internal/route-program.ts +404 -0
  131. package/src/server/bare-error-lane.ts +3 -2
  132. package/src/server/body-lane.ts +203 -0
  133. package/src/server/contract.ts +70 -20
  134. package/src/server/cookies.ts +16 -2
  135. package/src/server/plugin.ts +11 -5
  136. package/src/server/registry.ts +71 -16
  137. package/src/server/request-context.ts +7 -262
  138. package/src/server/respond.ts +45 -2
  139. package/src/server/runtime-core.ts +28 -2
  140. package/src/server/server-types.ts +3 -0
  141. package/src/server/server.ts +1152 -1260
  142. package/src/server/validation.ts +19 -0
  143. package/src/server/ws-bun.ts +44 -11
  144. package/src/server/ws-hook.ts +3 -2
  145. package/src/server.ts +7 -2
  146. package/src/single-copy.ts +3 -1
@@ -0,0 +1,744 @@
1
+ import type { EffectLifecycleObserver } from "./effect-lifecycle.ts"
2
+ import { createEffectScope } from "./effect-scope.ts"
3
+ import { validCapabilityId } from "./internal/capability-runtime.ts"
4
+ import { durableSagaProtocol } from "./internal/durable-saga-protocol.ts"
5
+ import {
6
+ addDuration,
7
+ assertPositiveMs,
8
+ assertTimestamp,
9
+ assertToken,
10
+ bucketAdd,
11
+ bucketMove,
12
+ cloneValue,
13
+ ERROR_CODE,
14
+ indexedScan,
15
+ MAX_RECONCILIATION_LIMIT,
16
+ memoryScan,
17
+ readClock,
18
+ reconciliationLimit,
19
+ } from "./internal/durable-shared.ts"
20
+ import type {
21
+ ReconciliationPage,
22
+ ReconciliationScanOptions,
23
+ ReconciliationScanPage,
24
+ SagaDefinition,
25
+ SagaRecord,
26
+ SagaRunContext,
27
+ SagaState,
28
+ SagaStepRecord,
29
+ SagaStore,
30
+ } from "./internal/durable-types.ts"
31
+
32
+ // Durable saga + compensation state machine
33
+
34
+ function assertSagaDefinition<I, C extends Record<string, unknown>>(
35
+ definition: SagaDefinition<I, C>,
36
+ ): void {
37
+ if (typeof definition !== "object" || definition === null)
38
+ throw new TypeError("saga definition must be an object")
39
+ assertToken(definition.name, "saga definition", 128)
40
+ if (definition.capability !== undefined && !validCapabilityId(definition.capability))
41
+ throw new TypeError("saga capability is invalid")
42
+ if (typeof definition.run !== "function") throw new TypeError("saga run must be a function")
43
+ if (
44
+ typeof definition.compensators !== "object" ||
45
+ definition.compensators === null ||
46
+ Array.isArray(definition.compensators)
47
+ ) {
48
+ throw new TypeError("saga compensators must be an object")
49
+ }
50
+ for (const [name, compensator] of Object.entries(definition.compensators)) {
51
+ assertToken(name, "saga compensator name", 128)
52
+ if (typeof compensator !== "function")
53
+ throw new TypeError(`saga compensator ${name} must be a function`)
54
+ }
55
+ if (definition.retry !== undefined) {
56
+ if (typeof definition.retry !== "object" || definition.retry === null)
57
+ throw new TypeError("saga retry must be an object")
58
+ if (definition.retry.maxAttempts !== undefined)
59
+ assertPositiveMs(definition.retry.maxAttempts, "saga maxAttempts")
60
+ if (
61
+ definition.retry.backoffMs !== undefined &&
62
+ typeof definition.retry.backoffMs !== "function"
63
+ )
64
+ throw new TypeError("saga backoffMs must be a function")
65
+ }
66
+ }
67
+
68
+ export function defineSaga<I, C extends Record<string, unknown>>(
69
+ definition: SagaDefinition<I, C>,
70
+ ): SagaDefinition<I, C> {
71
+ assertSagaDefinition(definition)
72
+ return Object.freeze({
73
+ ...definition,
74
+ compensators: Object.freeze({ ...definition.compensators }),
75
+ ...(definition.retry === undefined ? {} : { retry: Object.freeze({ ...definition.retry }) }),
76
+ })
77
+ }
78
+
79
+ export class SagaConcurrencyError extends Error {
80
+ constructor(public readonly sagaId: string) {
81
+ super(`saga ${sagaId}: concurrent transition rejected`)
82
+ this.name = "SagaConcurrencyError"
83
+ }
84
+ }
85
+ export class SagaAmbiguousStepError extends Error {
86
+ constructor(
87
+ public readonly sagaId: string,
88
+ public readonly step: string,
89
+ public readonly effectId: string,
90
+ ) {
91
+ super(`saga ${sagaId}: step ${step} has an ambiguous effect`)
92
+ this.name = "SagaAmbiguousStepError"
93
+ }
94
+ }
95
+
96
+ export class SagaResolutionError extends Error {
97
+ constructor(
98
+ public readonly sagaId: string,
99
+ public readonly step: string,
100
+ message: string,
101
+ ) {
102
+ super(`saga ${sagaId}: step ${step} ${message}`)
103
+ this.name = "SagaResolutionError"
104
+ }
105
+ }
106
+ /** Throw this only when a provider conclusively proves that no effect committed. */
107
+ export class SagaStepNotCommittedError extends Error {
108
+ constructor(public readonly code = "not_committed") {
109
+ super("saga step conclusively did not commit")
110
+ this.name = "SagaStepNotCommittedError"
111
+ if (!ERROR_CODE.test(code)) throw new TypeError("saga step error code is invalid")
112
+ }
113
+ }
114
+
115
+ export interface SagaEngineOptions {
116
+ readonly store: SagaStore
117
+ readonly now?: () => number
118
+ readonly observer?: EffectLifecycleObserver
119
+ /** Tests/local development only. Production sagas require a durable store. */
120
+ readonly allowMemoryStore?: boolean
121
+ }
122
+
123
+ export type SagaAmbiguityResolution =
124
+ | {
125
+ readonly kind: "execution"
126
+ readonly step: string
127
+ readonly effectId: string
128
+ readonly outcome: "committed"
129
+ /** Provider-confirmed result required by later saga steps. */
130
+ readonly result: unknown
131
+ }
132
+ | {
133
+ readonly kind: "execution"
134
+ readonly step: string
135
+ readonly effectId: string
136
+ readonly outcome: "not-committed"
137
+ readonly errorCode?: string
138
+ }
139
+ | {
140
+ readonly kind: "compensation"
141
+ readonly step: string
142
+ readonly effectId: string
143
+ readonly outcome: "compensated" | "not-compensated"
144
+ }
145
+
146
+ export interface SagaEngine {
147
+ execute<I, C extends Record<string, unknown>>(
148
+ definition: SagaDefinition<I, C>,
149
+ sagaId: string,
150
+ input: I,
151
+ options?: { readonly signal?: AbortSignal },
152
+ ): Promise<SagaRecord>
153
+ resume<I, C extends Record<string, unknown>>(
154
+ definition: SagaDefinition<I, C>,
155
+ sagaId: string,
156
+ options?: { readonly signal?: AbortSignal },
157
+ ): Promise<SagaRecord>
158
+ compensate<I, C extends Record<string, unknown>>(
159
+ definition: SagaDefinition<I, C>,
160
+ sagaId: string,
161
+ options?: { readonly signal?: AbortSignal },
162
+ ): Promise<SagaRecord>
163
+ /**
164
+ * Apply a provider/operator-confirmed outcome to an ambiguous durable transition. The supplied
165
+ * effect id must match the stored execution/compensation id, preventing a stale review from
166
+ * resolving a later operation. The caller remains responsible for authenticating and authorizing
167
+ * the operator. Call `resume` or `compensate` after the resolution.
168
+ */
169
+ resolveAmbiguity<I, C extends Record<string, unknown>>(
170
+ definition: SagaDefinition<I, C>,
171
+ sagaId: string,
172
+ resolution: SagaAmbiguityResolution,
173
+ ): Promise<SagaRecord>
174
+ }
175
+
176
+ let neverAbortSignal: AbortSignal | undefined
177
+ function signalOrNever(signal?: AbortSignal): AbortSignal {
178
+ neverAbortSignal ??= new AbortController().signal
179
+ return signal ?? neverAbortSignal
180
+ }
181
+
182
+ function throwIfAborted(signal: AbortSignal): void {
183
+ if (signal.aborted) {
184
+ throw signal.reason instanceof Error ? signal.reason : new Error("saga execution aborted")
185
+ }
186
+ }
187
+
188
+ export function createSagaEngine(options: SagaEngineOptions): SagaEngine {
189
+ if (options.store.durability !== "durable" && options.allowMemoryStore !== true)
190
+ throw new TypeError('saga engine requires store.durability === "durable"')
191
+ const clock = options.now ?? Date.now
192
+ const now = (): number => readClock(clock, "saga clock")
193
+ const observer = options.observer
194
+
195
+ const load = async (sagaId: string): Promise<SagaRecord> => {
196
+ const record = await options.store.get(sagaId)
197
+ if (record === undefined) throw new Error(`saga ${sagaId}: not found`)
198
+ if (
199
+ record.sagaId !== sagaId ||
200
+ !["running", "compensating", "completed", "compensated", "manual-review"].includes(
201
+ record.state,
202
+ ) ||
203
+ !Array.isArray(record.steps) ||
204
+ !Number.isSafeInteger(record.version) ||
205
+ record.version < 1
206
+ ) {
207
+ throw new TypeError(`saga ${sagaId}: store returned an invalid record`)
208
+ }
209
+ assertToken(record.definition, "stored saga definition", 128)
210
+ assertTimestamp(record.createdAt, "stored saga createdAt")
211
+ assertTimestamp(record.updatedAt, "stored saga updatedAt")
212
+ if (record.createdAt > record.updatedAt)
213
+ throw new TypeError(`saga ${sagaId}: updatedAt precedes createdAt`)
214
+ const names = new Set<string>()
215
+ const effectIds = new Set<string>()
216
+ for (const step of record.steps) {
217
+ assertToken(step.name, "stored saga step", 128)
218
+ assertToken(step.effectId, "stored saga effect id", 64)
219
+ assertToken(step.compensationEffectId, "stored saga compensation effect id", 64)
220
+ if (
221
+ ![
222
+ "executing",
223
+ "committed",
224
+ "failed",
225
+ "ambiguous",
226
+ "compensating",
227
+ "compensation-failed",
228
+ "compensated",
229
+ ].includes(step.state) ||
230
+ !Number.isSafeInteger(step.attempts) ||
231
+ step.attempts < 0 ||
232
+ names.has(step.name) ||
233
+ effectIds.has(step.effectId) ||
234
+ effectIds.has(step.compensationEffectId)
235
+ ) {
236
+ throw new TypeError(`saga ${sagaId}: store returned an invalid step record`)
237
+ }
238
+ names.add(step.name)
239
+ effectIds.add(step.effectId)
240
+ effectIds.add(step.compensationEffectId)
241
+ if (step.nextAttemptAt !== undefined)
242
+ assertTimestamp(step.nextAttemptAt, "stored saga nextAttemptAt")
243
+ if (step.errorCode !== undefined && !ERROR_CODE.test(step.errorCode))
244
+ throw new TypeError(`saga ${sagaId}: store returned an invalid error code`)
245
+ }
246
+ return record
247
+ }
248
+ const save = async (
249
+ current: SagaRecord,
250
+ update: Omit<SagaRecord, "version">,
251
+ ): Promise<SagaRecord> => {
252
+ const next = durableSagaProtocol.transition(current, {
253
+ sagaId: current.sagaId,
254
+ version: current.version,
255
+ record: Object.freeze({ ...update, version: current.version + 1 }),
256
+ })
257
+ if (next === undefined) throw new SagaConcurrencyError(current.sagaId)
258
+ if (
259
+ !(await options.store.compareAndSet({
260
+ sagaId: current.sagaId,
261
+ version: current.version,
262
+ record: next,
263
+ }))
264
+ )
265
+ throw new SagaConcurrencyError(current.sagaId)
266
+ return next
267
+ }
268
+ const withStep = (
269
+ record: SagaRecord,
270
+ index: number,
271
+ step: SagaStepRecord,
272
+ state = record.state,
273
+ ): Omit<SagaRecord, "version"> => durableSagaProtocol.withStep(record, index, step, state, now())
274
+ const effectScope = createEffectScope({
275
+ ...(observer === undefined ? {} : { observers: [observer] }),
276
+ })
277
+
278
+ const compensate = async <I, C extends Record<string, unknown>>(
279
+ definition: SagaDefinition<I, C>,
280
+ sagaId: string,
281
+ runOptions: { readonly signal?: AbortSignal } = {},
282
+ ): Promise<SagaRecord> => {
283
+ assertSagaDefinition(definition)
284
+ assertToken(sagaId, "saga id", 128)
285
+ const signal = signalOrNever(runOptions.signal)
286
+ throwIfAborted(signal)
287
+ let record = await load(sagaId)
288
+ if (record.definition !== definition.name) throw new TypeError("saga definition mismatch")
289
+ if (record.state === "completed" || record.state === "compensated") return record
290
+ if (
291
+ record.steps.some(
292
+ (step) =>
293
+ step.state === "ambiguous" || step.state === "executing" || step.state === "compensating",
294
+ )
295
+ ) {
296
+ if (record.state !== "manual-review")
297
+ record = await save(record, { ...record, state: "manual-review", updatedAt: now() })
298
+ const ambiguous = record.steps.find(
299
+ (step) =>
300
+ step.state === "ambiguous" || step.state === "executing" || step.state === "compensating",
301
+ ) as SagaStepRecord
302
+ throw new SagaAmbiguousStepError(
303
+ sagaId,
304
+ ambiguous.name,
305
+ ambiguous.state === "compensating" ? ambiguous.compensationEffectId : ambiguous.effectId,
306
+ )
307
+ }
308
+ if (record.state !== "compensating")
309
+ record = await save(record, { ...record, state: "compensating", updatedAt: now() })
310
+ const maxAttempts = definition.retry?.maxAttempts ?? 3
311
+ assertPositiveMs(maxAttempts, "saga maxAttempts")
312
+ const backoff =
313
+ definition.retry?.backoffMs ??
314
+ ((attempt: number) => Math.min(60_000, 100 * 2 ** (attempt - 1)))
315
+
316
+ for (let index = record.steps.length - 1; index >= 0; index--) {
317
+ throwIfAborted(signal)
318
+ let step = record.steps[index] as SagaStepRecord
319
+ if (step.state === "compensated" || step.state === "failed") continue
320
+ if (step.state !== "committed" && step.state !== "compensation-failed") continue
321
+ if (step.nextAttemptAt !== undefined && step.nextAttemptAt > now()) return record
322
+ const compensator = definition.compensators[step.name as keyof C]
323
+ if (typeof compensator !== "function") {
324
+ record = await save(record, { ...record, state: "manual-review", updatedAt: now() })
325
+ return record
326
+ }
327
+ if (step.attempts >= maxAttempts) {
328
+ if (record.state !== "manual-review")
329
+ record = await save(record, { ...record, state: "manual-review", updatedAt: now() })
330
+ return record
331
+ }
332
+ const attempt = step.attempts + 1
333
+ const { errorCode: _errorCode, nextAttemptAt: _nextAttemptAt, ...stepBase } = step
334
+ step = { ...stepBase, state: "compensating", attempts: attempt }
335
+ let transitionError: unknown
336
+ try {
337
+ await effectScope.run(
338
+ {
339
+ effectId: step.compensationEffectId,
340
+ capability: definition.capability ?? "saga.compensate",
341
+ stage: "compensation",
342
+ signal,
343
+ attempt,
344
+ failurePhase: () => "failed",
345
+ errorCode: () => "compensation_failed",
346
+ transitions: {
347
+ async executing() {
348
+ record = await save(record, withStep(record, index, step))
349
+ },
350
+ async committed() {
351
+ step = { ...step, state: "compensated" }
352
+ record = await save(record, withStep(record, index, step))
353
+ },
354
+ async failed() {
355
+ const exhausted = attempt >= maxAttempts
356
+ try {
357
+ const delay = exhausted ? undefined : backoff(attempt)
358
+ if (delay !== undefined && (!Number.isSafeInteger(delay) || delay < 0))
359
+ throw new RangeError("saga backoff must return a non-negative safe integer")
360
+ const failed: SagaStepRecord = {
361
+ ...step,
362
+ state: "compensation-failed",
363
+ errorCode: "compensation_failed",
364
+ ...(delay === undefined
365
+ ? {}
366
+ : { nextAttemptAt: addDuration(now(), delay, "saga next retry") }),
367
+ }
368
+ record = await save(
369
+ record,
370
+ withStep(record, index, failed, exhausted ? "manual-review" : "compensating"),
371
+ )
372
+ } catch (error) {
373
+ transitionError = error
374
+ const failed: SagaStepRecord = {
375
+ ...step,
376
+ state: "compensation-failed",
377
+ errorCode: "invalid_retry_policy",
378
+ }
379
+ record = await save(record, withStep(record, index, failed, "manual-review"))
380
+ }
381
+ },
382
+ },
383
+ },
384
+ async (owned) => {
385
+ await compensator(cloneValue(step.compensationArgs) as C[keyof C], {
386
+ effectId: owned.effectId,
387
+ sagaId,
388
+ attempt,
389
+ signal: owned.signal,
390
+ })
391
+ },
392
+ )
393
+ } catch {
394
+ if (transitionError !== undefined) throw transitionError
395
+ return record
396
+ }
397
+ }
398
+ return await save(record, { ...record, state: "compensated", updatedAt: now() })
399
+ }
400
+
401
+ const resume = async <I, C extends Record<string, unknown>>(
402
+ definition: SagaDefinition<I, C>,
403
+ sagaId: string,
404
+ runOptions: { readonly signal?: AbortSignal } = {},
405
+ ): Promise<SagaRecord> => {
406
+ assertSagaDefinition(definition)
407
+ assertToken(sagaId, "saga id", 128)
408
+ const signal = signalOrNever(runOptions.signal)
409
+ throwIfAborted(signal)
410
+ let record = await load(sagaId)
411
+ if (record.definition !== definition.name) throw new TypeError("saga definition mismatch")
412
+ if (record.state !== "running") return record
413
+ const context: SagaRunContext<C> = {
414
+ async step(name, compensationArgs, execute) {
415
+ throwIfAborted(signal)
416
+ assertToken(name, "saga step", 128)
417
+ if (typeof execute !== "function")
418
+ throw new TypeError("saga step execute must be a function")
419
+ if (typeof definition.compensators[name] !== "function")
420
+ throw new TypeError(`saga step ${name} has no compensator`)
421
+ record = await load(sagaId)
422
+ const existingIndex = record.steps.findIndex((step) => step.name === name)
423
+ if (existingIndex >= 0) {
424
+ const existing = record.steps[existingIndex] as SagaStepRecord
425
+ if (existing.state === "committed")
426
+ return cloneValue(existing.result) as Awaited<ReturnType<typeof execute>>
427
+ if (existing.state === "executing" || existing.state === "ambiguous") {
428
+ if (record.state !== "manual-review")
429
+ record = await save(record, { ...record, state: "manual-review", updatedAt: now() })
430
+ throw new SagaAmbiguousStepError(sagaId, name, existing.effectId)
431
+ }
432
+ throw new Error(`saga ${sagaId}: step ${name} cannot resume from ${existing.state}`)
433
+ }
434
+ const effectId = crypto.randomUUID()
435
+ const step: SagaStepRecord = Object.freeze({
436
+ name,
437
+ effectId,
438
+ compensationEffectId: crypto.randomUUID(),
439
+ state: "executing" as const,
440
+ compensationArgs: cloneValue(compensationArgs),
441
+ attempts: 0,
442
+ })
443
+ return await effectScope.run(
444
+ {
445
+ effectId,
446
+ capability: definition.capability ?? "saga.execute",
447
+ stage: "execution",
448
+ signal,
449
+ failurePhase: (error) =>
450
+ error instanceof SagaStepNotCommittedError ? "failed" : "ambiguous",
451
+ errorCode: (error, began) =>
452
+ error instanceof SagaStepNotCommittedError
453
+ ? error.code
454
+ : began
455
+ ? "execution_unknown"
456
+ : "aborted_before_execution",
457
+ transitions: {
458
+ async intent() {
459
+ record = await save(record, {
460
+ ...record,
461
+ steps: Object.freeze([...record.steps, step]),
462
+ updatedAt: now(),
463
+ })
464
+ },
465
+ async committed(_owned, result) {
466
+ record = await load(sagaId)
467
+ const index = record.steps.findIndex((candidate) => candidate.effectId === effectId)
468
+ const committed = {
469
+ ...(record.steps[index] as SagaStepRecord),
470
+ state: "committed" as const,
471
+ result: cloneValue(result),
472
+ }
473
+ record = await save(record, withStep(record, index, committed))
474
+ },
475
+ async failed(_owned, failure) {
476
+ record = await load(sagaId)
477
+ const index = record.steps.findIndex((candidate) => candidate.effectId === effectId)
478
+ const known = failure.error instanceof SagaStepNotCommittedError || !failure.began
479
+ const failed = {
480
+ ...(record.steps[index] as SagaStepRecord),
481
+ state: known ? ("failed" as const) : ("ambiguous" as const),
482
+ errorCode:
483
+ failure.error instanceof SagaStepNotCommittedError
484
+ ? failure.error.code
485
+ : failure.began
486
+ ? "execution_unknown"
487
+ : "aborted_before_execution",
488
+ }
489
+ record = await save(
490
+ record,
491
+ withStep(record, index, failed, known ? "compensating" : "manual-review"),
492
+ )
493
+ },
494
+ },
495
+ },
496
+ (owned) => execute({ effectId: owned.effectId, signal: owned.signal }),
497
+ )
498
+ },
499
+ }
500
+ try {
501
+ throwIfAborted(signal)
502
+ await definition.run(context, cloneValue(record.input) as I)
503
+ record = await load(sagaId)
504
+ if (record.state === "running")
505
+ record = await save(record, { ...record, state: "completed", updatedAt: now() })
506
+ return record
507
+ } catch (error) {
508
+ record = await load(sagaId)
509
+ if (record.state === "manual-review") throw error
510
+ await compensate(definition, sagaId, runOptions)
511
+ throw error
512
+ }
513
+ }
514
+
515
+ const resolveAmbiguity = async <I, C extends Record<string, unknown>>(
516
+ definition: SagaDefinition<I, C>,
517
+ sagaId: string,
518
+ resolution: SagaAmbiguityResolution,
519
+ ): Promise<SagaRecord> => {
520
+ assertSagaDefinition(definition)
521
+ assertToken(sagaId, "saga id", 128)
522
+ if (typeof resolution !== "object" || resolution === null)
523
+ throw new TypeError("saga resolution must be an object")
524
+ if (
525
+ (resolution.kind === "execution" &&
526
+ resolution.outcome !== "committed" &&
527
+ resolution.outcome !== "not-committed") ||
528
+ (resolution.kind === "compensation" &&
529
+ resolution.outcome !== "compensated" &&
530
+ resolution.outcome !== "not-compensated") ||
531
+ (resolution.kind !== "execution" && resolution.kind !== "compensation")
532
+ ) {
533
+ throw new TypeError("saga resolution kind or outcome is invalid")
534
+ }
535
+ assertToken(resolution.step, "saga resolution step", 128)
536
+ assertToken(resolution.effectId, "saga resolution effect id", 64)
537
+ let record = await load(sagaId)
538
+ if (record.definition !== definition.name) throw new TypeError("saga definition mismatch")
539
+ const index = record.steps.findIndex((step) => step.name === resolution.step)
540
+ if (index < 0) throw new SagaResolutionError(sagaId, resolution.step, "does not exist")
541
+ const current = record.steps[index] as SagaStepRecord
542
+
543
+ let step: SagaStepRecord
544
+ let state: SagaState
545
+ if (resolution.kind === "execution") {
546
+ if (current.effectId !== resolution.effectId)
547
+ throw new SagaResolutionError(sagaId, resolution.step, "execution effect id does not match")
548
+ if (current.state !== "executing" && current.state !== "ambiguous")
549
+ throw new SagaResolutionError(
550
+ sagaId,
551
+ resolution.step,
552
+ `is not awaiting an execution resolution (${current.state})`,
553
+ )
554
+ const { errorCode: _errorCode, result: _result, ...base } = current
555
+ if (resolution.outcome === "committed") {
556
+ step = { ...base, state: "committed", result: cloneValue(resolution.result) }
557
+ state = "running"
558
+ } else {
559
+ const errorCode = resolution.errorCode ?? "not_committed"
560
+ if (!ERROR_CODE.test(errorCode)) throw new TypeError("saga resolution errorCode is invalid")
561
+ step = { ...base, state: "failed", errorCode }
562
+ state = "compensating"
563
+ }
564
+ } else {
565
+ if (current.compensationEffectId !== resolution.effectId)
566
+ throw new SagaResolutionError(
567
+ sagaId,
568
+ resolution.step,
569
+ "compensation effect id does not match",
570
+ )
571
+ if (current.state !== "compensating")
572
+ throw new SagaResolutionError(
573
+ sagaId,
574
+ resolution.step,
575
+ `is not awaiting a compensation resolution (${current.state})`,
576
+ )
577
+ const { errorCode: _errorCode, nextAttemptAt: _nextAttemptAt, ...base } = current
578
+ step = {
579
+ ...base,
580
+ state: resolution.outcome === "compensated" ? "compensated" : "compensation-failed",
581
+ }
582
+ state =
583
+ resolution.outcome === "not-compensated" &&
584
+ current.attempts >= (definition.retry?.maxAttempts ?? 3)
585
+ ? "manual-review"
586
+ : "compensating"
587
+ }
588
+ record = await save(record, withStep(record, index, Object.freeze(step), state))
589
+ return record
590
+ }
591
+
592
+ const engine: SagaEngine = {
593
+ async execute<I, C extends Record<string, unknown>>(
594
+ definition: SagaDefinition<I, C>,
595
+ sagaId: string,
596
+ input: I,
597
+ runOptions: { readonly signal?: AbortSignal } = {},
598
+ ) {
599
+ assertSagaDefinition(definition)
600
+ assertToken(sagaId, "saga id", 128)
601
+ throwIfAborted(signalOrNever(runOptions.signal))
602
+ const at = now()
603
+ const accepted = await options.store.create(
604
+ Object.freeze({
605
+ sagaId,
606
+ definition: definition.name,
607
+ state: "running" as const,
608
+ input: cloneValue(input),
609
+ steps: Object.freeze([]),
610
+ createdAt: at,
611
+ updatedAt: at,
612
+ version: 1,
613
+ }),
614
+ )
615
+ if (!accepted) throw new Error(`saga ${sagaId}: already exists`)
616
+ return await resume(definition, sagaId, runOptions)
617
+ },
618
+ resume,
619
+ compensate,
620
+ resolveAmbiguity,
621
+ }
622
+ return Object.freeze(engine)
623
+ }
624
+
625
+ export class MemorySagaStore implements SagaStore {
626
+ readonly durability = "memory" as const
627
+ private readonly records = new Map<string, SagaRecord>()
628
+ // Secondary index: sagaIds grouped by state, so reconciliation scans only the requested (few,
629
+ // non-terminal) states instead of walking every retained record. See {@link indexedScan}.
630
+ private readonly byState = new Map<SagaState, Set<string>>()
631
+ create(record: SagaRecord): boolean {
632
+ if (this.records.has(record.sagaId)) return false
633
+ this.records.set(record.sagaId, Object.freeze(cloneValue(record)))
634
+ bucketAdd(this.byState, record.state, record.sagaId)
635
+ return true
636
+ }
637
+ get(sagaId: string): SagaRecord | undefined {
638
+ const record = this.records.get(sagaId)
639
+ return record === undefined ? undefined : Object.freeze(cloneValue(record))
640
+ }
641
+ compareAndSet(input: Parameters<SagaStore["compareAndSet"]>[0]): boolean {
642
+ const current = this.records.get(input.sagaId)
643
+ const next = durableSagaProtocol.transition(current, input)
644
+ if (next === undefined) return false
645
+ if (current === undefined) return false
646
+ this.records.set(input.sagaId, Object.freeze(next))
647
+ bucketMove(this.byState, current.state, input.record.state, input.sagaId)
648
+ return true
649
+ }
650
+ list(): readonly SagaRecord[] {
651
+ return Object.freeze(
652
+ [...this.records.values()].map((record) => Object.freeze(cloneValue(record))),
653
+ )
654
+ }
655
+
656
+ scan(input: ReconciliationScanOptions<SagaState>): ReconciliationScanPage<SagaRecord> {
657
+ return indexedScan(this.byState, (id) => this.records.get(id), input)
658
+ }
659
+ }
660
+
661
+ export interface SagaReconciliationFinding {
662
+ readonly sagaId: string
663
+ readonly state: SagaState
664
+ readonly step?: string
665
+ readonly effectId?: string
666
+ readonly reason:
667
+ | "stale-running"
668
+ | "ambiguous-execution"
669
+ | "ambiguous-compensation"
670
+ | "manual-review"
671
+ }
672
+
673
+ async function scanSagaRecords(
674
+ store: SagaStore,
675
+ input: ReconciliationScanOptions<SagaState>,
676
+ ): Promise<ReconciliationScanPage<SagaRecord>> {
677
+ if (store.scan !== undefined) return await store.scan(input)
678
+ if (store.list === undefined) throw new TypeError("saga store does not support reconciliation")
679
+ return memoryScan(await store.list(), input)
680
+ }
681
+
682
+ export async function reconcileSagasPage(
683
+ store: SagaStore,
684
+ options: { readonly staleBefore: number; readonly cursor?: string; readonly limit?: number },
685
+ ): Promise<ReconciliationPage<SagaReconciliationFinding>> {
686
+ const findings: SagaReconciliationFinding[] = []
687
+ const page = await scanSagaRecords(store, {
688
+ states: ["running", "compensating", "manual-review"],
689
+ ...(options.cursor === undefined ? {} : { cursor: options.cursor }),
690
+ limit: reconciliationLimit(options.limit),
691
+ })
692
+ for (const record of page.records) {
693
+ const ambiguous = record.steps.find(
694
+ (step) =>
695
+ step.state === "executing" || step.state === "ambiguous" || step.state === "compensating",
696
+ )
697
+ if (ambiguous !== undefined) {
698
+ findings.push(
699
+ Object.freeze({
700
+ sagaId: record.sagaId,
701
+ state: record.state,
702
+ step: ambiguous.name,
703
+ effectId:
704
+ ambiguous.state === "compensating"
705
+ ? ambiguous.compensationEffectId
706
+ : ambiguous.effectId,
707
+ reason:
708
+ ambiguous.state === "compensating" ? "ambiguous-compensation" : "ambiguous-execution",
709
+ }),
710
+ )
711
+ } else if (record.state === "manual-review") {
712
+ findings.push(
713
+ Object.freeze({ sagaId: record.sagaId, state: record.state, reason: "manual-review" }),
714
+ )
715
+ } else if (
716
+ (record.state === "running" || record.state === "compensating") &&
717
+ record.updatedAt <= options.staleBefore
718
+ ) {
719
+ findings.push(
720
+ Object.freeze({ sagaId: record.sagaId, state: record.state, reason: "stale-running" }),
721
+ )
722
+ }
723
+ }
724
+ return Object.freeze({
725
+ findings: Object.freeze(findings),
726
+ ...(page.cursor === undefined ? {} : { cursor: page.cursor }),
727
+ })
728
+ }
729
+
730
+ export async function reconcileSagas(
731
+ store: SagaStore,
732
+ options: { readonly staleBefore: number },
733
+ ): Promise<readonly SagaReconciliationFinding[]> {
734
+ const page = await reconcileSagasPage(store, {
735
+ ...options,
736
+ limit: MAX_RECONCILIATION_LIMIT,
737
+ })
738
+ if (page.cursor !== undefined) {
739
+ throw new RangeError(
740
+ `saga reconciliation exceeds ${MAX_RECONCILIATION_LIMIT} records; use reconcileSagasPage()`,
741
+ )
742
+ }
743
+ return page.findings
744
+ }