@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
@@ -1,1634 +1,15 @@
1
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.
2
+ * Durable capability execution primitives. The public subpath remains the compatibility façade;
3
+ * each domain now owns its implementation while records, transitions, and shared validation helpers
4
+ * live behind explicit internal seams.
6
5
  */
7
6
 
8
- import { type EffectLifecycleObserver, emitEffectLifecycle } from "./effect-lifecycle.ts"
9
- import { createEffectScope } from "./effect-scope.ts"
10
- import type {
7
+ export * from "./durable-approval.ts"
8
+ export * from "./durable-effect.ts"
9
+ export * from "./durable-saga.ts"
10
+ export type {
11
11
  CapabilityApprovalGate,
12
12
  CapabilityExecutionIdentity,
13
13
  CapabilityExecutionJournal,
14
14
  } from "./internal/capability-runtime.ts"
15
- import { validCapabilityId } from "./internal/capability-runtime.ts"
16
- import { durableApprovalProtocol } from "./internal/durable-approval-protocol.ts"
17
- import { durableEffectProtocol } from "./internal/durable-effect-protocol.ts"
18
- import { durableSagaProtocol } from "./internal/durable-saga-protocol.ts"
19
-
20
- export type { CapabilityApprovalGate, CapabilityExecutionIdentity, CapabilityExecutionJournal }
21
-
22
- const TOKEN = /^[!-~]+$/
23
- const ERROR_CODE = /^[a-z][a-z0-9_.-]{0,63}$/
24
- const encoder = new TextEncoder()
25
- const decoder = new TextDecoder()
26
-
27
- function assertToken(value: string, label: string, maxLength = 255): void {
28
- if (
29
- typeof value !== "string" ||
30
- value.length === 0 ||
31
- value.length > maxLength ||
32
- !TOKEN.test(value)
33
- ) {
34
- throw new TypeError(`${label} must be a bounded printable token`)
35
- }
36
- }
37
-
38
- function assertPositiveMs(value: number, label: string): void {
39
- if (!Number.isSafeInteger(value) || value < 1)
40
- throw new RangeError(`${label} must be a positive safe integer`)
41
- }
42
-
43
- function assertTimestamp(value: number, label: string): void {
44
- if (!Number.isSafeInteger(value) || value < 0)
45
- throw new RangeError(`${label} must be a non-negative safe integer timestamp`)
46
- }
47
-
48
- function readClock(clock: () => number, label: string): number {
49
- const value = clock()
50
- assertTimestamp(value, label)
51
- return value
52
- }
53
-
54
- function addDuration(timestamp: number, duration: number, label: string): number {
55
- const result = timestamp + duration
56
- assertTimestamp(result, label)
57
- return result
58
- }
59
-
60
- function bytesToBase64Url(bytes: Uint8Array): string {
61
- let binary = ""
62
- for (const byte of bytes) binary += String.fromCharCode(byte)
63
- return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "")
64
- }
65
-
66
- function base64UrlToBytes(value: string): Uint8Array {
67
- if (!/^[A-Za-z0-9_-]+$/u.test(value)) throw new ApprovalTokenInvalidError()
68
- const padded = value
69
- .replaceAll("-", "+")
70
- .replaceAll("_", "/")
71
- .padEnd(Math.ceil(value.length / 4) * 4, "=")
72
- let binary: string
73
- try {
74
- binary = atob(padded)
75
- } catch {
76
- throw new ApprovalTokenInvalidError()
77
- }
78
- return Uint8Array.from(binary, (character) => character.charCodeAt(0))
79
- }
80
-
81
- async function sha256(value: string): Promise<string> {
82
- return bytesToBase64Url(
83
- new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value))),
84
- )
85
- }
86
-
87
- function cloneValue<T>(value: T): T {
88
- return structuredClone(value)
89
- }
90
-
91
- // -------------------------------------------------------------------------------------------------
92
- // Durable effect journal + reconciliation
93
-
94
- export type DurableEffectState = "admission" | "executing" | "committed" | "failed" | "unknown"
95
-
96
- export interface ReconciliationScanOptions<State extends string> {
97
- readonly states: readonly State[]
98
- readonly updatedBefore?: number
99
- readonly cursor?: string
100
- readonly limit: number
101
- }
102
-
103
- export interface ReconciliationScanPage<Record> {
104
- readonly records: readonly Record[]
105
- readonly cursor?: string
106
- }
107
-
108
- export interface ReconciliationPage<Finding> {
109
- readonly findings: readonly Finding[]
110
- readonly cursor?: string
111
- }
112
-
113
- export interface DurableEffectRecord {
114
- readonly effectId: string
115
- readonly capability: string
116
- readonly target?: string
117
- readonly digest?: string
118
- readonly tenantId?: string
119
- readonly principalId?: string
120
- readonly state: DurableEffectState
121
- readonly createdAt: number
122
- readonly updatedAt: number
123
- readonly errorCode?: string
124
- readonly version: number
125
- }
126
-
127
- export interface DurableEffectStore {
128
- readonly durability?: "memory" | "durable"
129
- create(record: DurableEffectRecord): boolean | Promise<boolean>
130
- get(effectId: string): DurableEffectRecord | undefined | Promise<DurableEffectRecord | undefined>
131
- /** Atomic compare-and-set; exactly one caller may advance the supplied `version` and `from` state. */
132
- transition(input: {
133
- readonly effectId: string
134
- readonly version: number
135
- readonly from: DurableEffectState
136
- readonly to: DurableEffectState
137
- readonly updatedAt: number
138
- readonly errorCode?: string
139
- }): boolean | Promise<boolean>
140
- /** Bounded operational scan. Transition-only stores do not need to implement reconciliation. */
141
- scan?(
142
- input: ReconciliationScanOptions<DurableEffectState>,
143
- ):
144
- | ReconciliationScanPage<DurableEffectRecord>
145
- | Promise<ReconciliationScanPage<DurableEffectRecord>>
146
- /** @deprecated Compatibility fallback; production reconciliation should implement `scan`. */
147
- list?(): readonly DurableEffectRecord[] | Promise<readonly DurableEffectRecord[]>
148
- }
149
-
150
- export class DurableEffectTransitionError extends Error {
151
- constructor(
152
- public readonly effectId: string,
153
- public readonly transition: string,
154
- ) {
155
- super(`durable effect ${effectId}: ${transition} transition was rejected`)
156
- this.name = "DurableEffectTransitionError"
157
- }
158
- }
159
-
160
- export interface DurableEffectJournalOptions {
161
- readonly store: DurableEffectStore
162
- readonly now?: () => number
163
- /** Tests/local development only. Production journals require `store.durability === "durable"`. */
164
- readonly allowMemoryStore?: boolean
165
- }
166
-
167
- export function createDurableEffectJournal(
168
- options: DurableEffectJournalOptions,
169
- ): CapabilityExecutionJournal {
170
- if (options.store.durability !== "durable" && options.allowMemoryStore !== true) {
171
- throw new TypeError('durable effect journal requires store.durability === "durable"')
172
- }
173
- const clock = options.now ?? Date.now
174
- const now = (): number => readClock(clock, "durable effect clock")
175
-
176
- const current = async (effectId: string): Promise<DurableEffectRecord> => {
177
- assertToken(effectId, "durable effect id", 64)
178
- const record = await options.store.get(effectId)
179
- if (record === undefined) throw new DurableEffectTransitionError(effectId, "missing")
180
- if (
181
- record.effectId !== effectId ||
182
- !validCapabilityId(record.capability) ||
183
- !["admission", "executing", "committed", "failed", "unknown"].includes(record.state) ||
184
- !Number.isSafeInteger(record.version) ||
185
- record.version < 1
186
- ) {
187
- throw new TypeError(`durable effect ${effectId}: store returned an invalid record`)
188
- }
189
- assertTimestamp(record.createdAt, "durable effect createdAt")
190
- assertTimestamp(record.updatedAt, "durable effect updatedAt")
191
- if (record.createdAt > record.updatedAt)
192
- throw new TypeError(`durable effect ${effectId}: updatedAt precedes createdAt`)
193
- if (record.target !== undefined) assertToken(record.target, "stored durable effect target", 128)
194
- if (record.digest !== undefined && !/^[0-9a-f]{64}$/u.test(record.digest))
195
- throw new TypeError(`durable effect ${effectId}: store returned an invalid digest`)
196
- if (record.tenantId !== undefined)
197
- assertToken(record.tenantId, "stored durable effect tenantId")
198
- if (record.principalId !== undefined)
199
- assertToken(record.principalId, "stored durable effect principalId")
200
- if ((record.tenantId === undefined) !== (record.principalId === undefined))
201
- throw new TypeError(`durable effect ${effectId}: store returned a partial identity`)
202
- if (record.errorCode !== undefined && !ERROR_CODE.test(record.errorCode))
203
- throw new TypeError(`durable effect ${effectId}: store returned an invalid error code`)
204
- return record
205
- }
206
- const transition = async (
207
- effectId: string,
208
- from: DurableEffectState,
209
- to: DurableEffectState,
210
- errorCode?: string,
211
- ): Promise<void> => {
212
- const record = await current(effectId)
213
- const next = durableEffectProtocol.transition(record, {
214
- effectId,
215
- version: record.version,
216
- from,
217
- to,
218
- updatedAt: now(),
219
- ...(errorCode === undefined ? {} : { errorCode }),
220
- })
221
- if (next === undefined) throw new DurableEffectTransitionError(effectId, `${from}->${to}`)
222
- const accepted = await options.store.transition({
223
- effectId,
224
- version: record.version,
225
- from,
226
- to,
227
- updatedAt: next.updatedAt,
228
- ...(errorCode === undefined ? {} : { errorCode }),
229
- })
230
- if (!accepted) throw new DurableEffectTransitionError(effectId, `${from}->${to}`)
231
- }
232
-
233
- const journal: CapabilityExecutionJournal = {
234
- async intent(input) {
235
- assertToken(input.effectId, "durable effect id", 64)
236
- if (!validCapabilityId(input.capability))
237
- throw new TypeError("durable effect capability is invalid")
238
- if (input.target !== undefined) assertToken(input.target, "durable effect target", 128)
239
- if (input.digest !== undefined && !/^[0-9a-f]{64}$/u.test(input.digest))
240
- throw new TypeError("durable effect digest is invalid")
241
- if (input.identity !== undefined) {
242
- assertToken(input.identity.tenantId, "durable effect tenantId")
243
- assertToken(input.identity.principalId, "durable effect principalId")
244
- }
245
- const at = now()
246
- const accepted = await options.store.create(
247
- Object.freeze({
248
- effectId: input.effectId,
249
- capability: input.capability,
250
- ...(input.target === undefined ? {} : { target: input.target }),
251
- ...(input.digest === undefined ? {} : { digest: input.digest }),
252
- ...(input.identity === undefined
253
- ? {}
254
- : { tenantId: input.identity.tenantId, principalId: input.identity.principalId }),
255
- state: "admission" as const,
256
- createdAt: at,
257
- updatedAt: at,
258
- version: 1,
259
- }),
260
- )
261
- if (!accepted) throw new DurableEffectTransitionError(input.effectId, "create")
262
- },
263
- async executing(effectId) {
264
- await transition(effectId, "admission", "executing")
265
- },
266
- async committed(effectId) {
267
- await transition(effectId, "executing", "committed")
268
- },
269
- async failed(effectId, input) {
270
- if (!ERROR_CODE.test(input.errorCode))
271
- throw new TypeError("durable effect errorCode is invalid")
272
- const record = await current(effectId)
273
- const to = input.began ? "unknown" : "failed"
274
- const next = durableEffectProtocol.transition(record, {
275
- effectId,
276
- version: record.version,
277
- from: record.state,
278
- to,
279
- updatedAt: now(),
280
- errorCode: input.errorCode,
281
- })
282
- if (next === undefined)
283
- throw new DurableEffectTransitionError(effectId, `${record.state}->${to}`)
284
- const accepted = await options.store.transition({
285
- effectId,
286
- version: record.version,
287
- from: record.state,
288
- to,
289
- updatedAt: next.updatedAt,
290
- errorCode: input.errorCode,
291
- })
292
- if (!accepted) throw new DurableEffectTransitionError(effectId, `${record.state}->${to}`)
293
- },
294
- }
295
- return Object.freeze(journal)
296
- }
297
-
298
- export class MemoryDurableEffectStore implements DurableEffectStore {
299
- readonly durability = "memory" as const
300
- private readonly records = new Map<string, DurableEffectRecord>()
301
- // Secondary index: effectIds grouped by state, so reconciliation scans only the requested (few,
302
- // non-terminal) states instead of walking every retained record. See {@link indexedScan}.
303
- private readonly byState = new Map<DurableEffectState, Set<string>>()
304
-
305
- create(record: DurableEffectRecord): boolean {
306
- if (this.records.has(record.effectId)) return false
307
- this.records.set(record.effectId, Object.freeze(cloneValue(record)))
308
- bucketAdd(this.byState, record.state, record.effectId)
309
- return true
310
- }
311
-
312
- get(effectId: string): DurableEffectRecord | undefined {
313
- const record = this.records.get(effectId)
314
- return record === undefined ? undefined : Object.freeze(cloneValue(record))
315
- }
316
-
317
- transition(input: Parameters<DurableEffectStore["transition"]>[0]): boolean {
318
- const current = this.records.get(input.effectId)
319
- const next = durableEffectProtocol.transition(current, input)
320
- if (next === undefined) return false
321
- if (current === undefined) return false
322
- this.records.set(input.effectId, Object.freeze(next))
323
- bucketMove(this.byState, current.state, input.to, input.effectId)
324
- return true
325
- }
326
-
327
- list(): readonly DurableEffectRecord[] {
328
- return Object.freeze(
329
- [...this.records.values()].map((record) => Object.freeze(cloneValue(record))),
330
- )
331
- }
332
-
333
- scan(
334
- input: ReconciliationScanOptions<DurableEffectState>,
335
- ): ReconciliationScanPage<DurableEffectRecord> {
336
- return indexedScan(this.byState, (id) => this.records.get(id), input)
337
- }
338
- }
339
-
340
- export interface EffectReconciliationFinding {
341
- readonly effectId: string
342
- readonly capability: string
343
- readonly state: "incomplete" | "ambiguous"
344
- readonly updatedAt: number
345
- }
346
-
347
- const DEFAULT_RECONCILIATION_LIMIT = 100
348
- const MAX_RECONCILIATION_LIMIT = 1_000
349
-
350
- function reconciliationLimit(value = DEFAULT_RECONCILIATION_LIMIT): number {
351
- if (!Number.isSafeInteger(value) || value < 1 || value > MAX_RECONCILIATION_LIMIT) {
352
- throw new RangeError(`reconciliation limit must be between 1 and ${MAX_RECONCILIATION_LIMIT}`)
353
- }
354
- return value
355
- }
356
-
357
- function cursorOffset(cursor?: string): number {
358
- if (cursor === undefined) return 0
359
- if (!/^(?:0|[1-9][0-9]*)$/u.test(cursor)) throw new TypeError("invalid reconciliation cursor")
360
- const offset = Number(cursor)
361
- if (!Number.isSafeInteger(offset)) throw new TypeError("invalid reconciliation cursor")
362
- return offset
363
- }
364
-
365
- function memoryScan<
366
- Record extends { readonly state: State; readonly updatedAt: number },
367
- State extends string,
368
- >(
369
- source: Iterable<Record>,
370
- input: ReconciliationScanOptions<State>,
371
- ): ReconciliationScanPage<Record> {
372
- const states = new Set(input.states)
373
- const offset = cursorOffset(input.cursor)
374
- const end = offset + input.limit
375
- const records: Record[] = []
376
- // Stream the source: skip the first `offset` eligible records, collect up to `limit`, and stop as
377
- // soon as one more eligible record proves a next page exists. Bounds allocation to the page and
378
- // avoids materializing the whole store when the page fills early. (An unindexed in-memory store still
379
- // walks its records to find sparse matches; a production store implements `scan()` as an indexed
380
- // query - state + updatedAt predicate, keyset cursor - instead of relying on this reference scan.)
381
- let seen = 0
382
- let more = false
383
- for (const record of source) {
384
- if (!states.has(record.state)) continue
385
- if (input.updatedBefore !== undefined && record.updatedAt > input.updatedBefore) continue
386
- if (seen >= end) {
387
- more = true
388
- break
389
- }
390
- if (seen >= offset) records.push(cloneValue(record))
391
- seen++
392
- }
393
- return Object.freeze({
394
- records: Object.freeze(records),
395
- ...(more ? { cursor: String(offset + records.length) } : {}),
396
- })
397
- }
398
-
399
- function bucketAdd<State extends string>(
400
- byState: Map<State, Set<string>>,
401
- state: State,
402
- id: string,
403
- ): void {
404
- let bucket = byState.get(state)
405
- if (bucket === undefined) {
406
- bucket = new Set()
407
- byState.set(state, bucket)
408
- }
409
- bucket.add(id)
410
- }
411
-
412
- function bucketMove<State extends string>(
413
- byState: Map<State, Set<string>>,
414
- from: State,
415
- to: State,
416
- id: string,
417
- ): void {
418
- if (from === to) return
419
- byState.get(from)?.delete(id)
420
- bucketAdd(byState, to, id)
421
- }
422
-
423
- /**
424
- * Paginate a state-bucketed in-memory index: walk only the requested states' buckets (each a Set of
425
- * ids in insertion order), resolve + `updatedBefore`-filter each record, and page by offset cursor.
426
- * O(sum of the requested buckets) instead of O(total records) - the store still retains terminal
427
- * records, but reconciliation never walks them. Matches {@link memoryScan}'s paging semantics.
428
- */
429
- function indexedScan<Record extends { readonly updatedAt: number }, State extends string>(
430
- byState: Map<State, Set<string>>,
431
- resolve: (id: string) => Record | undefined,
432
- input: ReconciliationScanOptions<State>,
433
- ): ReconciliationScanPage<Record> {
434
- const offset = cursorOffset(input.cursor)
435
- const end = offset + input.limit
436
- const records: Record[] = []
437
- let seen = 0
438
- let more = false
439
- for (const state of new Set(input.states)) {
440
- if (more) break
441
- const bucket = byState.get(state)
442
- if (bucket === undefined) continue
443
- for (const id of bucket) {
444
- const record = resolve(id)
445
- if (record === undefined) continue
446
- if (input.updatedBefore !== undefined && record.updatedAt > input.updatedBefore) continue
447
- if (seen >= end) {
448
- more = true
449
- break
450
- }
451
- if (seen >= offset) records.push(cloneValue(record))
452
- seen++
453
- }
454
- }
455
- return Object.freeze({
456
- records: Object.freeze(records),
457
- ...(more ? { cursor: String(offset + records.length) } : {}),
458
- })
459
- }
460
-
461
- async function scanEffectRecords(
462
- store: DurableEffectStore,
463
- input: ReconciliationScanOptions<DurableEffectState>,
464
- ): Promise<ReconciliationScanPage<DurableEffectRecord>> {
465
- if (store.scan !== undefined) return await store.scan(input)
466
- if (store.list === undefined) throw new TypeError("effect store does not support reconciliation")
467
- return memoryScan(await store.list(), input)
468
- }
469
-
470
- export async function reconcileEffectsPage(
471
- store: DurableEffectStore,
472
- options: {
473
- readonly staleBefore: number
474
- readonly observer?: EffectLifecycleObserver
475
- readonly cursor?: string
476
- readonly limit?: number
477
- },
478
- ): Promise<ReconciliationPage<EffectReconciliationFinding>> {
479
- const findings: EffectReconciliationFinding[] = []
480
- const page = await scanEffectRecords(store, {
481
- states: ["admission", "executing", "unknown"],
482
- updatedBefore: options.staleBefore,
483
- ...(options.cursor === undefined ? {} : { cursor: options.cursor }),
484
- limit: reconciliationLimit(options.limit),
485
- })
486
- for (const record of page.records) {
487
- if (!validCapabilityId(record.capability) || !/^[!-~]{1,64}$/u.test(record.effectId)) continue
488
- if (record.updatedAt > options.staleBefore) continue
489
- const state =
490
- record.state === "admission"
491
- ? "incomplete"
492
- : record.state === "executing" || record.state === "unknown"
493
- ? "ambiguous"
494
- : undefined
495
- if (state === undefined) continue
496
- findings.push(
497
- Object.freeze({
498
- effectId: record.effectId,
499
- capability: record.capability,
500
- state,
501
- updatedAt: record.updatedAt,
502
- }),
503
- )
504
- if (options.observer !== undefined) {
505
- emitEffectLifecycle([options.observer], {
506
- effectId: record.effectId,
507
- capability: record.capability,
508
- stage: "reconciliation",
509
- phase: state === "ambiguous" ? "ambiguous" : "failed",
510
- errorCode: state === "ambiguous" ? "effect_ambiguous" : "effect_incomplete",
511
- })
512
- }
513
- }
514
- return Object.freeze({
515
- findings: Object.freeze(findings),
516
- ...(page.cursor === undefined ? {} : { cursor: page.cursor }),
517
- })
518
- }
519
-
520
- export async function reconcileEffects(
521
- store: DurableEffectStore,
522
- options: { readonly staleBefore: number; readonly observer?: EffectLifecycleObserver },
523
- ): Promise<readonly EffectReconciliationFinding[]> {
524
- const page = await reconcileEffectsPage(store, {
525
- ...options,
526
- limit: MAX_RECONCILIATION_LIMIT,
527
- })
528
- if (page.cursor !== undefined) {
529
- throw new RangeError(
530
- `effect reconciliation exceeds ${MAX_RECONCILIATION_LIMIT} records; use reconcileEffectsPage()`,
531
- )
532
- }
533
- return page.findings
534
- }
535
-
536
- // -------------------------------------------------------------------------------------------------
537
- // Durable human approval with signed, single-use resume tokens
538
-
539
- export type ApprovalState = "pending" | "approved" | "denied" | "consumed" | "expired"
540
-
541
- export interface ApprovalRecord {
542
- readonly approvalId: string
543
- readonly effectId: string
544
- readonly capability: string
545
- readonly target?: string
546
- readonly digest?: string
547
- readonly tenantId: string
548
- readonly principalId: string
549
- readonly tokenHash: string
550
- readonly state: ApprovalState
551
- readonly createdAt: number
552
- readonly expiresAt: number
553
- readonly updatedAt: number
554
- readonly decidedBy?: string
555
- readonly version: number
556
- }
557
-
558
- export type ApprovalConsumeResult =
559
- | { readonly state: "consumed" }
560
- | {
561
- readonly state: "missing" | "pending" | "denied" | "expired" | "replay" | "binding" | "token"
562
- }
563
-
564
- export interface ApprovalStore {
565
- readonly durability?: "memory" | "durable"
566
- create(record: ApprovalRecord): boolean | Promise<boolean>
567
- get(approvalId: string): ApprovalRecord | undefined | Promise<ApprovalRecord | undefined>
568
- decide(input: {
569
- readonly approvalId: string
570
- readonly tenantId: string
571
- readonly decision: "approved" | "denied"
572
- readonly decidedBy: string
573
- readonly now: number
574
- }): boolean | Promise<boolean>
575
- /**
576
- * Atomically validate binding/token/state and change `approved` to `consumed`. Two concurrent
577
- * callers with one token must yield exactly one `consumed` and one `replay` result.
578
- */
579
- consume(input: {
580
- readonly approvalId: string
581
- readonly tenantId: string
582
- readonly principalId: string
583
- readonly capability: string
584
- readonly target?: string
585
- readonly digest?: string
586
- readonly tokenHash: string
587
- readonly now: number
588
- }): ApprovalConsumeResult | Promise<ApprovalConsumeResult>
589
- }
590
-
591
- export class ApprovalRequiredError extends Error {
592
- readonly approvalId: string
593
- readonly effectId: string
594
- readonly resumeToken!: string
595
- readonly expiresAt: number
596
-
597
- constructor(approvalId: string, effectId: string, resumeToken: string, expiresAt: number) {
598
- super("capability approval is required")
599
- this.approvalId = approvalId
600
- this.effectId = effectId
601
- this.expiresAt = expiresAt
602
- Object.defineProperty(this, "resumeToken", {
603
- value: resumeToken,
604
- enumerable: false,
605
- configurable: false,
606
- writable: false,
607
- })
608
- this.name = "ApprovalRequiredError"
609
- }
610
- }
611
-
612
- export class ApprovalTokenInvalidError extends Error {
613
- constructor() {
614
- super("approval resume token is invalid")
615
- this.name = "ApprovalTokenInvalidError"
616
- }
617
- }
618
- export class ApprovalTokenExpiredError extends Error {
619
- constructor() {
620
- super("approval resume token has expired")
621
- this.name = "ApprovalTokenExpiredError"
622
- }
623
- }
624
- export class ApprovalTokenReplayError extends Error {
625
- constructor() {
626
- super("approval resume token has already been consumed")
627
- this.name = "ApprovalTokenReplayError"
628
- }
629
- }
630
- export class ApprovalBindingError extends Error {
631
- constructor() {
632
- super("approval resume token is bound to another tenant or principal")
633
- this.name = "ApprovalBindingError"
634
- }
635
- }
636
- export class ApprovalPendingError extends Error {
637
- constructor() {
638
- super("capability approval is still pending")
639
- this.name = "ApprovalPendingError"
640
- }
641
- }
642
- export class ApprovalDeniedError extends Error {
643
- constructor() {
644
- super("capability approval was denied")
645
- this.name = "ApprovalDeniedError"
646
- }
647
- }
648
-
649
- interface ResumeClaims {
650
- readonly v: 1
651
- readonly id: string
652
- readonly nonce: string
653
- readonly exp: number
654
- }
655
-
656
- export interface ApprovalCoordinator extends CapabilityApprovalGate {
657
- decide(input: {
658
- readonly approvalId: string
659
- readonly tenantId: string
660
- readonly decision: "approved" | "denied"
661
- readonly decidedBy: string
662
- }): Promise<void>
663
- get(approvalId: string): Promise<ApprovalRecord | undefined>
664
- }
665
-
666
- export interface ApprovalCoordinatorOptions {
667
- readonly store: ApprovalStore
668
- /** At least 32 random bytes, kept separately from the approval store. */
669
- readonly secret: Uint8Array
670
- readonly ttlMs?: number
671
- readonly now?: () => number
672
- /** Tests/local development only. Production approval records require a durable store. */
673
- readonly allowMemoryStore?: boolean
674
- }
675
-
676
- export function createApprovalCoordinator(
677
- options: ApprovalCoordinatorOptions,
678
- ): ApprovalCoordinator {
679
- if (options.store.durability !== "durable" && options.allowMemoryStore !== true) {
680
- throw new TypeError('approval coordinator requires store.durability === "durable"')
681
- }
682
- if (!(options.secret instanceof Uint8Array) || options.secret.byteLength < 32) {
683
- throw new TypeError("approval secret must contain at least 32 random bytes")
684
- }
685
- const secret = new Uint8Array(options.secret)
686
- const ttlMs = options.ttlMs ?? 15 * 60_000
687
- assertPositiveMs(ttlMs, "approval ttlMs")
688
- const clock = options.now ?? Date.now
689
- const now = (): number => readClock(clock, "approval clock")
690
- const key = crypto.subtle.importKey("raw", secret, { name: "HMAC", hash: "SHA-256" }, false, [
691
- "sign",
692
- "verify",
693
- ])
694
-
695
- const sign = async (claims: ResumeClaims): Promise<string> => {
696
- const payload = bytesToBase64Url(encoder.encode(JSON.stringify(claims)))
697
- const signature = new Uint8Array(
698
- await crypto.subtle.sign("HMAC", await key, encoder.encode(payload)),
699
- )
700
- return `v1.${payload}.${bytesToBase64Url(signature)}`
701
- }
702
- const verify = async (token: string): Promise<ResumeClaims> => {
703
- if (typeof token !== "string" || token.length > 2048) throw new ApprovalTokenInvalidError()
704
- const parts = token.split(".")
705
- if (parts.length !== 3 || parts[0] !== "v1") throw new ApprovalTokenInvalidError()
706
- const payload = parts[1] as string
707
- const valid = await crypto.subtle.verify(
708
- "HMAC",
709
- await key,
710
- base64UrlToBytes(parts[2] as string).buffer as ArrayBuffer,
711
- encoder.encode(payload),
712
- )
713
- if (!valid) throw new ApprovalTokenInvalidError()
714
- let claims: unknown
715
- try {
716
- claims = JSON.parse(decoder.decode(base64UrlToBytes(payload)))
717
- } catch {
718
- throw new ApprovalTokenInvalidError()
719
- }
720
- if (typeof claims !== "object" || claims === null) throw new ApprovalTokenInvalidError()
721
- const candidate = claims as Partial<ResumeClaims>
722
- if (
723
- candidate.v !== 1 ||
724
- typeof candidate.id !== "string" ||
725
- typeof candidate.nonce !== "string" ||
726
- !Number.isSafeInteger(candidate.exp)
727
- ) {
728
- throw new ApprovalTokenInvalidError()
729
- }
730
- assertToken(candidate.id, "approval id", 128)
731
- assertToken(candidate.nonce, "approval nonce", 128)
732
- if ((candidate.exp as number) <= now()) throw new ApprovalTokenExpiredError()
733
- return candidate as ResumeClaims
734
- }
735
-
736
- const coordinator: ApprovalCoordinator = {
737
- async authorize(input: Parameters<CapabilityApprovalGate["authorize"]>[0]) {
738
- if (input.signal.aborted) throw input.signal.reason
739
- assertToken(input.identity.tenantId, "approval tenantId")
740
- assertToken(input.identity.principalId, "approval principalId")
741
- if (input.resumeToken === undefined) {
742
- const approvalId = crypto.randomUUID()
743
- const nonce = bytesToBase64Url(crypto.getRandomValues(new Uint8Array(18)))
744
- const issuedAt = now()
745
- const expiresAt = addDuration(issuedAt, ttlMs, "approval expiry")
746
- const resumeToken = await sign({ v: 1, id: approvalId, nonce, exp: expiresAt })
747
- const accepted = await options.store.create(
748
- Object.freeze({
749
- approvalId,
750
- effectId: input.effectId,
751
- capability: input.capability,
752
- ...(input.target === undefined ? {} : { target: input.target }),
753
- ...(input.digest === undefined ? {} : { digest: input.digest }),
754
- tenantId: input.identity.tenantId,
755
- principalId: input.identity.principalId,
756
- tokenHash: await sha256(resumeToken),
757
- state: "pending" as const,
758
- createdAt: issuedAt,
759
- expiresAt,
760
- updatedAt: issuedAt,
761
- version: 1,
762
- }),
763
- )
764
- if (!accepted) throw new Error("approval store rejected a unique approval id")
765
- throw new ApprovalRequiredError(approvalId, input.effectId, resumeToken, expiresAt)
766
- }
767
- const claims = await verify(input.resumeToken)
768
- const consumed = await options.store.consume({
769
- approvalId: claims.id,
770
- tenantId: input.identity.tenantId,
771
- principalId: input.identity.principalId,
772
- capability: input.capability,
773
- ...(input.target === undefined ? {} : { target: input.target }),
774
- ...(input.digest === undefined ? {} : { digest: input.digest }),
775
- tokenHash: await sha256(input.resumeToken),
776
- now: now(),
777
- })
778
- if (consumed.state === "consumed") return
779
- if (consumed.state === "replay") throw new ApprovalTokenReplayError()
780
- if (consumed.state === "binding") throw new ApprovalBindingError()
781
- if (consumed.state === "expired") throw new ApprovalTokenExpiredError()
782
- if (consumed.state === "pending") throw new ApprovalPendingError()
783
- if (consumed.state === "denied") throw new ApprovalDeniedError()
784
- throw new ApprovalTokenInvalidError()
785
- },
786
- async decide(input: Parameters<ApprovalCoordinator["decide"]>[0]) {
787
- assertToken(input.approvalId, "approval id", 128)
788
- assertToken(input.tenantId, "approval tenantId")
789
- assertToken(input.decidedBy, "approval decidedBy")
790
- const accepted = await options.store.decide({ ...input, now: now() })
791
- if (!accepted) throw new Error("approval decision rejected")
792
- },
793
- async get(approvalId: string) {
794
- assertToken(approvalId, "approval id", 128)
795
- return await options.store.get(approvalId)
796
- },
797
- }
798
- return Object.freeze(coordinator)
799
- }
800
-
801
- export class MemoryApprovalStore implements ApprovalStore {
802
- readonly durability = "memory" as const
803
- private readonly records = new Map<string, ApprovalRecord>()
804
-
805
- create(record: ApprovalRecord): boolean {
806
- if (this.records.has(record.approvalId)) return false
807
- this.records.set(record.approvalId, Object.freeze(cloneValue(record)))
808
- return true
809
- }
810
- get(approvalId: string): ApprovalRecord | undefined {
811
- const record = this.records.get(approvalId)
812
- return record === undefined ? undefined : Object.freeze(cloneValue(record))
813
- }
814
- decide(input: Parameters<ApprovalStore["decide"]>[0]): boolean {
815
- const record = this.records.get(input.approvalId)
816
- const next = durableApprovalProtocol.decide(record, input)
817
- if (next === undefined) return false
818
- this.records.set(input.approvalId, Object.freeze(next))
819
- return true
820
- }
821
- consume(input: Parameters<ApprovalStore["consume"]>[0]): ApprovalConsumeResult {
822
- const record = this.records.get(input.approvalId)
823
- const transition = durableApprovalProtocol.consume(record, input)
824
- if (transition.next !== undefined)
825
- this.records.set(input.approvalId, Object.freeze(transition.next))
826
- return transition.result
827
- }
828
- list(): readonly ApprovalRecord[] {
829
- return Object.freeze(
830
- [...this.records.values()].map((record) => Object.freeze(cloneValue(record))),
831
- )
832
- }
833
- }
834
-
835
- // -------------------------------------------------------------------------------------------------
836
- // Durable saga + compensation state machine
837
-
838
- export type SagaState = "running" | "compensating" | "completed" | "compensated" | "manual-review"
839
- export type SagaStepState =
840
- | "executing"
841
- | "committed"
842
- | "failed"
843
- | "ambiguous"
844
- | "compensating"
845
- | "compensation-failed"
846
- | "compensated"
847
-
848
- export interface SagaStepRecord {
849
- readonly name: string
850
- readonly effectId: string
851
- readonly compensationEffectId: string
852
- readonly state: SagaStepState
853
- readonly compensationArgs: unknown
854
- readonly result?: unknown
855
- readonly attempts: number
856
- readonly nextAttemptAt?: number
857
- readonly errorCode?: string
858
- }
859
-
860
- export interface SagaRecord {
861
- readonly sagaId: string
862
- readonly definition: string
863
- readonly state: SagaState
864
- readonly input: unknown
865
- readonly steps: readonly SagaStepRecord[]
866
- readonly createdAt: number
867
- readonly updatedAt: number
868
- readonly version: number
869
- }
870
-
871
- export interface SagaStore {
872
- readonly durability?: "memory" | "durable"
873
- create(record: SagaRecord): boolean | Promise<boolean>
874
- get(sagaId: string): SagaRecord | undefined | Promise<SagaRecord | undefined>
875
- /** Atomic version compare-and-set; this is the saga engine's crash/concurrency boundary. */
876
- compareAndSet(input: {
877
- readonly sagaId: string
878
- readonly version: number
879
- readonly record: SagaRecord
880
- }): boolean | Promise<boolean>
881
- /** Bounded operational scan. Transition-only stores do not need to implement reconciliation. */
882
- scan?(
883
- input: ReconciliationScanOptions<SagaState>,
884
- ): ReconciliationScanPage<SagaRecord> | Promise<ReconciliationScanPage<SagaRecord>>
885
- /** @deprecated Compatibility fallback; production reconciliation should implement `scan`. */
886
- list?(): readonly SagaRecord[] | Promise<readonly SagaRecord[]>
887
- }
888
-
889
- export interface SagaStepExecutionContext {
890
- readonly effectId: string
891
- readonly signal: AbortSignal
892
- }
893
- export interface SagaCompensationContext {
894
- readonly effectId: string
895
- readonly sagaId: string
896
- readonly attempt: number
897
- readonly signal: AbortSignal
898
- }
899
-
900
- export interface SagaRunContext<C extends Record<string, unknown>> {
901
- step<K extends keyof C & string, T>(
902
- name: K,
903
- compensationArgs: C[K],
904
- execute: (context: SagaStepExecutionContext) => T | PromiseLike<T>,
905
- ): Promise<T>
906
- }
907
-
908
- export interface SagaDefinition<I, C extends Record<string, unknown>> {
909
- readonly name: string
910
- readonly capability?: string
911
- readonly run: (context: SagaRunContext<C>, input: I) => void | PromiseLike<void>
912
- readonly compensators: {
913
- readonly [K in keyof C]: (
914
- args: C[K],
915
- context: SagaCompensationContext,
916
- ) => void | PromiseLike<void>
917
- }
918
- readonly retry?: {
919
- readonly maxAttempts?: number
920
- readonly backoffMs?: (attempt: number) => number
921
- }
922
- }
923
-
924
- function assertSagaDefinition<I, C extends Record<string, unknown>>(
925
- definition: SagaDefinition<I, C>,
926
- ): void {
927
- if (typeof definition !== "object" || definition === null)
928
- throw new TypeError("saga definition must be an object")
929
- assertToken(definition.name, "saga definition", 128)
930
- if (definition.capability !== undefined && !validCapabilityId(definition.capability))
931
- throw new TypeError("saga capability is invalid")
932
- if (typeof definition.run !== "function") throw new TypeError("saga run must be a function")
933
- if (
934
- typeof definition.compensators !== "object" ||
935
- definition.compensators === null ||
936
- Array.isArray(definition.compensators)
937
- ) {
938
- throw new TypeError("saga compensators must be an object")
939
- }
940
- for (const [name, compensator] of Object.entries(definition.compensators)) {
941
- assertToken(name, "saga compensator name", 128)
942
- if (typeof compensator !== "function")
943
- throw new TypeError(`saga compensator ${name} must be a function`)
944
- }
945
- if (definition.retry !== undefined) {
946
- if (typeof definition.retry !== "object" || definition.retry === null)
947
- throw new TypeError("saga retry must be an object")
948
- if (definition.retry.maxAttempts !== undefined)
949
- assertPositiveMs(definition.retry.maxAttempts, "saga maxAttempts")
950
- if (
951
- definition.retry.backoffMs !== undefined &&
952
- typeof definition.retry.backoffMs !== "function"
953
- )
954
- throw new TypeError("saga backoffMs must be a function")
955
- }
956
- }
957
-
958
- export function defineSaga<I, C extends Record<string, unknown>>(
959
- definition: SagaDefinition<I, C>,
960
- ): SagaDefinition<I, C> {
961
- assertSagaDefinition(definition)
962
- return Object.freeze({
963
- ...definition,
964
- compensators: Object.freeze({ ...definition.compensators }),
965
- ...(definition.retry === undefined ? {} : { retry: Object.freeze({ ...definition.retry }) }),
966
- })
967
- }
968
-
969
- export class SagaConcurrencyError extends Error {
970
- constructor(public readonly sagaId: string) {
971
- super(`saga ${sagaId}: concurrent transition rejected`)
972
- this.name = "SagaConcurrencyError"
973
- }
974
- }
975
- export class SagaAmbiguousStepError extends Error {
976
- constructor(
977
- public readonly sagaId: string,
978
- public readonly step: string,
979
- public readonly effectId: string,
980
- ) {
981
- super(`saga ${sagaId}: step ${step} has an ambiguous effect`)
982
- this.name = "SagaAmbiguousStepError"
983
- }
984
- }
985
-
986
- export class SagaResolutionError extends Error {
987
- constructor(
988
- public readonly sagaId: string,
989
- public readonly step: string,
990
- message: string,
991
- ) {
992
- super(`saga ${sagaId}: step ${step} ${message}`)
993
- this.name = "SagaResolutionError"
994
- }
995
- }
996
- /** Throw this only when a provider conclusively proves that no effect committed. */
997
- export class SagaStepNotCommittedError extends Error {
998
- constructor(public readonly code = "not_committed") {
999
- super("saga step conclusively did not commit")
1000
- this.name = "SagaStepNotCommittedError"
1001
- if (!ERROR_CODE.test(code)) throw new TypeError("saga step error code is invalid")
1002
- }
1003
- }
1004
-
1005
- export interface SagaEngineOptions {
1006
- readonly store: SagaStore
1007
- readonly now?: () => number
1008
- readonly observer?: EffectLifecycleObserver
1009
- /** Tests/local development only. Production sagas require a durable store. */
1010
- readonly allowMemoryStore?: boolean
1011
- }
1012
-
1013
- export type SagaAmbiguityResolution =
1014
- | {
1015
- readonly kind: "execution"
1016
- readonly step: string
1017
- readonly effectId: string
1018
- readonly outcome: "committed"
1019
- /** Provider-confirmed result required by later saga steps. */
1020
- readonly result: unknown
1021
- }
1022
- | {
1023
- readonly kind: "execution"
1024
- readonly step: string
1025
- readonly effectId: string
1026
- readonly outcome: "not-committed"
1027
- readonly errorCode?: string
1028
- }
1029
- | {
1030
- readonly kind: "compensation"
1031
- readonly step: string
1032
- readonly effectId: string
1033
- readonly outcome: "compensated" | "not-compensated"
1034
- }
1035
-
1036
- export interface SagaEngine {
1037
- execute<I, C extends Record<string, unknown>>(
1038
- definition: SagaDefinition<I, C>,
1039
- sagaId: string,
1040
- input: I,
1041
- options?: { readonly signal?: AbortSignal },
1042
- ): Promise<SagaRecord>
1043
- resume<I, C extends Record<string, unknown>>(
1044
- definition: SagaDefinition<I, C>,
1045
- sagaId: string,
1046
- options?: { readonly signal?: AbortSignal },
1047
- ): Promise<SagaRecord>
1048
- compensate<I, C extends Record<string, unknown>>(
1049
- definition: SagaDefinition<I, C>,
1050
- sagaId: string,
1051
- options?: { readonly signal?: AbortSignal },
1052
- ): Promise<SagaRecord>
1053
- /**
1054
- * Apply a provider/operator-confirmed outcome to an ambiguous durable transition. The supplied
1055
- * effect id must match the stored execution/compensation id, preventing a stale review from
1056
- * resolving a later operation. The caller remains responsible for authenticating and authorizing
1057
- * the operator. Call `resume` or `compensate` after the resolution.
1058
- */
1059
- resolveAmbiguity<I, C extends Record<string, unknown>>(
1060
- definition: SagaDefinition<I, C>,
1061
- sagaId: string,
1062
- resolution: SagaAmbiguityResolution,
1063
- ): Promise<SagaRecord>
1064
- }
1065
-
1066
- let neverAbortSignal: AbortSignal | undefined
1067
- function signalOrNever(signal?: AbortSignal): AbortSignal {
1068
- neverAbortSignal ??= new AbortController().signal
1069
- return signal ?? neverAbortSignal
1070
- }
1071
-
1072
- function throwIfAborted(signal: AbortSignal): void {
1073
- if (signal.aborted) {
1074
- throw signal.reason instanceof Error ? signal.reason : new Error("saga execution aborted")
1075
- }
1076
- }
1077
-
1078
- export function createSagaEngine(options: SagaEngineOptions): SagaEngine {
1079
- if (options.store.durability !== "durable" && options.allowMemoryStore !== true)
1080
- throw new TypeError('saga engine requires store.durability === "durable"')
1081
- const clock = options.now ?? Date.now
1082
- const now = (): number => readClock(clock, "saga clock")
1083
- const observer = options.observer
1084
-
1085
- const load = async (sagaId: string): Promise<SagaRecord> => {
1086
- const record = await options.store.get(sagaId)
1087
- if (record === undefined) throw new Error(`saga ${sagaId}: not found`)
1088
- if (
1089
- record.sagaId !== sagaId ||
1090
- !["running", "compensating", "completed", "compensated", "manual-review"].includes(
1091
- record.state,
1092
- ) ||
1093
- !Array.isArray(record.steps) ||
1094
- !Number.isSafeInteger(record.version) ||
1095
- record.version < 1
1096
- ) {
1097
- throw new TypeError(`saga ${sagaId}: store returned an invalid record`)
1098
- }
1099
- assertToken(record.definition, "stored saga definition", 128)
1100
- assertTimestamp(record.createdAt, "stored saga createdAt")
1101
- assertTimestamp(record.updatedAt, "stored saga updatedAt")
1102
- if (record.createdAt > record.updatedAt)
1103
- throw new TypeError(`saga ${sagaId}: updatedAt precedes createdAt`)
1104
- const names = new Set<string>()
1105
- const effectIds = new Set<string>()
1106
- for (const step of record.steps) {
1107
- assertToken(step.name, "stored saga step", 128)
1108
- assertToken(step.effectId, "stored saga effect id", 64)
1109
- assertToken(step.compensationEffectId, "stored saga compensation effect id", 64)
1110
- if (
1111
- ![
1112
- "executing",
1113
- "committed",
1114
- "failed",
1115
- "ambiguous",
1116
- "compensating",
1117
- "compensation-failed",
1118
- "compensated",
1119
- ].includes(step.state) ||
1120
- !Number.isSafeInteger(step.attempts) ||
1121
- step.attempts < 0 ||
1122
- names.has(step.name) ||
1123
- effectIds.has(step.effectId) ||
1124
- effectIds.has(step.compensationEffectId)
1125
- ) {
1126
- throw new TypeError(`saga ${sagaId}: store returned an invalid step record`)
1127
- }
1128
- names.add(step.name)
1129
- effectIds.add(step.effectId)
1130
- effectIds.add(step.compensationEffectId)
1131
- if (step.nextAttemptAt !== undefined)
1132
- assertTimestamp(step.nextAttemptAt, "stored saga nextAttemptAt")
1133
- if (step.errorCode !== undefined && !ERROR_CODE.test(step.errorCode))
1134
- throw new TypeError(`saga ${sagaId}: store returned an invalid error code`)
1135
- }
1136
- return record
1137
- }
1138
- const save = async (
1139
- current: SagaRecord,
1140
- update: Omit<SagaRecord, "version">,
1141
- ): Promise<SagaRecord> => {
1142
- const next = durableSagaProtocol.transition(current, {
1143
- sagaId: current.sagaId,
1144
- version: current.version,
1145
- record: Object.freeze({ ...update, version: current.version + 1 }),
1146
- })
1147
- if (next === undefined) throw new SagaConcurrencyError(current.sagaId)
1148
- if (
1149
- !(await options.store.compareAndSet({
1150
- sagaId: current.sagaId,
1151
- version: current.version,
1152
- record: next,
1153
- }))
1154
- )
1155
- throw new SagaConcurrencyError(current.sagaId)
1156
- return next
1157
- }
1158
- const withStep = (
1159
- record: SagaRecord,
1160
- index: number,
1161
- step: SagaStepRecord,
1162
- state = record.state,
1163
- ): Omit<SagaRecord, "version"> => durableSagaProtocol.withStep(record, index, step, state, now())
1164
- const effectScope = createEffectScope({
1165
- ...(observer === undefined ? {} : { observers: [observer] }),
1166
- })
1167
-
1168
- const compensate = async <I, C extends Record<string, unknown>>(
1169
- definition: SagaDefinition<I, C>,
1170
- sagaId: string,
1171
- runOptions: { readonly signal?: AbortSignal } = {},
1172
- ): Promise<SagaRecord> => {
1173
- assertSagaDefinition(definition)
1174
- assertToken(sagaId, "saga id", 128)
1175
- const signal = signalOrNever(runOptions.signal)
1176
- throwIfAborted(signal)
1177
- let record = await load(sagaId)
1178
- if (record.definition !== definition.name) throw new TypeError("saga definition mismatch")
1179
- if (record.state === "completed" || record.state === "compensated") return record
1180
- if (
1181
- record.steps.some(
1182
- (step) =>
1183
- step.state === "ambiguous" || step.state === "executing" || step.state === "compensating",
1184
- )
1185
- ) {
1186
- if (record.state !== "manual-review")
1187
- record = await save(record, { ...record, state: "manual-review", updatedAt: now() })
1188
- const ambiguous = record.steps.find(
1189
- (step) =>
1190
- step.state === "ambiguous" || step.state === "executing" || step.state === "compensating",
1191
- ) as SagaStepRecord
1192
- throw new SagaAmbiguousStepError(
1193
- sagaId,
1194
- ambiguous.name,
1195
- ambiguous.state === "compensating" ? ambiguous.compensationEffectId : ambiguous.effectId,
1196
- )
1197
- }
1198
- if (record.state !== "compensating")
1199
- record = await save(record, { ...record, state: "compensating", updatedAt: now() })
1200
- const maxAttempts = definition.retry?.maxAttempts ?? 3
1201
- assertPositiveMs(maxAttempts, "saga maxAttempts")
1202
- const backoff =
1203
- definition.retry?.backoffMs ??
1204
- ((attempt: number) => Math.min(60_000, 100 * 2 ** (attempt - 1)))
1205
-
1206
- for (let index = record.steps.length - 1; index >= 0; index--) {
1207
- throwIfAborted(signal)
1208
- let step = record.steps[index] as SagaStepRecord
1209
- if (step.state === "compensated" || step.state === "failed") continue
1210
- if (step.state !== "committed" && step.state !== "compensation-failed") continue
1211
- if (step.nextAttemptAt !== undefined && step.nextAttemptAt > now()) return record
1212
- const compensator = definition.compensators[step.name as keyof C]
1213
- if (typeof compensator !== "function") {
1214
- record = await save(record, { ...record, state: "manual-review", updatedAt: now() })
1215
- return record
1216
- }
1217
- if (step.attempts >= maxAttempts) {
1218
- if (record.state !== "manual-review")
1219
- record = await save(record, { ...record, state: "manual-review", updatedAt: now() })
1220
- return record
1221
- }
1222
- const attempt = step.attempts + 1
1223
- const { errorCode: _errorCode, nextAttemptAt: _nextAttemptAt, ...stepBase } = step
1224
- step = { ...stepBase, state: "compensating", attempts: attempt }
1225
- let transitionError: unknown
1226
- try {
1227
- await effectScope.run(
1228
- {
1229
- effectId: step.compensationEffectId,
1230
- capability: definition.capability ?? "saga.compensate",
1231
- stage: "compensation",
1232
- signal,
1233
- attempt,
1234
- failurePhase: () => "failed",
1235
- errorCode: () => "compensation_failed",
1236
- transitions: {
1237
- async executing() {
1238
- record = await save(record, withStep(record, index, step))
1239
- },
1240
- async committed() {
1241
- step = { ...step, state: "compensated" }
1242
- record = await save(record, withStep(record, index, step))
1243
- },
1244
- async failed() {
1245
- const exhausted = attempt >= maxAttempts
1246
- try {
1247
- const delay = exhausted ? undefined : backoff(attempt)
1248
- if (delay !== undefined && (!Number.isSafeInteger(delay) || delay < 0))
1249
- throw new RangeError("saga backoff must return a non-negative safe integer")
1250
- const failed: SagaStepRecord = {
1251
- ...step,
1252
- state: "compensation-failed",
1253
- errorCode: "compensation_failed",
1254
- ...(delay === undefined
1255
- ? {}
1256
- : { nextAttemptAt: addDuration(now(), delay, "saga next retry") }),
1257
- }
1258
- record = await save(
1259
- record,
1260
- withStep(record, index, failed, exhausted ? "manual-review" : "compensating"),
1261
- )
1262
- } catch (error) {
1263
- transitionError = error
1264
- const failed: SagaStepRecord = {
1265
- ...step,
1266
- state: "compensation-failed",
1267
- errorCode: "invalid_retry_policy",
1268
- }
1269
- record = await save(record, withStep(record, index, failed, "manual-review"))
1270
- }
1271
- },
1272
- },
1273
- },
1274
- async (owned) => {
1275
- await compensator(cloneValue(step.compensationArgs) as C[keyof C], {
1276
- effectId: owned.effectId,
1277
- sagaId,
1278
- attempt,
1279
- signal: owned.signal,
1280
- })
1281
- },
1282
- )
1283
- } catch {
1284
- if (transitionError !== undefined) throw transitionError
1285
- return record
1286
- }
1287
- }
1288
- return await save(record, { ...record, state: "compensated", updatedAt: now() })
1289
- }
1290
-
1291
- const resume = async <I, C extends Record<string, unknown>>(
1292
- definition: SagaDefinition<I, C>,
1293
- sagaId: string,
1294
- runOptions: { readonly signal?: AbortSignal } = {},
1295
- ): Promise<SagaRecord> => {
1296
- assertSagaDefinition(definition)
1297
- assertToken(sagaId, "saga id", 128)
1298
- const signal = signalOrNever(runOptions.signal)
1299
- throwIfAborted(signal)
1300
- let record = await load(sagaId)
1301
- if (record.definition !== definition.name) throw new TypeError("saga definition mismatch")
1302
- if (record.state !== "running") return record
1303
- const context: SagaRunContext<C> = {
1304
- async step(name, compensationArgs, execute) {
1305
- throwIfAborted(signal)
1306
- assertToken(name, "saga step", 128)
1307
- if (typeof execute !== "function")
1308
- throw new TypeError("saga step execute must be a function")
1309
- if (typeof definition.compensators[name] !== "function")
1310
- throw new TypeError(`saga step ${name} has no compensator`)
1311
- record = await load(sagaId)
1312
- const existingIndex = record.steps.findIndex((step) => step.name === name)
1313
- if (existingIndex >= 0) {
1314
- const existing = record.steps[existingIndex] as SagaStepRecord
1315
- if (existing.state === "committed")
1316
- return cloneValue(existing.result) as Awaited<ReturnType<typeof execute>>
1317
- if (existing.state === "executing" || existing.state === "ambiguous") {
1318
- if (record.state !== "manual-review")
1319
- record = await save(record, { ...record, state: "manual-review", updatedAt: now() })
1320
- throw new SagaAmbiguousStepError(sagaId, name, existing.effectId)
1321
- }
1322
- throw new Error(`saga ${sagaId}: step ${name} cannot resume from ${existing.state}`)
1323
- }
1324
- const effectId = crypto.randomUUID()
1325
- const step: SagaStepRecord = Object.freeze({
1326
- name,
1327
- effectId,
1328
- compensationEffectId: crypto.randomUUID(),
1329
- state: "executing" as const,
1330
- compensationArgs: cloneValue(compensationArgs),
1331
- attempts: 0,
1332
- })
1333
- return await effectScope.run(
1334
- {
1335
- effectId,
1336
- capability: definition.capability ?? "saga.execute",
1337
- stage: "execution",
1338
- signal,
1339
- failurePhase: (error) =>
1340
- error instanceof SagaStepNotCommittedError ? "failed" : "ambiguous",
1341
- errorCode: (error, began) =>
1342
- error instanceof SagaStepNotCommittedError
1343
- ? error.code
1344
- : began
1345
- ? "execution_unknown"
1346
- : "aborted_before_execution",
1347
- transitions: {
1348
- async intent() {
1349
- record = await save(record, {
1350
- ...record,
1351
- steps: Object.freeze([...record.steps, step]),
1352
- updatedAt: now(),
1353
- })
1354
- },
1355
- async committed(_owned, result) {
1356
- record = await load(sagaId)
1357
- const index = record.steps.findIndex((candidate) => candidate.effectId === effectId)
1358
- const committed = {
1359
- ...(record.steps[index] as SagaStepRecord),
1360
- state: "committed" as const,
1361
- result: cloneValue(result),
1362
- }
1363
- record = await save(record, withStep(record, index, committed))
1364
- },
1365
- async failed(_owned, failure) {
1366
- record = await load(sagaId)
1367
- const index = record.steps.findIndex((candidate) => candidate.effectId === effectId)
1368
- const known = failure.error instanceof SagaStepNotCommittedError || !failure.began
1369
- const failed = {
1370
- ...(record.steps[index] as SagaStepRecord),
1371
- state: known ? ("failed" as const) : ("ambiguous" as const),
1372
- errorCode:
1373
- failure.error instanceof SagaStepNotCommittedError
1374
- ? failure.error.code
1375
- : failure.began
1376
- ? "execution_unknown"
1377
- : "aborted_before_execution",
1378
- }
1379
- record = await save(
1380
- record,
1381
- withStep(record, index, failed, known ? "compensating" : "manual-review"),
1382
- )
1383
- },
1384
- },
1385
- },
1386
- (owned) => execute({ effectId: owned.effectId, signal: owned.signal }),
1387
- )
1388
- },
1389
- }
1390
- try {
1391
- throwIfAborted(signal)
1392
- await definition.run(context, cloneValue(record.input) as I)
1393
- record = await load(sagaId)
1394
- if (record.state === "running")
1395
- record = await save(record, { ...record, state: "completed", updatedAt: now() })
1396
- return record
1397
- } catch (error) {
1398
- record = await load(sagaId)
1399
- if (record.state === "manual-review") throw error
1400
- await compensate(definition, sagaId, runOptions)
1401
- throw error
1402
- }
1403
- }
1404
-
1405
- const resolveAmbiguity = async <I, C extends Record<string, unknown>>(
1406
- definition: SagaDefinition<I, C>,
1407
- sagaId: string,
1408
- resolution: SagaAmbiguityResolution,
1409
- ): Promise<SagaRecord> => {
1410
- assertSagaDefinition(definition)
1411
- assertToken(sagaId, "saga id", 128)
1412
- if (typeof resolution !== "object" || resolution === null)
1413
- throw new TypeError("saga resolution must be an object")
1414
- if (
1415
- (resolution.kind === "execution" &&
1416
- resolution.outcome !== "committed" &&
1417
- resolution.outcome !== "not-committed") ||
1418
- (resolution.kind === "compensation" &&
1419
- resolution.outcome !== "compensated" &&
1420
- resolution.outcome !== "not-compensated") ||
1421
- (resolution.kind !== "execution" && resolution.kind !== "compensation")
1422
- ) {
1423
- throw new TypeError("saga resolution kind or outcome is invalid")
1424
- }
1425
- assertToken(resolution.step, "saga resolution step", 128)
1426
- assertToken(resolution.effectId, "saga resolution effect id", 64)
1427
- let record = await load(sagaId)
1428
- if (record.definition !== definition.name) throw new TypeError("saga definition mismatch")
1429
- const index = record.steps.findIndex((step) => step.name === resolution.step)
1430
- if (index < 0) throw new SagaResolutionError(sagaId, resolution.step, "does not exist")
1431
- const current = record.steps[index] as SagaStepRecord
1432
-
1433
- let step: SagaStepRecord
1434
- let state: SagaState
1435
- if (resolution.kind === "execution") {
1436
- if (current.effectId !== resolution.effectId)
1437
- throw new SagaResolutionError(sagaId, resolution.step, "execution effect id does not match")
1438
- if (current.state !== "executing" && current.state !== "ambiguous")
1439
- throw new SagaResolutionError(
1440
- sagaId,
1441
- resolution.step,
1442
- `is not awaiting an execution resolution (${current.state})`,
1443
- )
1444
- const { errorCode: _errorCode, result: _result, ...base } = current
1445
- if (resolution.outcome === "committed") {
1446
- step = { ...base, state: "committed", result: cloneValue(resolution.result) }
1447
- state = "running"
1448
- } else {
1449
- const errorCode = resolution.errorCode ?? "not_committed"
1450
- if (!ERROR_CODE.test(errorCode)) throw new TypeError("saga resolution errorCode is invalid")
1451
- step = { ...base, state: "failed", errorCode }
1452
- state = "compensating"
1453
- }
1454
- } else {
1455
- if (current.compensationEffectId !== resolution.effectId)
1456
- throw new SagaResolutionError(
1457
- sagaId,
1458
- resolution.step,
1459
- "compensation effect id does not match",
1460
- )
1461
- if (current.state !== "compensating")
1462
- throw new SagaResolutionError(
1463
- sagaId,
1464
- resolution.step,
1465
- `is not awaiting a compensation resolution (${current.state})`,
1466
- )
1467
- const { errorCode: _errorCode, nextAttemptAt: _nextAttemptAt, ...base } = current
1468
- step = {
1469
- ...base,
1470
- state: resolution.outcome === "compensated" ? "compensated" : "compensation-failed",
1471
- }
1472
- state =
1473
- resolution.outcome === "not-compensated" &&
1474
- current.attempts >= (definition.retry?.maxAttempts ?? 3)
1475
- ? "manual-review"
1476
- : "compensating"
1477
- }
1478
- record = await save(record, withStep(record, index, Object.freeze(step), state))
1479
- return record
1480
- }
1481
-
1482
- const engine: SagaEngine = {
1483
- async execute<I, C extends Record<string, unknown>>(
1484
- definition: SagaDefinition<I, C>,
1485
- sagaId: string,
1486
- input: I,
1487
- runOptions: { readonly signal?: AbortSignal } = {},
1488
- ) {
1489
- assertSagaDefinition(definition)
1490
- assertToken(sagaId, "saga id", 128)
1491
- throwIfAborted(signalOrNever(runOptions.signal))
1492
- const at = now()
1493
- const accepted = await options.store.create(
1494
- Object.freeze({
1495
- sagaId,
1496
- definition: definition.name,
1497
- state: "running" as const,
1498
- input: cloneValue(input),
1499
- steps: Object.freeze([]),
1500
- createdAt: at,
1501
- updatedAt: at,
1502
- version: 1,
1503
- }),
1504
- )
1505
- if (!accepted) throw new Error(`saga ${sagaId}: already exists`)
1506
- return await resume(definition, sagaId, runOptions)
1507
- },
1508
- resume,
1509
- compensate,
1510
- resolveAmbiguity,
1511
- }
1512
- return Object.freeze(engine)
1513
- }
1514
-
1515
- export class MemorySagaStore implements SagaStore {
1516
- readonly durability = "memory" as const
1517
- private readonly records = new Map<string, SagaRecord>()
1518
- // Secondary index: sagaIds grouped by state, so reconciliation scans only the requested (few,
1519
- // non-terminal) states instead of walking every retained record. See {@link indexedScan}.
1520
- private readonly byState = new Map<SagaState, Set<string>>()
1521
- create(record: SagaRecord): boolean {
1522
- if (this.records.has(record.sagaId)) return false
1523
- this.records.set(record.sagaId, Object.freeze(cloneValue(record)))
1524
- bucketAdd(this.byState, record.state, record.sagaId)
1525
- return true
1526
- }
1527
- get(sagaId: string): SagaRecord | undefined {
1528
- const record = this.records.get(sagaId)
1529
- return record === undefined ? undefined : Object.freeze(cloneValue(record))
1530
- }
1531
- compareAndSet(input: Parameters<SagaStore["compareAndSet"]>[0]): boolean {
1532
- const current = this.records.get(input.sagaId)
1533
- const next = durableSagaProtocol.transition(current, input)
1534
- if (next === undefined) return false
1535
- if (current === undefined) return false
1536
- this.records.set(input.sagaId, Object.freeze(next))
1537
- bucketMove(this.byState, current.state, input.record.state, input.sagaId)
1538
- return true
1539
- }
1540
- list(): readonly SagaRecord[] {
1541
- return Object.freeze(
1542
- [...this.records.values()].map((record) => Object.freeze(cloneValue(record))),
1543
- )
1544
- }
1545
-
1546
- scan(input: ReconciliationScanOptions<SagaState>): ReconciliationScanPage<SagaRecord> {
1547
- return indexedScan(this.byState, (id) => this.records.get(id), input)
1548
- }
1549
- }
1550
-
1551
- export interface SagaReconciliationFinding {
1552
- readonly sagaId: string
1553
- readonly state: SagaState
1554
- readonly step?: string
1555
- readonly effectId?: string
1556
- readonly reason:
1557
- | "stale-running"
1558
- | "ambiguous-execution"
1559
- | "ambiguous-compensation"
1560
- | "manual-review"
1561
- }
1562
-
1563
- async function scanSagaRecords(
1564
- store: SagaStore,
1565
- input: ReconciliationScanOptions<SagaState>,
1566
- ): Promise<ReconciliationScanPage<SagaRecord>> {
1567
- if (store.scan !== undefined) return await store.scan(input)
1568
- if (store.list === undefined) throw new TypeError("saga store does not support reconciliation")
1569
- return memoryScan(await store.list(), input)
1570
- }
1571
-
1572
- export async function reconcileSagasPage(
1573
- store: SagaStore,
1574
- options: { readonly staleBefore: number; readonly cursor?: string; readonly limit?: number },
1575
- ): Promise<ReconciliationPage<SagaReconciliationFinding>> {
1576
- const findings: SagaReconciliationFinding[] = []
1577
- const page = await scanSagaRecords(store, {
1578
- states: ["running", "compensating", "manual-review"],
1579
- ...(options.cursor === undefined ? {} : { cursor: options.cursor }),
1580
- limit: reconciliationLimit(options.limit),
1581
- })
1582
- for (const record of page.records) {
1583
- const ambiguous = record.steps.find(
1584
- (step) =>
1585
- step.state === "executing" || step.state === "ambiguous" || step.state === "compensating",
1586
- )
1587
- if (ambiguous !== undefined) {
1588
- findings.push(
1589
- Object.freeze({
1590
- sagaId: record.sagaId,
1591
- state: record.state,
1592
- step: ambiguous.name,
1593
- effectId:
1594
- ambiguous.state === "compensating"
1595
- ? ambiguous.compensationEffectId
1596
- : ambiguous.effectId,
1597
- reason:
1598
- ambiguous.state === "compensating" ? "ambiguous-compensation" : "ambiguous-execution",
1599
- }),
1600
- )
1601
- } else if (record.state === "manual-review") {
1602
- findings.push(
1603
- Object.freeze({ sagaId: record.sagaId, state: record.state, reason: "manual-review" }),
1604
- )
1605
- } else if (
1606
- (record.state === "running" || record.state === "compensating") &&
1607
- record.updatedAt <= options.staleBefore
1608
- ) {
1609
- findings.push(
1610
- Object.freeze({ sagaId: record.sagaId, state: record.state, reason: "stale-running" }),
1611
- )
1612
- }
1613
- }
1614
- return Object.freeze({
1615
- findings: Object.freeze(findings),
1616
- ...(page.cursor === undefined ? {} : { cursor: page.cursor }),
1617
- })
1618
- }
1619
-
1620
- export async function reconcileSagas(
1621
- store: SagaStore,
1622
- options: { readonly staleBefore: number },
1623
- ): Promise<readonly SagaReconciliationFinding[]> {
1624
- const page = await reconcileSagasPage(store, {
1625
- ...options,
1626
- limit: MAX_RECONCILIATION_LIMIT,
1627
- })
1628
- if (page.cursor !== undefined) {
1629
- throw new RangeError(
1630
- `saga reconciliation exceeds ${MAX_RECONCILIATION_LIMIT} records; use reconcileSagasPage()`,
1631
- )
1632
- }
1633
- return page.findings
1634
- }
15
+ export * from "./internal/durable-types.ts"