@nifrajs/core 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +27 -0
  2. package/dist/capabilities.d.ts +54 -2
  3. package/dist/capabilities.d.ts.map +1 -1
  4. package/dist/capabilities.js +240 -5
  5. package/dist/capabilities.js.map +1 -1
  6. package/dist/durable-adapters.d.ts +241 -0
  7. package/dist/durable-adapters.d.ts.map +1 -0
  8. package/dist/durable-adapters.js +697 -0
  9. package/dist/durable-adapters.js.map +1 -0
  10. package/dist/durable-execution.d.ts +349 -0
  11. package/dist/durable-execution.d.ts.map +1 -0
  12. package/dist/durable-execution.js +1173 -0
  13. package/dist/durable-execution.js.map +1 -0
  14. package/dist/effect-lifecycle.d.ts +42 -0
  15. package/dist/effect-lifecycle.d.ts.map +1 -0
  16. package/dist/effect-lifecycle.js +66 -0
  17. package/dist/effect-lifecycle.js.map +1 -0
  18. package/dist/effect-scope.d.ts +75 -0
  19. package/dist/effect-scope.d.ts.map +1 -0
  20. package/dist/effect-scope.js +177 -0
  21. package/dist/effect-scope.js.map +1 -0
  22. package/dist/idempotency-plugin.d.ts +5 -0
  23. package/dist/idempotency-plugin.d.ts.map +1 -1
  24. package/dist/idempotency-plugin.js +8 -0
  25. package/dist/idempotency-plugin.js.map +1 -1
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.js +1 -1
  28. package/dist/internal/capability-runtime.d.ts +62 -1
  29. package/dist/internal/capability-runtime.d.ts.map +1 -1
  30. package/dist/internal/capability-runtime.js +11 -2
  31. package/dist/internal/capability-runtime.js.map +1 -1
  32. package/dist/internal/effect-execution.d.ts +16 -0
  33. package/dist/internal/effect-execution.d.ts.map +1 -0
  34. package/dist/internal/effect-execution.js +50 -0
  35. package/dist/internal/effect-execution.js.map +1 -0
  36. package/dist/internal/safe-equal.d.ts +20 -0
  37. package/dist/internal/safe-equal.d.ts.map +1 -0
  38. package/dist/internal/safe-equal.js +31 -0
  39. package/dist/internal/safe-equal.js.map +1 -0
  40. package/dist/ledger.d.ts +14 -6
  41. package/dist/ledger.d.ts.map +1 -1
  42. package/dist/ledger.js +31 -5
  43. package/dist/ledger.js.map +1 -1
  44. package/dist/reconciliation-worker.d.ts +108 -0
  45. package/dist/reconciliation-worker.d.ts.map +1 -0
  46. package/dist/reconciliation-worker.js +239 -0
  47. package/dist/reconciliation-worker.js.map +1 -0
  48. package/dist/router/pattern.d.ts +26 -1
  49. package/dist/router/pattern.d.ts.map +1 -1
  50. package/dist/router/pattern.js +164 -8
  51. package/dist/router/pattern.js.map +1 -1
  52. package/dist/router/router.d.ts.map +1 -1
  53. package/dist/router/router.js +49 -1
  54. package/dist/router/router.js.map +1 -1
  55. package/dist/server/idempotency-lane.d.ts +1 -0
  56. package/dist/server/idempotency-lane.d.ts.map +1 -1
  57. package/dist/server/idempotency-lane.js +61 -37
  58. package/dist/server/idempotency-lane.js.map +1 -1
  59. package/dist/server/respond.d.ts.map +1 -1
  60. package/dist/server/respond.js.map +1 -1
  61. package/dist/server/server.d.ts +12 -0
  62. package/dist/server/server.d.ts.map +1 -1
  63. package/dist/server/server.js +33 -2
  64. package/dist/server/server.js.map +1 -1
  65. package/dist/server/transport-lane.d.ts +28 -0
  66. package/dist/server/transport-lane.d.ts.map +1 -0
  67. package/dist/server/transport-lane.js +106 -0
  68. package/dist/server/transport-lane.js.map +1 -0
  69. package/dist/server/websocket.d.ts +6 -0
  70. package/dist/server/websocket.d.ts.map +1 -1
  71. package/dist/server/websocket.js +10 -1
  72. package/dist/server/websocket.js.map +1 -1
  73. package/dist/transport-codec-rich.d.ts +8 -0
  74. package/dist/transport-codec-rich.d.ts.map +1 -0
  75. package/dist/transport-codec-rich.js +14 -0
  76. package/dist/transport-codec-rich.js.map +1 -0
  77. package/dist/transport-codec.d.ts +31 -0
  78. package/dist/transport-codec.d.ts.map +1 -0
  79. package/dist/transport-codec.js +214 -0
  80. package/dist/transport-codec.js.map +1 -0
  81. package/dist/transport-plugin.d.ts +3 -0
  82. package/dist/transport-plugin.d.ts.map +1 -0
  83. package/dist/transport-plugin.js +3 -0
  84. package/dist/transport-plugin.js.map +1 -0
  85. package/dist/wire.d.ts +58 -0
  86. package/dist/wire.d.ts.map +1 -0
  87. package/dist/wire.js +445 -0
  88. package/dist/wire.js.map +1 -0
  89. package/package.json +46 -1
  90. package/src/capabilities.ts +306 -5
  91. package/src/durable-adapters.ts +1039 -0
  92. package/src/durable-execution.ts +1676 -0
  93. package/src/effect-lifecycle.ts +120 -0
  94. package/src/effect-scope.ts +267 -0
  95. package/src/idempotency-plugin.ts +10 -0
  96. package/src/index.ts +1 -1
  97. package/src/internal/capability-runtime.ts +87 -1
  98. package/src/internal/effect-execution.ts +70 -0
  99. package/src/internal/safe-equal.ts +27 -0
  100. package/src/ledger.ts +45 -11
  101. package/src/reconciliation-worker.ts +372 -0
  102. package/src/router/pattern.ts +179 -8
  103. package/src/router/router.ts +75 -1
  104. package/src/server/idempotency-lane.ts +68 -34
  105. package/src/server/respond.ts +1 -0
  106. package/src/server/server.ts +46 -0
  107. package/src/server/transport-lane.ts +141 -0
  108. package/src/server/websocket.ts +15 -1
  109. package/src/transport-codec-rich.ts +20 -0
  110. package/src/transport-codec.ts +284 -0
  111. package/src/transport-plugin.ts +2 -0
  112. package/src/wire.ts +474 -0
package/src/ledger.ts CHANGED
@@ -27,18 +27,24 @@ const EFFECT_PHASES: ReadonlySet<string> = new Set(["intent", "committed", "fail
27
27
  */
28
28
  export type EffectCost = Readonly<Record<string, number>>
29
29
 
30
- /** Caller-supplied fields for one entry. Everything else (`seq`, `at`) is assigned by the ledger. */
31
- export interface EffectEntryInput {
32
- /** Capability token (`db.write`, `payments.charge`). Must be a valid capability id. */
33
- readonly capability: string
34
- /** Default `"intent"`. Record `committed`/`failed`/`compensated` only after the outcome is known. */
35
- readonly phase?: EffectPhase
30
+ /** Token-only caller metadata shared by an effect intent and outcome. */
31
+ export interface EffectMetadata {
36
32
  /** Adapter/resource token (`repo:orders`, `provider:payments`). A token — never a value or a row. */
37
33
  readonly target?: string
38
34
  /** Dimensionless counters; see {@link EffectCost}. At most {@link MAX_COST_AXES} axes. */
39
35
  readonly cost?: EffectCost
40
36
  /** Optional keyed-HMAC digest of the effect payload (see {@link computeEffectDigest}); hex, 64 chars. */
41
37
  readonly digest?: string
38
+ }
39
+
40
+ /** Caller-supplied fields for one entry. Everything else (`seq`, `at`) is assigned by the ledger. */
41
+ export interface EffectEntryInput extends EffectMetadata {
42
+ /** Opaque execution correlation token shared by one intent and its terminal outcome. */
43
+ readonly effectId?: string
44
+ /** Capability token (`db.write`, `payments.charge`). Must be a valid capability id. */
45
+ readonly capability: string
46
+ /** Default `"intent"`. Record `committed`/`failed`/`compensated` only after the outcome is known. */
47
+ readonly phase?: EffectPhase
42
48
  /** Outcome error as a bounded token code — never a message, never a stack. */
43
49
  readonly error?: { readonly code: string }
44
50
  }
@@ -49,6 +55,7 @@ export interface EffectEntry {
49
55
  readonly seq: number
50
56
  /** Milliseconds from the ledger's (injectable) clock at append time. */
51
57
  readonly at: number
58
+ readonly effectId?: string
52
59
  readonly capability: string
53
60
  readonly phase: EffectPhase
54
61
  readonly target?: string
@@ -147,9 +154,12 @@ function printableToken(value: string, maxLength: number): boolean {
147
154
  }
148
155
 
149
156
  function validateCost(cost: EffectCost): EffectCost {
157
+ if (Object(cost) !== cost || Array.isArray(cost)) {
158
+ throw new TypeError("effect ledger: invalid cost")
159
+ }
150
160
  const keys = Object.keys(cost)
151
161
  if (keys.length > MAX_COST_AXES) {
152
- throw new TypeError(`effect ledger: cost carries more than ${MAX_COST_AXES} axes`)
162
+ throw new TypeError(`effect ledger: more than ${MAX_COST_AXES} cost axes`)
153
163
  }
154
164
  for (const key of keys) {
155
165
  if (!validCapabilityId(key)) {
@@ -157,12 +167,30 @@ function validateCost(cost: EffectCost): EffectCost {
157
167
  }
158
168
  const value = cost[key]
159
169
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
160
- throw new TypeError(`effect ledger: cost axis ${key} must be a finite non-negative number`)
170
+ throw new TypeError(`effect ledger: invalid cost axis ${key}`)
161
171
  }
162
172
  }
163
173
  return Object.freeze({ ...cost })
164
174
  }
165
175
 
176
+ /** Validate, copy, and freeze token-only effect metadata before it reaches a ledger or policy hook. */
177
+ export function normalizeEffectMetadata(input: EffectMetadata): EffectMetadata {
178
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
179
+ throw new TypeError("effect ledger: effect metadata must be an object")
180
+ }
181
+ if (input.target !== undefined && !printableToken(input.target, TARGET_MAX_LENGTH)) {
182
+ throw new TypeError("effect ledger: invalid target")
183
+ }
184
+ if (input.digest !== undefined && !DIGEST_HEX.test(input.digest)) {
185
+ throw new TypeError("effect ledger: invalid digest")
186
+ }
187
+ return Object.freeze({
188
+ ...(input.target !== undefined ? { target: input.target } : {}),
189
+ ...(input.cost !== undefined ? { cost: validateCost(input.cost) } : {}),
190
+ ...(input.digest !== undefined ? { digest: input.digest } : {}),
191
+ })
192
+ }
193
+
166
194
  const defaultClock = (): number => performance.now()
167
195
 
168
196
  class BoundedRequestLedger implements RequestLedger {
@@ -215,11 +243,14 @@ class BoundedRequestLedger implements RequestLedger {
215
243
  if (!EFFECT_PHASES.has(phase)) {
216
244
  throw new TypeError(`effect ledger: invalid phase ${JSON.stringify(phase)}`)
217
245
  }
246
+ if (input.effectId !== undefined && !printableToken(input.effectId, 64)) {
247
+ throw new TypeError("effect ledger: invalid effectId")
248
+ }
218
249
  if (input.target !== undefined && !printableToken(input.target, TARGET_MAX_LENGTH)) {
219
- throw new TypeError("effect ledger: target must be a bounded printable token")
250
+ throw new TypeError("effect ledger: invalid target")
220
251
  }
221
252
  if (input.digest !== undefined && !DIGEST_HEX.test(input.digest)) {
222
- throw new TypeError("effect ledger: digest must be 64 lowercase hex characters")
253
+ throw new TypeError("effect ledger: invalid digest")
223
254
  }
224
255
  if (input.error !== undefined && !ERROR_CODE.test(input.error.code)) {
225
256
  throw new TypeError("effect ledger: error code must be a bounded lowercase token")
@@ -232,6 +263,7 @@ class BoundedRequestLedger implements RequestLedger {
232
263
  const entry: EffectEntry = Object.freeze({
233
264
  seq: this.items.length,
234
265
  at,
266
+ ...(input.effectId !== undefined ? { effectId: input.effectId } : {}),
235
267
  capability: input.capability,
236
268
  phase,
237
269
  ...(input.target !== undefined ? { target: input.target } : {}),
@@ -293,7 +325,9 @@ function canonicalEntry(entry: EffectEntry): string {
293
325
  .map((key) => `${JSON.stringify(key)}:${JSON.stringify((entry.cost as EffectCost)[key])}`)
294
326
  .join(",")}}`
295
327
  return (
296
- `{"seq":${entry.seq},"at":${JSON.stringify(entry.at)},"capability":${JSON.stringify(entry.capability)},"phase":${JSON.stringify(entry.phase)}` +
328
+ `{"seq":${entry.seq},"at":${JSON.stringify(entry.at)}` +
329
+ `${entry.effectId !== undefined ? `,"effectId":${JSON.stringify(entry.effectId)}` : ""}` +
330
+ `,"capability":${JSON.stringify(entry.capability)},"phase":${JSON.stringify(entry.phase)}` +
297
331
  `${entry.target !== undefined ? `,"target":${JSON.stringify(entry.target)}` : ""}` +
298
332
  cost +
299
333
  `${entry.digest !== undefined ? `,"digest":${JSON.stringify(entry.digest)}` : ""}` +
@@ -0,0 +1,372 @@
1
+ /** Leased, cursor-checkpointed, bounded reconciliation worker orchestration. */
2
+ import {
3
+ type DurableEffectStore,
4
+ type EffectReconciliationFinding,
5
+ reconcileEffectsPage,
6
+ reconcileSagasPage,
7
+ type SagaReconciliationFinding,
8
+ type SagaStore,
9
+ } from "./durable-execution.ts"
10
+ import { safeEqual } from "./internal/safe-equal.ts"
11
+
12
+ const TOKEN = /^[!-~]{1,128}$/
13
+
14
+ export interface ReconciliationLease {
15
+ readonly name: string
16
+ readonly owner: string
17
+ readonly token: string
18
+ readonly expiresAt: number
19
+ readonly cursor?: string
20
+ }
21
+
22
+ export interface ReconciliationLeaseStore {
23
+ readonly durability?: "memory" | "durable"
24
+ acquire(input: {
25
+ readonly name: string
26
+ readonly owner: string
27
+ readonly now: number
28
+ readonly leaseMs: number
29
+ }): ReconciliationLease | undefined | Promise<ReconciliationLease | undefined>
30
+ renew(input: {
31
+ readonly name: string
32
+ readonly owner: string
33
+ readonly token: string
34
+ readonly now: number
35
+ readonly leaseMs: number
36
+ }): boolean | Promise<boolean>
37
+ checkpoint(input: {
38
+ readonly name: string
39
+ readonly owner: string
40
+ readonly token: string
41
+ readonly cursor?: string
42
+ }): boolean | Promise<boolean>
43
+ release(input: {
44
+ readonly name: string
45
+ readonly owner: string
46
+ readonly token: string
47
+ }): boolean | Promise<boolean>
48
+ }
49
+
50
+ export type ReconciliationWorkerEvent =
51
+ | { readonly type: "acquired" | "released" | "lease-lost"; readonly name: string }
52
+ | {
53
+ readonly type: "page" | "checkpoint"
54
+ readonly name: string
55
+ readonly scanned: number
56
+ readonly handled: number
57
+ }
58
+ | { readonly type: "failed"; readonly name: string; readonly errorCode: "scan" | "handle" }
59
+
60
+ export interface ReconciliationWorkerOptions<Finding> {
61
+ readonly name: string
62
+ readonly owner: string
63
+ readonly leases: ReconciliationLeaseStore
64
+ readonly leaseMs?: number
65
+ readonly batchSize?: number
66
+ readonly maxPages?: number
67
+ readonly concurrency?: number
68
+ readonly signal?: AbortSignal
69
+ readonly now?: () => number
70
+ readonly scan: (input: {
71
+ readonly cursor?: string
72
+ readonly limit: number
73
+ readonly signal: AbortSignal
74
+ }) =>
75
+ | { readonly findings: readonly Finding[]; readonly cursor?: string }
76
+ | Promise<{
77
+ readonly findings: readonly Finding[]
78
+ readonly cursor?: string
79
+ }>
80
+ readonly filter?: (finding: Finding) => boolean
81
+ readonly handle: (finding: Finding, signal: AbortSignal) => void | PromiseLike<void>
82
+ readonly observe?: (event: ReconciliationWorkerEvent) => void
83
+ }
84
+
85
+ export interface ReconciliationWorkerResult {
86
+ readonly acquired: boolean
87
+ readonly pages: number
88
+ readonly scanned: number
89
+ readonly handled: number
90
+ }
91
+
92
+ function positive(value: number, label: string, max: number): number {
93
+ if (!Number.isSafeInteger(value) || value < 1 || value > max)
94
+ throw new RangeError(`${label} must be an integer between 1 and ${max}`)
95
+ return value
96
+ }
97
+
98
+ function timestamp(value: number): number {
99
+ if (!Number.isSafeInteger(value) || value < 0)
100
+ throw new RangeError("reconciliation clock must return a non-negative safe integer")
101
+ return value
102
+ }
103
+
104
+ function assertToken(value: string, label: string): void {
105
+ if (!TOKEN.test(value)) throw new TypeError(`${label} must be a bounded printable token`)
106
+ }
107
+
108
+ function throwIfAborted(signal: AbortSignal): void {
109
+ if (signal.aborted)
110
+ throw signal.reason instanceof Error ? signal.reason : new Error("reconciliation aborted")
111
+ }
112
+
113
+ async function boundedMap<T>(
114
+ values: readonly T[],
115
+ concurrency: number,
116
+ run: (value: T) => Promise<void>,
117
+ ): Promise<void> {
118
+ let next = 0
119
+ const worker = async (): Promise<void> => {
120
+ for (;;) {
121
+ const index = next++
122
+ if (index >= values.length) return
123
+ await run(values[index] as T)
124
+ }
125
+ }
126
+ await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, () => worker()))
127
+ }
128
+
129
+ export async function runReconciliationWorker<Finding>(
130
+ options: ReconciliationWorkerOptions<Finding>,
131
+ ): Promise<ReconciliationWorkerResult> {
132
+ assertToken(options.name, "reconciliation worker name")
133
+ assertToken(options.owner, "reconciliation worker owner")
134
+ const leaseMs = positive(options.leaseMs ?? 30_000, "reconciliation leaseMs", 86_400_000)
135
+ const batchSize = positive(options.batchSize ?? 100, "reconciliation batchSize", 1_000)
136
+ const maxPages = positive(options.maxPages ?? 10, "reconciliation maxPages", 1_000)
137
+ const concurrency = positive(options.concurrency ?? 4, "reconciliation concurrency", 64)
138
+ const now = options.now ?? Date.now
139
+ const signal = options.signal ?? new AbortController().signal
140
+ throwIfAborted(signal)
141
+ const lease = await options.leases.acquire({
142
+ name: options.name,
143
+ owner: options.owner,
144
+ now: timestamp(now()),
145
+ leaseMs,
146
+ })
147
+ if (lease === undefined) return { acquired: false, pages: 0, scanned: 0, handled: 0 }
148
+ if (
149
+ lease.name !== options.name ||
150
+ lease.owner !== options.owner ||
151
+ !TOKEN.test(lease.token) ||
152
+ !Number.isSafeInteger(lease.expiresAt) ||
153
+ lease.expiresAt <= 0 ||
154
+ (lease.cursor !== undefined && !/^[ -~]{1,1024}$/u.test(lease.cursor))
155
+ ) {
156
+ throw new TypeError("reconciliation lease store returned an invalid lease")
157
+ }
158
+ options.observe?.({ type: "acquired", name: options.name })
159
+ let cursor = lease.cursor
160
+ let pages = 0
161
+ let scanned = 0
162
+ let handled = 0
163
+ try {
164
+ while (pages < maxPages) {
165
+ throwIfAborted(signal)
166
+ const renewed = await options.leases.renew({
167
+ name: options.name,
168
+ owner: options.owner,
169
+ token: lease.token,
170
+ now: timestamp(now()),
171
+ leaseMs,
172
+ })
173
+ if (!renewed) {
174
+ options.observe?.({ type: "lease-lost", name: options.name })
175
+ throw new Error("reconciliation lease lost")
176
+ }
177
+ let page: Awaited<ReturnType<typeof options.scan>>
178
+ try {
179
+ page = await options.scan({
180
+ ...(cursor === undefined ? {} : { cursor }),
181
+ limit: batchSize,
182
+ signal,
183
+ })
184
+ } catch (error) {
185
+ options.observe?.({ type: "failed", name: options.name, errorCode: "scan" })
186
+ throw error
187
+ }
188
+ if (!Array.isArray(page.findings) || page.findings.length > batchSize)
189
+ throw new TypeError("reconciliation scan returned an invalid or oversized page")
190
+ pages++
191
+ scanned += page.findings.length
192
+ const selected =
193
+ options.filter === undefined ? page.findings : page.findings.filter(options.filter)
194
+ try {
195
+ await boundedMap(selected, concurrency, async (finding) => {
196
+ throwIfAborted(signal)
197
+ await options.handle(finding, signal)
198
+ handled++
199
+ })
200
+ } catch (error) {
201
+ options.observe?.({ type: "failed", name: options.name, errorCode: "handle" })
202
+ throw error
203
+ }
204
+ const renewedAfterWork = await options.leases.renew({
205
+ name: options.name,
206
+ owner: options.owner,
207
+ token: lease.token,
208
+ now: timestamp(now()),
209
+ leaseMs,
210
+ })
211
+ if (!renewedAfterWork) {
212
+ options.observe?.({ type: "lease-lost", name: options.name })
213
+ throw new Error("reconciliation lease expired during page handling")
214
+ }
215
+ options.observe?.({
216
+ type: "page",
217
+ name: options.name,
218
+ scanned: page.findings.length,
219
+ handled: selected.length,
220
+ })
221
+ const checkpointed = await options.leases.checkpoint({
222
+ name: options.name,
223
+ owner: options.owner,
224
+ token: lease.token,
225
+ ...(page.cursor === undefined ? {} : { cursor: page.cursor }),
226
+ })
227
+ if (!checkpointed) {
228
+ options.observe?.({ type: "lease-lost", name: options.name })
229
+ throw new Error("reconciliation lease lost before checkpoint")
230
+ }
231
+ options.observe?.({
232
+ type: "checkpoint",
233
+ name: options.name,
234
+ scanned,
235
+ handled,
236
+ })
237
+ cursor = page.cursor
238
+ if (cursor === undefined) break
239
+ }
240
+ return { acquired: true, pages, scanned, handled }
241
+ } finally {
242
+ if (
243
+ await options.leases.release({
244
+ name: options.name,
245
+ owner: options.owner,
246
+ token: lease.token,
247
+ })
248
+ ) {
249
+ options.observe?.({ type: "released", name: options.name })
250
+ }
251
+ }
252
+ }
253
+
254
+ interface MemoryLeaseRecord {
255
+ owner?: string
256
+ token?: string
257
+ expiresAt?: number
258
+ cursor?: string
259
+ }
260
+
261
+ export class MemoryReconciliationLeaseStore implements ReconciliationLeaseStore {
262
+ readonly durability = "memory" as const
263
+ private readonly records = new Map<string, MemoryLeaseRecord>()
264
+
265
+ acquire(
266
+ input: Parameters<ReconciliationLeaseStore["acquire"]>[0],
267
+ ): ReconciliationLease | undefined {
268
+ assertToken(input.name, "reconciliation lease name")
269
+ assertToken(input.owner, "reconciliation lease owner")
270
+ const current = this.records.get(input.name)
271
+ if (
272
+ current?.owner !== undefined &&
273
+ current.expiresAt !== undefined &&
274
+ current.expiresAt > input.now
275
+ ) {
276
+ return undefined
277
+ }
278
+ const token = crypto.randomUUID()
279
+ const expiresAt = input.now + input.leaseMs
280
+ if (!Number.isSafeInteger(expiresAt)) throw new RangeError("reconciliation lease overflow")
281
+ this.records.set(input.name, {
282
+ owner: input.owner,
283
+ token,
284
+ expiresAt,
285
+ ...(current?.cursor === undefined ? {} : { cursor: current.cursor }),
286
+ })
287
+ return Object.freeze({
288
+ name: input.name,
289
+ owner: input.owner,
290
+ token,
291
+ expiresAt,
292
+ ...(current?.cursor === undefined ? {} : { cursor: current.cursor }),
293
+ })
294
+ }
295
+
296
+ renew(input: Parameters<ReconciliationLeaseStore["renew"]>[0]): boolean {
297
+ const record = this.records.get(input.name)
298
+ if (
299
+ record?.owner !== input.owner ||
300
+ !safeEqual(record.token, input.token) ||
301
+ (record.expiresAt ?? 0) <= input.now
302
+ )
303
+ return false
304
+ const expiresAt = input.now + input.leaseMs
305
+ if (!Number.isSafeInteger(expiresAt)) throw new RangeError("reconciliation lease overflow")
306
+ record.expiresAt = expiresAt
307
+ return true
308
+ }
309
+
310
+ checkpoint(input: Parameters<ReconciliationLeaseStore["checkpoint"]>[0]): boolean {
311
+ const record = this.records.get(input.name)
312
+ if (record?.owner !== input.owner || !safeEqual(record.token, input.token)) return false
313
+ if (input.cursor === undefined) delete record.cursor
314
+ else record.cursor = input.cursor
315
+ return true
316
+ }
317
+
318
+ release(input: Parameters<ReconciliationLeaseStore["release"]>[0]): boolean {
319
+ const record = this.records.get(input.name)
320
+ if (record?.owner !== input.owner || !safeEqual(record.token, input.token)) return false
321
+ delete record.owner
322
+ delete record.token
323
+ delete record.expiresAt
324
+ return true
325
+ }
326
+ }
327
+
328
+ interface SpecializedWorkerOptions<Finding> {
329
+ readonly name: string
330
+ readonly owner: string
331
+ readonly leases: ReconciliationLeaseStore
332
+ readonly staleBefore: number
333
+ readonly leaseMs?: number
334
+ readonly batchSize?: number
335
+ readonly maxPages?: number
336
+ readonly concurrency?: number
337
+ readonly signal?: AbortSignal
338
+ readonly now?: () => number
339
+ readonly filter?: (finding: Finding) => boolean
340
+ readonly handle: (finding: Finding, signal: AbortSignal) => void | PromiseLike<void>
341
+ readonly observe?: (event: ReconciliationWorkerEvent) => void
342
+ }
343
+
344
+ export function runEffectReconciliationWorker(
345
+ store: DurableEffectStore,
346
+ options: SpecializedWorkerOptions<EffectReconciliationFinding>,
347
+ ): Promise<ReconciliationWorkerResult> {
348
+ return runReconciliationWorker({
349
+ ...options,
350
+ scan: ({ cursor, limit }) =>
351
+ reconcileEffectsPage(store, {
352
+ staleBefore: options.staleBefore,
353
+ ...(cursor === undefined ? {} : { cursor }),
354
+ limit,
355
+ }),
356
+ })
357
+ }
358
+
359
+ export function runSagaReconciliationWorker(
360
+ store: SagaStore,
361
+ options: SpecializedWorkerOptions<SagaReconciliationFinding>,
362
+ ): Promise<ReconciliationWorkerResult> {
363
+ return runReconciliationWorker({
364
+ ...options,
365
+ scan: ({ cursor, limit }) =>
366
+ reconcileSagasPage(store, {
367
+ staleBefore: options.staleBefore,
368
+ ...(cursor === undefined ? {} : { cursor }),
369
+ limit,
370
+ }),
371
+ })
372
+ }