@nifrajs/core 1.13.0 → 2.1.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 (233) hide show
  1. package/README.md +37 -1
  2. package/dist/assurance.d.ts +11 -2
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +20 -0
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +54 -2
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +240 -5
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/durable-adapters.d.ts +241 -0
  11. package/dist/durable-adapters.d.ts.map +1 -0
  12. package/dist/durable-adapters.js +697 -0
  13. package/dist/durable-adapters.js.map +1 -0
  14. package/dist/durable-execution.d.ts +349 -0
  15. package/dist/durable-execution.d.ts.map +1 -0
  16. package/dist/durable-execution.js +1173 -0
  17. package/dist/durable-execution.js.map +1 -0
  18. package/dist/effect-ledger.d.ts +9 -0
  19. package/dist/effect-ledger.d.ts.map +1 -0
  20. package/dist/effect-ledger.js +8 -0
  21. package/dist/effect-ledger.js.map +1 -0
  22. package/dist/effect-lifecycle.d.ts +42 -0
  23. package/dist/effect-lifecycle.d.ts.map +1 -0
  24. package/dist/effect-lifecycle.js +66 -0
  25. package/dist/effect-lifecycle.js.map +1 -0
  26. package/dist/effect-scope.d.ts +75 -0
  27. package/dist/effect-scope.d.ts.map +1 -0
  28. package/dist/effect-scope.js +177 -0
  29. package/dist/effect-scope.js.map +1 -0
  30. package/dist/idempotency-plugin.d.ts +13 -0
  31. package/dist/idempotency-plugin.d.ts.map +1 -0
  32. package/dist/idempotency-plugin.js +16 -0
  33. package/dist/idempotency-plugin.js.map +1 -0
  34. package/dist/index.d.ts +6 -35
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +6 -31
  37. package/dist/index.js.map +1 -1
  38. package/dist/internal/capability-runtime.d.ts +62 -1
  39. package/dist/internal/capability-runtime.d.ts.map +1 -1
  40. package/dist/internal/capability-runtime.js +11 -2
  41. package/dist/internal/capability-runtime.js.map +1 -1
  42. package/dist/internal/effect-execution.d.ts +16 -0
  43. package/dist/internal/effect-execution.d.ts.map +1 -0
  44. package/dist/internal/effect-execution.js +50 -0
  45. package/dist/internal/effect-execution.js.map +1 -0
  46. package/dist/internal/safe-equal.d.ts +20 -0
  47. package/dist/internal/safe-equal.d.ts.map +1 -0
  48. package/dist/internal/safe-equal.js +31 -0
  49. package/dist/internal/safe-equal.js.map +1 -0
  50. package/dist/ledger.d.ts +14 -6
  51. package/dist/ledger.d.ts.map +1 -1
  52. package/dist/ledger.js +31 -5
  53. package/dist/ledger.js.map +1 -1
  54. package/dist/mcp.d.ts +7 -0
  55. package/dist/mcp.d.ts.map +1 -0
  56. package/dist/mcp.js +56 -0
  57. package/dist/mcp.js.map +1 -0
  58. package/dist/node-direct.d.ts +5 -0
  59. package/dist/node-direct.d.ts.map +1 -0
  60. package/dist/node-direct.js +27 -0
  61. package/dist/node-direct.js.map +1 -0
  62. package/dist/reconciliation-worker.d.ts +108 -0
  63. package/dist/reconciliation-worker.d.ts.map +1 -0
  64. package/dist/reconciliation-worker.js +239 -0
  65. package/dist/reconciliation-worker.js.map +1 -0
  66. package/dist/reflection.d.ts +5 -0
  67. package/dist/reflection.d.ts.map +1 -1
  68. package/dist/reflection.js +2 -0
  69. package/dist/reflection.js.map +1 -1
  70. package/dist/server/client-ip.d.ts +35 -0
  71. package/dist/server/client-ip.d.ts.map +1 -0
  72. package/dist/server/client-ip.js +42 -0
  73. package/dist/server/client-ip.js.map +1 -0
  74. package/dist/server/context.d.ts +47 -2
  75. package/dist/server/context.d.ts.map +1 -1
  76. package/dist/server/contract.d.ts +14 -4
  77. package/dist/server/contract.d.ts.map +1 -1
  78. package/dist/server/contract.js +8 -2
  79. package/dist/server/contract.js.map +1 -1
  80. package/dist/server/http.d.ts +14 -0
  81. package/dist/server/http.d.ts.map +1 -0
  82. package/dist/server/http.js +45 -0
  83. package/dist/server/http.js.map +1 -0
  84. package/dist/server/idempotency-lane.d.ts +45 -0
  85. package/dist/server/idempotency-lane.d.ts.map +1 -0
  86. package/dist/server/idempotency-lane.js +205 -0
  87. package/dist/server/idempotency-lane.js.map +1 -0
  88. package/dist/server/install.d.ts +21 -0
  89. package/dist/server/install.d.ts.map +1 -0
  90. package/dist/server/install.js +21 -0
  91. package/dist/server/install.js.map +1 -0
  92. package/dist/server/ledger-lane.d.ts +53 -0
  93. package/dist/server/ledger-lane.d.ts.map +1 -0
  94. package/dist/server/ledger-lane.js +82 -0
  95. package/dist/server/ledger-lane.js.map +1 -0
  96. package/dist/server/mcp-hook.d.ts +28 -0
  97. package/dist/server/mcp-hook.d.ts.map +1 -0
  98. package/dist/server/mcp-hook.js +2 -0
  99. package/dist/server/mcp-hook.js.map +1 -0
  100. package/dist/server/node-outcome-hook.d.ts +9 -0
  101. package/dist/server/node-outcome-hook.d.ts.map +1 -0
  102. package/dist/server/node-outcome-hook.js +2 -0
  103. package/dist/server/node-outcome-hook.js.map +1 -0
  104. package/dist/server/node-outcome.d.ts +37 -0
  105. package/dist/server/node-outcome.d.ts.map +1 -0
  106. package/dist/server/node-outcome.js +75 -0
  107. package/dist/server/node-outcome.js.map +1 -0
  108. package/dist/server/plugin.d.ts +78 -0
  109. package/dist/server/plugin.d.ts.map +1 -0
  110. package/dist/server/plugin.js +57 -0
  111. package/dist/server/plugin.js.map +1 -0
  112. package/dist/server/query.d.ts +18 -0
  113. package/dist/server/query.d.ts.map +1 -0
  114. package/dist/server/query.js +102 -0
  115. package/dist/server/query.js.map +1 -0
  116. package/dist/server/registry.d.ts +31 -6
  117. package/dist/server/registry.d.ts.map +1 -1
  118. package/dist/server/request-context.d.ts +51 -0
  119. package/dist/server/request-context.d.ts.map +1 -0
  120. package/dist/server/request-context.js +184 -0
  121. package/dist/server/request-context.js.map +1 -0
  122. package/dist/server/respond.d.ts +17 -0
  123. package/dist/server/respond.d.ts.map +1 -0
  124. package/dist/server/respond.js +81 -0
  125. package/dist/server/respond.js.map +1 -0
  126. package/dist/server/runtime-core.d.ts +36 -0
  127. package/dist/server/runtime-core.d.ts.map +1 -0
  128. package/dist/server/runtime-core.js +51 -0
  129. package/dist/server/runtime-core.js.map +1 -0
  130. package/dist/server/server-types.d.ts +203 -0
  131. package/dist/server/server-types.d.ts.map +1 -0
  132. package/dist/server/server-types.js +2 -0
  133. package/dist/server/server-types.js.map +1 -0
  134. package/dist/server/server.d.ts +119 -345
  135. package/dist/server/server.d.ts.map +1 -1
  136. package/dist/server/server.js +247 -833
  137. package/dist/server/server.js.map +1 -1
  138. package/dist/server/sse-hook.d.ts +10 -0
  139. package/dist/server/sse-hook.d.ts.map +1 -0
  140. package/dist/server/sse-hook.js +2 -0
  141. package/dist/server/sse-hook.js.map +1 -0
  142. package/dist/server/sse.d.ts +7 -23
  143. package/dist/server/sse.d.ts.map +1 -1
  144. package/dist/server/sse.js +18 -0
  145. package/dist/server/sse.js.map +1 -1
  146. package/dist/server/transport-lane.d.ts +28 -0
  147. package/dist/server/transport-lane.d.ts.map +1 -0
  148. package/dist/server/transport-lane.js +106 -0
  149. package/dist/server/transport-lane.js.map +1 -0
  150. package/dist/server/websocket.d.ts +23 -1
  151. package/dist/server/websocket.d.ts.map +1 -1
  152. package/dist/server/websocket.js +10 -1
  153. package/dist/server/websocket.js.map +1 -1
  154. package/dist/server/ws-hook.d.ts +8 -11
  155. package/dist/server/ws-hook.d.ts.map +1 -1
  156. package/dist/server/ws-hook.js +7 -14
  157. package/dist/server/ws-hook.js.map +1 -1
  158. package/dist/server.d.ts +21 -0
  159. package/dist/server.d.ts.map +1 -0
  160. package/dist/server.js +16 -0
  161. package/dist/server.js.map +1 -0
  162. package/dist/transport-codec-rich.d.ts +8 -0
  163. package/dist/transport-codec-rich.d.ts.map +1 -0
  164. package/dist/transport-codec-rich.js +14 -0
  165. package/dist/transport-codec-rich.js.map +1 -0
  166. package/dist/transport-codec.d.ts +31 -0
  167. package/dist/transport-codec.d.ts.map +1 -0
  168. package/dist/transport-codec.js +190 -0
  169. package/dist/transport-codec.js.map +1 -0
  170. package/dist/transport-plugin.d.ts +3 -0
  171. package/dist/transport-plugin.d.ts.map +1 -0
  172. package/dist/transport-plugin.js +3 -0
  173. package/dist/transport-plugin.js.map +1 -0
  174. package/dist/wire.d.ts +58 -0
  175. package/dist/wire.d.ts.map +1 -0
  176. package/dist/wire.js +445 -0
  177. package/dist/wire.js.map +1 -0
  178. package/dist/ws.d.ts +16 -7
  179. package/dist/ws.d.ts.map +1 -1
  180. package/dist/ws.js +25 -10
  181. package/dist/ws.js.map +1 -1
  182. package/package.json +122 -6
  183. package/src/assurance.ts +36 -1
  184. package/src/capabilities.ts +306 -5
  185. package/src/durable-adapters.ts +1039 -0
  186. package/src/durable-execution.ts +1676 -0
  187. package/src/effect-ledger.ts +9 -0
  188. package/src/effect-lifecycle.ts +120 -0
  189. package/src/effect-scope.ts +267 -0
  190. package/src/idempotency-plugin.ts +17 -0
  191. package/src/index.ts +6 -133
  192. package/src/internal/capability-runtime.ts +87 -1
  193. package/src/internal/effect-execution.ts +70 -0
  194. package/src/internal/safe-equal.ts +27 -0
  195. package/src/ledger.ts +45 -11
  196. package/src/mcp.ts +65 -0
  197. package/src/node-direct.ts +37 -0
  198. package/src/reconciliation-worker.ts +372 -0
  199. package/src/reflection.ts +7 -0
  200. package/src/server/client-ip.ts +60 -0
  201. package/src/server/context.ts +50 -2
  202. package/src/server/contract.ts +26 -7
  203. package/src/server/http.ts +58 -0
  204. package/src/server/idempotency-lane.ts +321 -0
  205. package/src/server/install.ts +28 -0
  206. package/src/server/ledger-lane.ts +125 -0
  207. package/src/server/mcp-hook.ts +41 -0
  208. package/src/server/node-outcome-hook.ts +9 -0
  209. package/src/server/node-outcome.ts +112 -0
  210. package/src/server/plugin.ts +92 -0
  211. package/src/server/query.ts +119 -0
  212. package/src/server/registry.ts +36 -6
  213. package/src/server/request-context.ts +271 -0
  214. package/src/server/respond.ts +94 -0
  215. package/src/server/runtime-core.ts +74 -0
  216. package/src/server/server-types.ts +204 -0
  217. package/src/server/server.ts +448 -1314
  218. package/src/server/sse-hook.ts +14 -0
  219. package/src/server/sse.ts +26 -0
  220. package/src/server/transport-lane.ts +141 -0
  221. package/src/server/websocket.ts +35 -1
  222. package/src/server/ws-hook.ts +8 -19
  223. package/src/server.ts +92 -0
  224. package/src/transport-codec-rich.ts +20 -0
  225. package/src/transport-codec.ts +250 -0
  226. package/src/transport-plugin.ts +2 -0
  227. package/src/wire.ts +474 -0
  228. package/src/ws.ts +33 -10
  229. package/dist/invariants.d.ts +0 -70
  230. package/dist/invariants.d.ts.map +0 -1
  231. package/dist/invariants.js +0 -487
  232. package/dist/invariants.js.map +0 -1
  233. package/src/invariants.ts +0 -604
@@ -0,0 +1,1039 @@
1
+ /**
2
+ * Common durable execution persistence adapter. Concrete storage backends only implement atomic
3
+ * insert/CAS/scan/lease primitives; these views supply the effect, approval, saga, and worker APIs.
4
+ */
5
+ import type {
6
+ ApprovalConsumeResult,
7
+ ApprovalRecord,
8
+ ApprovalStore,
9
+ DurableEffectRecord,
10
+ DurableEffectStore,
11
+ ReconciliationScanOptions,
12
+ ReconciliationScanPage,
13
+ SagaRecord,
14
+ SagaStore,
15
+ } from "./durable-execution.ts"
16
+ import { safeEqual } from "./internal/safe-equal.ts"
17
+ import type { ReconciliationLease, ReconciliationLeaseStore } from "./reconciliation-worker.ts"
18
+ import { parse as parseWire, stringify as stringifyWire } from "./wire.ts"
19
+
20
+ export type DurableRecordKind = "effect" | "approval" | "saga"
21
+
22
+ export interface DurableRecordBackend {
23
+ create(
24
+ kind: DurableRecordKind,
25
+ id: string,
26
+ record: {
27
+ readonly version: number
28
+ readonly state?: string
29
+ readonly updatedAt?: number
30
+ },
31
+ ): Promise<boolean>
32
+ get<T extends { readonly version: number }>(
33
+ kind: DurableRecordKind,
34
+ id: string,
35
+ ): Promise<T | undefined>
36
+ compareAndSet(
37
+ kind: DurableRecordKind,
38
+ id: string,
39
+ expectedVersion: number,
40
+ record: {
41
+ readonly version: number
42
+ readonly state?: string
43
+ readonly updatedAt?: number
44
+ },
45
+ ): Promise<boolean>
46
+ scan<T extends { readonly state: string; readonly updatedAt: number }>(
47
+ kind: DurableRecordKind,
48
+ input: ReconciliationScanOptions<string>,
49
+ ): Promise<ReconciliationScanPage<T>>
50
+ acquireLease(input: {
51
+ readonly name: string
52
+ readonly owner: string
53
+ readonly now: number
54
+ readonly leaseMs: number
55
+ }): Promise<ReconciliationLease | undefined>
56
+ renewLease(input: {
57
+ readonly name: string
58
+ readonly owner: string
59
+ readonly token: string
60
+ readonly now: number
61
+ readonly leaseMs: number
62
+ }): Promise<boolean>
63
+ checkpointLease(input: {
64
+ readonly name: string
65
+ readonly owner: string
66
+ readonly token: string
67
+ readonly cursor?: string
68
+ }): Promise<boolean>
69
+ releaseLease(input: {
70
+ readonly name: string
71
+ readonly owner: string
72
+ readonly token: string
73
+ }): Promise<boolean>
74
+ }
75
+
76
+ export interface DurableExecutionAdapter {
77
+ readonly effects: DurableEffectStore
78
+ readonly approvals: ApprovalStore
79
+ readonly sagas: SagaStore
80
+ readonly leases: ReconciliationLeaseStore
81
+ }
82
+
83
+ function clone<T>(value: T): T {
84
+ return structuredClone(value)
85
+ }
86
+
87
+ export function createDurableExecutionAdapter(
88
+ backend: DurableRecordBackend,
89
+ ): DurableExecutionAdapter {
90
+ const effects: DurableEffectStore = {
91
+ durability: "durable",
92
+ create: (record) => backend.create("effect", record.effectId, clone(record)),
93
+ get: (effectId) => backend.get<DurableEffectRecord>("effect", effectId),
94
+ async transition(input) {
95
+ const current = await backend.get<DurableEffectRecord>("effect", input.effectId)
96
+ if (
97
+ current === undefined ||
98
+ current.version !== input.version ||
99
+ current.state !== input.from
100
+ ) {
101
+ return false
102
+ }
103
+ const next: DurableEffectRecord = {
104
+ ...current,
105
+ state: input.to,
106
+ updatedAt: input.updatedAt,
107
+ version: current.version + 1,
108
+ ...(input.errorCode === undefined ? {} : { errorCode: input.errorCode }),
109
+ }
110
+ return await backend.compareAndSet("effect", input.effectId, input.version, next)
111
+ },
112
+ scan: (input) =>
113
+ backend.scan<DurableEffectRecord>("effect", input as ReconciliationScanOptions<string>),
114
+ }
115
+
116
+ const approvals: ApprovalStore = {
117
+ durability: "durable",
118
+ create: (record) => backend.create("approval", record.approvalId, clone(record)),
119
+ get: (approvalId) => backend.get<ApprovalRecord>("approval", approvalId),
120
+ async decide(input) {
121
+ const current = await backend.get<ApprovalRecord>("approval", input.approvalId)
122
+ if (
123
+ current === undefined ||
124
+ current.tenantId !== input.tenantId ||
125
+ current.state !== "pending" ||
126
+ current.expiresAt <= input.now
127
+ ) {
128
+ return false
129
+ }
130
+ const decided: ApprovalRecord = {
131
+ ...current,
132
+ state: input.decision,
133
+ decidedBy: input.decidedBy,
134
+ updatedAt: input.now,
135
+ version: current.version + 1,
136
+ }
137
+ return await backend.compareAndSet("approval", input.approvalId, current.version, decided)
138
+ },
139
+ async consume(input): Promise<ApprovalConsumeResult> {
140
+ const current = await backend.get<ApprovalRecord>("approval", input.approvalId)
141
+ if (current === undefined) return { state: "missing" }
142
+ if (current.tenantId !== input.tenantId || current.principalId !== input.principalId)
143
+ return { state: "binding" }
144
+ if (
145
+ current.capability !== input.capability ||
146
+ current.target !== input.target ||
147
+ !safeEqual(current.digest, input.digest)
148
+ )
149
+ return { state: "binding" }
150
+ if (!safeEqual(current.tokenHash, input.tokenHash)) return { state: "token" }
151
+ if (current.state === "consumed") return { state: "replay" }
152
+ if (current.state === "denied") return { state: "denied" }
153
+ if (current.state === "pending") return { state: "pending" }
154
+ if (current.state === "expired" || current.expiresAt <= input.now) return { state: "expired" }
155
+ const consumed = await backend.compareAndSet("approval", input.approvalId, current.version, {
156
+ ...current,
157
+ state: "consumed",
158
+ updatedAt: input.now,
159
+ version: current.version + 1,
160
+ })
161
+ return consumed ? { state: "consumed" } : { state: "replay" }
162
+ },
163
+ }
164
+
165
+ const sagas: SagaStore = {
166
+ durability: "durable",
167
+ create: (record) => backend.create("saga", record.sagaId, clone(record)),
168
+ get: (sagaId) => backend.get<SagaRecord>("saga", sagaId),
169
+ compareAndSet: (input) =>
170
+ backend.compareAndSet("saga", input.sagaId, input.version, clone(input.record)),
171
+ scan: (input) => backend.scan<SagaRecord>("saga", input as ReconciliationScanOptions<string>),
172
+ }
173
+
174
+ const leases: ReconciliationLeaseStore = {
175
+ durability: "durable",
176
+ acquire: (input) => backend.acquireLease(input),
177
+ renew: (input) => backend.renewLease(input),
178
+ checkpoint: (input) => backend.checkpointLease(input),
179
+ release: (input) => backend.releaseLease(input),
180
+ }
181
+ return Object.freeze({ effects, approvals, sagas, leases })
182
+ }
183
+
184
+ interface MemoryRecord {
185
+ readonly version: number
186
+ readonly state?: string
187
+ readonly updatedAt?: number
188
+ }
189
+ interface MemoryLease {
190
+ owner?: string
191
+ token?: string
192
+ expiresAt?: number
193
+ cursor?: string
194
+ }
195
+
196
+ /** Deterministic reference backend for tests and adapter conformance; not for production. */
197
+ export class MemoryDurableRecordBackend implements DurableRecordBackend {
198
+ private readonly records = new Map<string, MemoryRecord>()
199
+ private readonly leases = new Map<string, MemoryLease>()
200
+ private key(kind: DurableRecordKind, id: string): string {
201
+ return `${kind}:${id}`
202
+ }
203
+ async create(
204
+ kind: DurableRecordKind,
205
+ id: string,
206
+ record: {
207
+ readonly version: number
208
+ readonly state?: string
209
+ readonly updatedAt?: number
210
+ },
211
+ ): Promise<boolean> {
212
+ const key = this.key(kind, id)
213
+ if (this.records.has(key)) return false
214
+ this.records.set(key, clone(record))
215
+ return true
216
+ }
217
+ async get<T extends { readonly version: number }>(
218
+ kind: DurableRecordKind,
219
+ id: string,
220
+ ): Promise<T | undefined> {
221
+ const value = this.records.get(this.key(kind, id))
222
+ return value === undefined ? undefined : clone(value as T)
223
+ }
224
+ async compareAndSet(
225
+ kind: DurableRecordKind,
226
+ id: string,
227
+ expectedVersion: number,
228
+ record: {
229
+ readonly version: number
230
+ readonly state?: string
231
+ readonly updatedAt?: number
232
+ },
233
+ ): Promise<boolean> {
234
+ const key = this.key(kind, id)
235
+ const current = this.records.get(key)
236
+ if (current?.version !== expectedVersion || record.version !== expectedVersion + 1) return false
237
+ this.records.set(key, clone(record))
238
+ return true
239
+ }
240
+ async scan<T extends { readonly state: string; readonly updatedAt: number }>(
241
+ kind: DurableRecordKind,
242
+ input: ReconciliationScanOptions<string>,
243
+ ): Promise<ReconciliationScanPage<T>> {
244
+ const prefix = `${kind}:`
245
+ const records = [...this.records.entries()]
246
+ .filter(([key, value]) => {
247
+ if (!key.startsWith(prefix) || !input.states.includes(value.state ?? "")) return false
248
+ return input.updatedBefore === undefined || (value.updatedAt ?? 0) <= input.updatedBefore
249
+ })
250
+ .sort(([a], [b]) => a.localeCompare(b))
251
+ .filter(([key]) => input.cursor === undefined || key > prefix + input.cursor)
252
+ const page = records.slice(0, input.limit)
253
+ return Object.freeze({
254
+ records: Object.freeze(page.map(([, value]) => clone(value as unknown as T))),
255
+ ...(records.length > input.limit
256
+ ? { cursor: (page.at(-1)?.[0] ?? "").slice(prefix.length) }
257
+ : {}),
258
+ })
259
+ }
260
+ async acquireLease(
261
+ input: Parameters<DurableRecordBackend["acquireLease"]>[0],
262
+ ): Promise<ReconciliationLease | undefined> {
263
+ const current = this.leases.get(input.name)
264
+ if (current?.owner !== undefined && (current.expiresAt ?? 0) > input.now) return undefined
265
+ const expiresAt = input.now + input.leaseMs
266
+ const token = crypto.randomUUID()
267
+ const next = {
268
+ owner: input.owner,
269
+ token,
270
+ expiresAt,
271
+ ...(current?.cursor === undefined ? {} : { cursor: current.cursor }),
272
+ }
273
+ this.leases.set(input.name, next)
274
+ return Object.freeze({ name: input.name, ...next }) as ReconciliationLease
275
+ }
276
+ async renewLease(input: Parameters<DurableRecordBackend["renewLease"]>[0]): Promise<boolean> {
277
+ const current = this.leases.get(input.name)
278
+ if (
279
+ current?.owner !== input.owner ||
280
+ !safeEqual(current.token, input.token) ||
281
+ (current.expiresAt ?? 0) <= input.now
282
+ )
283
+ return false
284
+ current.expiresAt = input.now + input.leaseMs
285
+ return true
286
+ }
287
+ async checkpointLease(
288
+ input: Parameters<DurableRecordBackend["checkpointLease"]>[0],
289
+ ): Promise<boolean> {
290
+ const current = this.leases.get(input.name)
291
+ if (current?.owner !== input.owner || !safeEqual(current.token, input.token)) return false
292
+ if (input.cursor === undefined) delete current.cursor
293
+ else current.cursor = input.cursor
294
+ return true
295
+ }
296
+ async releaseLease(input: Parameters<DurableRecordBackend["releaseLease"]>[0]): Promise<boolean> {
297
+ const current = this.leases.get(input.name)
298
+ if (current?.owner !== input.owner || !safeEqual(current.token, input.token)) return false
299
+ delete current.owner
300
+ delete current.token
301
+ delete current.expiresAt
302
+ return true
303
+ }
304
+ }
305
+
306
+ export interface DurableExecutionConformanceResult {
307
+ readonly effects: true
308
+ readonly approvals: true
309
+ readonly sagas: true
310
+ readonly leases: true
311
+ }
312
+
313
+ function requireConformance(condition: unknown, message: string): asserts condition {
314
+ if (!condition) throw new Error(`durable adapter conformance: ${message}`)
315
+ }
316
+
317
+ /** Runtime-independent conformance suite reusable by adapter authors and CI. */
318
+ export async function runDurableExecutionAdapterConformance(
319
+ adapter: DurableExecutionAdapter,
320
+ ): Promise<DurableExecutionConformanceResult> {
321
+ const prefix = crypto.randomUUID()
322
+ const effectId = `${prefix}-effect`
323
+ const effect: DurableEffectRecord = {
324
+ effectId,
325
+ capability: "test.write",
326
+ state: "admission",
327
+ createdAt: 1,
328
+ updatedAt: 1,
329
+ version: 1,
330
+ }
331
+ requireConformance(await adapter.effects.create(effect), "effect create rejected")
332
+ requireConformance(!(await adapter.effects.create(effect)), "duplicate effect create accepted")
333
+ requireConformance(
334
+ await adapter.effects.transition({
335
+ effectId,
336
+ version: 1,
337
+ from: "admission",
338
+ to: "executing",
339
+ updatedAt: 2,
340
+ }),
341
+ "effect CAS rejected",
342
+ )
343
+ requireConformance(
344
+ !(await adapter.effects.transition({
345
+ effectId,
346
+ version: 1,
347
+ from: "admission",
348
+ to: "failed",
349
+ updatedAt: 3,
350
+ })),
351
+ "stale effect CAS accepted",
352
+ )
353
+
354
+ const approvalId = `${prefix}-approval`
355
+ const approval: ApprovalRecord = {
356
+ approvalId,
357
+ effectId,
358
+ capability: "test.write",
359
+ tenantId: "tenant",
360
+ principalId: "principal",
361
+ tokenHash: "hash",
362
+ state: "pending",
363
+ createdAt: 1,
364
+ expiresAt: 100,
365
+ updatedAt: 1,
366
+ version: 1,
367
+ }
368
+ requireConformance(await adapter.approvals.create(approval), "approval create rejected")
369
+ requireConformance(
370
+ await adapter.approvals.decide({
371
+ approvalId,
372
+ tenantId: "tenant",
373
+ decision: "approved",
374
+ decidedBy: "operator",
375
+ now: 2,
376
+ }),
377
+ "approval decision rejected",
378
+ )
379
+ const consumed = await adapter.approvals.consume({
380
+ approvalId,
381
+ tenantId: "tenant",
382
+ principalId: "principal",
383
+ capability: "test.write",
384
+ tokenHash: "hash",
385
+ now: 3,
386
+ })
387
+ requireConformance(consumed.state === "consumed", "approval consume rejected")
388
+ const replay = await adapter.approvals.consume({
389
+ approvalId,
390
+ tenantId: "tenant",
391
+ principalId: "principal",
392
+ capability: "test.write",
393
+ tokenHash: "hash",
394
+ now: 4,
395
+ })
396
+ requireConformance(replay.state === "replay", "approval replay not detected")
397
+
398
+ const sagaId = `${prefix}-saga`
399
+ const saga: SagaRecord = {
400
+ sagaId,
401
+ definition: "test",
402
+ state: "running",
403
+ input: null,
404
+ steps: [],
405
+ createdAt: 1,
406
+ updatedAt: 1,
407
+ version: 1,
408
+ }
409
+ requireConformance(await adapter.sagas.create(saga), "saga create rejected")
410
+ requireConformance(
411
+ await adapter.sagas.compareAndSet({
412
+ sagaId,
413
+ version: 1,
414
+ record: { ...saga, state: "completed", updatedAt: 2, version: 2 },
415
+ }),
416
+ "saga CAS rejected",
417
+ )
418
+
419
+ const lease = await adapter.leases.acquire({
420
+ name: `${prefix}-worker`,
421
+ owner: "a",
422
+ now: 1,
423
+ leaseMs: 100,
424
+ })
425
+ requireConformance(lease !== undefined, "lease acquire rejected")
426
+ requireConformance(
427
+ (await adapter.leases.acquire({
428
+ name: `${prefix}-worker`,
429
+ owner: "b",
430
+ now: 2,
431
+ leaseMs: 100,
432
+ })) === undefined,
433
+ "live lease stolen",
434
+ )
435
+ requireConformance(
436
+ await adapter.leases.checkpoint({
437
+ name: lease.name,
438
+ owner: lease.owner,
439
+ token: lease.token,
440
+ cursor: "next",
441
+ }),
442
+ "lease checkpoint rejected",
443
+ )
444
+ requireConformance(
445
+ await adapter.leases.release({ name: lease.name, owner: lease.owner, token: lease.token }),
446
+ "lease release rejected",
447
+ )
448
+ return { effects: true, approvals: true, sagas: true, leases: true }
449
+ }
450
+
451
+ // -------------------------------------------------------------------------------------------------
452
+ // PostgreSQL
453
+
454
+ export interface PostgresQueryResult {
455
+ readonly rows: readonly Record<string, unknown>[]
456
+ readonly rowCount?: number
457
+ }
458
+
459
+ export interface PostgresClient {
460
+ query(sql: string, values?: readonly unknown[]): Promise<PostgresQueryResult>
461
+ }
462
+
463
+ function sqlIdentifier(value: string): string {
464
+ if (!/^[a-z_][a-z0-9_]{0,62}$/u.test(value)) throw new TypeError("invalid SQL table prefix")
465
+ return value
466
+ }
467
+
468
+ function recordFromRow<T>(row: Record<string, unknown> | undefined): T | undefined {
469
+ if (row === undefined) return undefined
470
+ const payload = row.payload
471
+ const text = typeof payload === "string" ? payload : JSON.stringify(payload)
472
+ return clone(parseWire(text) as T)
473
+ }
474
+
475
+ function serializeRecord(record: unknown): string {
476
+ return stringifyWire(record)
477
+ }
478
+
479
+ export class PostgresDurableRecordBackend implements DurableRecordBackend {
480
+ readonly recordsTable: string
481
+ readonly leasesTable: string
482
+ constructor(
483
+ private readonly client: PostgresClient,
484
+ options: { readonly tablePrefix?: string } = {},
485
+ ) {
486
+ const prefix = sqlIdentifier(options.tablePrefix ?? "nifra_durable")
487
+ this.recordsTable = `${prefix}_records`
488
+ this.leasesTable = `${prefix}_leases`
489
+ }
490
+
491
+ async migrate(): Promise<void> {
492
+ await this.client.query(
493
+ "CREATE TABLE IF NOT EXISTS " +
494
+ this.recordsTable +
495
+ " (kind TEXT NOT NULL, id TEXT NOT NULL, state TEXT NOT NULL, updated_at BIGINT NOT NULL, version BIGINT NOT NULL, payload JSONB NOT NULL, PRIMARY KEY (kind, id))",
496
+ )
497
+ await this.client.query(
498
+ "CREATE INDEX IF NOT EXISTS " +
499
+ this.recordsTable +
500
+ "_reconcile ON " +
501
+ this.recordsTable +
502
+ " (kind, state, updated_at, id)",
503
+ )
504
+ await this.client.query(
505
+ "CREATE TABLE IF NOT EXISTS " +
506
+ this.leasesTable +
507
+ " (name TEXT PRIMARY KEY, owner TEXT, token TEXT, expires_at BIGINT NOT NULL DEFAULT 0, cursor TEXT)",
508
+ )
509
+ }
510
+
511
+ async create(
512
+ kind: DurableRecordKind,
513
+ id: string,
514
+ record: { readonly version: number; readonly state?: string; readonly updatedAt?: number },
515
+ ): Promise<boolean> {
516
+ const result = await this.client.query(
517
+ "INSERT INTO " +
518
+ this.recordsTable +
519
+ " (kind,id,state,updated_at,version,payload) VALUES ($1,$2,$3,$4,$5,$6::jsonb) ON CONFLICT DO NOTHING RETURNING id",
520
+ [
521
+ kind,
522
+ id,
523
+ record.state ?? "",
524
+ record.updatedAt ?? 0,
525
+ record.version,
526
+ serializeRecord(record),
527
+ ],
528
+ )
529
+ return result.rows.length === 1
530
+ }
531
+ async get<T extends { readonly version: number }>(
532
+ kind: DurableRecordKind,
533
+ id: string,
534
+ ): Promise<T | undefined> {
535
+ const result = await this.client.query(
536
+ `SELECT payload FROM ${this.recordsTable} WHERE kind=$1 AND id=$2`,
537
+ [kind, id],
538
+ )
539
+ return recordFromRow<T>(result.rows[0])
540
+ }
541
+ async compareAndSet(
542
+ kind: DurableRecordKind,
543
+ id: string,
544
+ expectedVersion: number,
545
+ record: { readonly version: number; readonly state?: string; readonly updatedAt?: number },
546
+ ): Promise<boolean> {
547
+ if (record.version !== expectedVersion + 1) return false
548
+ const result = await this.client.query(
549
+ "UPDATE " +
550
+ this.recordsTable +
551
+ " SET state=$4,updated_at=$5,version=$6,payload=$7::jsonb WHERE kind=$1 AND id=$2 AND version=$3 RETURNING id",
552
+ [
553
+ kind,
554
+ id,
555
+ expectedVersion,
556
+ record.state ?? "",
557
+ record.updatedAt ?? 0,
558
+ record.version,
559
+ serializeRecord(record),
560
+ ],
561
+ )
562
+ return result.rows.length === 1
563
+ }
564
+ async scan<T extends { readonly state: string; readonly updatedAt: number }>(
565
+ kind: DurableRecordKind,
566
+ input: ReconciliationScanOptions<string>,
567
+ ): Promise<ReconciliationScanPage<T>> {
568
+ const result = await this.client.query(
569
+ "SELECT id,payload FROM " +
570
+ this.recordsTable +
571
+ " WHERE kind=$1 AND state = ANY($2::text[]) AND ($3::bigint IS NULL OR updated_at <= $3) AND ($4::text IS NULL OR id > $4) ORDER BY id LIMIT $5",
572
+ [kind, input.states, input.updatedBefore ?? null, input.cursor ?? null, input.limit + 1],
573
+ )
574
+ const hasMore = result.rows.length > input.limit
575
+ const rows = result.rows.slice(0, input.limit)
576
+ return Object.freeze({
577
+ records: Object.freeze(rows.map((row) => recordFromRow<T>(row) as T)),
578
+ ...(hasMore ? { cursor: String(rows.at(-1)?.id) } : {}),
579
+ })
580
+ }
581
+ async acquireLease(
582
+ input: Parameters<DurableRecordBackend["acquireLease"]>[0],
583
+ ): Promise<ReconciliationLease | undefined> {
584
+ const token = crypto.randomUUID()
585
+ const expiresAt = input.now + input.leaseMs
586
+ const result = await this.client.query(
587
+ "INSERT INTO " +
588
+ this.leasesTable +
589
+ " (name,owner,token,expires_at) VALUES ($1,$2,$3,$4) ON CONFLICT (name) DO UPDATE SET owner=EXCLUDED.owner,token=EXCLUDED.token,expires_at=EXCLUDED.expires_at WHERE " +
590
+ this.leasesTable +
591
+ ".expires_at <= $5 RETURNING name,owner,token,expires_at,cursor",
592
+ [input.name, input.owner, token, expiresAt, input.now],
593
+ )
594
+ const row = result.rows[0]
595
+ if (row === undefined) return undefined
596
+ return Object.freeze({
597
+ name: String(row.name),
598
+ owner: String(row.owner),
599
+ token: String(row.token),
600
+ expiresAt: Number(row.expires_at),
601
+ ...(row.cursor === null || row.cursor === undefined ? {} : { cursor: String(row.cursor) }),
602
+ })
603
+ }
604
+ async renewLease(input: Parameters<DurableRecordBackend["renewLease"]>[0]): Promise<boolean> {
605
+ const result = await this.client.query(
606
+ "UPDATE " +
607
+ this.leasesTable +
608
+ " SET expires_at=$4 WHERE name=$1 AND owner=$2 AND token=$3 AND expires_at>$5 RETURNING name",
609
+ [input.name, input.owner, input.token, input.now + input.leaseMs, input.now],
610
+ )
611
+ return result.rows.length === 1
612
+ }
613
+ async checkpointLease(
614
+ input: Parameters<DurableRecordBackend["checkpointLease"]>[0],
615
+ ): Promise<boolean> {
616
+ const result = await this.client.query(
617
+ "UPDATE " +
618
+ this.leasesTable +
619
+ " SET cursor=$4 WHERE name=$1 AND owner=$2 AND token=$3 RETURNING name",
620
+ [input.name, input.owner, input.token, input.cursor ?? null],
621
+ )
622
+ return result.rows.length === 1
623
+ }
624
+ async releaseLease(input: Parameters<DurableRecordBackend["releaseLease"]>[0]): Promise<boolean> {
625
+ const result = await this.client.query(
626
+ "UPDATE " +
627
+ this.leasesTable +
628
+ " SET owner=NULL,token=NULL,expires_at=0 WHERE name=$1 AND owner=$2 AND token=$3 RETURNING name",
629
+ [input.name, input.owner, input.token],
630
+ )
631
+ return result.rows.length === 1
632
+ }
633
+ }
634
+
635
+ export class PostgresDurableExecutionAdapter implements DurableExecutionAdapter {
636
+ readonly effects: DurableEffectStore
637
+ readonly approvals: ApprovalStore
638
+ readonly sagas: SagaStore
639
+ readonly leases: ReconciliationLeaseStore
640
+ readonly backend: PostgresDurableRecordBackend
641
+ constructor(client: PostgresClient, options: { readonly tablePrefix?: string } = {}) {
642
+ this.backend = new PostgresDurableRecordBackend(client, options)
643
+ const views = createDurableExecutionAdapter(this.backend)
644
+ this.effects = views.effects
645
+ this.approvals = views.approvals
646
+ this.sagas = views.sagas
647
+ this.leases = views.leases
648
+ }
649
+ migrate(): Promise<void> {
650
+ return this.backend.migrate()
651
+ }
652
+ }
653
+
654
+ // -------------------------------------------------------------------------------------------------
655
+ // SQLite (Bun SQLite and compatible drivers)
656
+
657
+ export interface SQLiteRunResult {
658
+ readonly changes: number
659
+ }
660
+ export interface SQLiteStatement {
661
+ run(...values: readonly unknown[]): SQLiteRunResult
662
+ get(...values: readonly unknown[]): Record<string, unknown> | null | undefined
663
+ all(...values: readonly unknown[]): readonly (Record<string, unknown> | undefined)[]
664
+ }
665
+ export interface SQLiteClient {
666
+ exec(sql: string): unknown
667
+ query(sql: string): SQLiteStatement
668
+ }
669
+
670
+ export class SQLiteDurableRecordBackend implements DurableRecordBackend {
671
+ readonly recordsTable: string
672
+ readonly leasesTable: string
673
+ constructor(
674
+ private readonly client: SQLiteClient,
675
+ options: { readonly tablePrefix?: string } = {},
676
+ ) {
677
+ const prefix = sqlIdentifier(options.tablePrefix ?? "nifra_durable")
678
+ this.recordsTable = `${prefix}_records`
679
+ this.leasesTable = `${prefix}_leases`
680
+ }
681
+ migrate(): void {
682
+ this.client.exec(
683
+ "CREATE TABLE IF NOT EXISTS " +
684
+ this.recordsTable +
685
+ " (kind TEXT NOT NULL, id TEXT NOT NULL, state TEXT NOT NULL, updated_at INTEGER NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL, PRIMARY KEY (kind,id));" +
686
+ "CREATE INDEX IF NOT EXISTS " +
687
+ this.recordsTable +
688
+ "_reconcile ON " +
689
+ this.recordsTable +
690
+ " (kind,state,updated_at,id);" +
691
+ "CREATE TABLE IF NOT EXISTS " +
692
+ this.leasesTable +
693
+ " (name TEXT PRIMARY KEY, owner TEXT, token TEXT, expires_at INTEGER NOT NULL DEFAULT 0, cursor TEXT);",
694
+ )
695
+ }
696
+ async create(
697
+ kind: DurableRecordKind,
698
+ id: string,
699
+ record: { readonly version: number; readonly state?: string; readonly updatedAt?: number },
700
+ ): Promise<boolean> {
701
+ const result = this.client
702
+ .query(
703
+ "INSERT OR IGNORE INTO " +
704
+ this.recordsTable +
705
+ " (kind,id,state,updated_at,version,payload) VALUES (?,?,?,?,?,?)",
706
+ )
707
+ .run(
708
+ kind,
709
+ id,
710
+ record.state ?? "",
711
+ record.updatedAt ?? 0,
712
+ record.version,
713
+ serializeRecord(record),
714
+ )
715
+ return result.changes === 1
716
+ }
717
+ async get<T extends { readonly version: number }>(
718
+ kind: DurableRecordKind,
719
+ id: string,
720
+ ): Promise<T | undefined> {
721
+ const row = this.client
722
+ .query(`SELECT payload FROM ${this.recordsTable} WHERE kind=? AND id=?`)
723
+ .get(kind, id)
724
+ return recordFromRow<T>(row ?? undefined)
725
+ }
726
+ async compareAndSet(
727
+ kind: DurableRecordKind,
728
+ id: string,
729
+ expectedVersion: number,
730
+ record: { readonly version: number; readonly state?: string; readonly updatedAt?: number },
731
+ ): Promise<boolean> {
732
+ if (record.version !== expectedVersion + 1) return false
733
+ const result = this.client
734
+ .query(
735
+ "UPDATE " +
736
+ this.recordsTable +
737
+ " SET state=?,updated_at=?,version=?,payload=? WHERE kind=? AND id=? AND version=?",
738
+ )
739
+ .run(
740
+ record.state ?? "",
741
+ record.updatedAt ?? 0,
742
+ record.version,
743
+ serializeRecord(record),
744
+ kind,
745
+ id,
746
+ expectedVersion,
747
+ )
748
+ return result.changes === 1
749
+ }
750
+ async scan<T extends { readonly state: string; readonly updatedAt: number }>(
751
+ kind: DurableRecordKind,
752
+ input: ReconciliationScanOptions<string>,
753
+ ): Promise<ReconciliationScanPage<T>> {
754
+ if (input.states.length === 0) return { records: [] }
755
+ const placeholders = input.states.map(() => "?").join(",")
756
+ const rows = this.client
757
+ .query(
758
+ "SELECT id,payload FROM " +
759
+ this.recordsTable +
760
+ " WHERE kind=? AND state IN (" +
761
+ placeholders +
762
+ ") AND (? IS NULL OR updated_at <= ?) AND (? IS NULL OR id > ?) ORDER BY id LIMIT ?",
763
+ )
764
+ .all(
765
+ kind,
766
+ ...input.states,
767
+ input.updatedBefore ?? null,
768
+ input.updatedBefore ?? null,
769
+ input.cursor ?? null,
770
+ input.cursor ?? null,
771
+ input.limit + 1,
772
+ )
773
+ const present = rows.filter((row): row is Record<string, unknown> => row !== undefined)
774
+ const hasMore = present.length > input.limit
775
+ const page = present.slice(0, input.limit)
776
+ return Object.freeze({
777
+ records: Object.freeze(page.map((row) => recordFromRow<T>(row) as T)),
778
+ ...(hasMore ? { cursor: String(page.at(-1)?.id) } : {}),
779
+ })
780
+ }
781
+ async acquireLease(
782
+ input: Parameters<DurableRecordBackend["acquireLease"]>[0],
783
+ ): Promise<ReconciliationLease | undefined> {
784
+ const token = crypto.randomUUID()
785
+ const expiresAt = input.now + input.leaseMs
786
+ const row = this.client
787
+ .query(
788
+ "INSERT INTO " +
789
+ this.leasesTable +
790
+ " (name,owner,token,expires_at) VALUES (?,?,?,?) ON CONFLICT(name) DO UPDATE SET owner=excluded.owner,token=excluded.token,expires_at=excluded.expires_at WHERE " +
791
+ this.leasesTable +
792
+ ".expires_at <= ? RETURNING name,owner,token,expires_at,cursor",
793
+ )
794
+ .get(input.name, input.owner, token, expiresAt, input.now)
795
+ if (row === undefined || row === null) return undefined
796
+ return Object.freeze({
797
+ name: String(row.name),
798
+ owner: String(row.owner),
799
+ token: String(row.token),
800
+ expiresAt: Number(row.expires_at),
801
+ ...(row.cursor === null || row.cursor === undefined ? {} : { cursor: String(row.cursor) }),
802
+ })
803
+ }
804
+ async renewLease(input: Parameters<DurableRecordBackend["renewLease"]>[0]): Promise<boolean> {
805
+ return (
806
+ this.client
807
+ .query(
808
+ `UPDATE ${this.leasesTable} SET expires_at=? WHERE name=? AND owner=? AND token=? AND expires_at>?`,
809
+ )
810
+ .run(input.now + input.leaseMs, input.name, input.owner, input.token, input.now).changes ===
811
+ 1
812
+ )
813
+ }
814
+ async checkpointLease(
815
+ input: Parameters<DurableRecordBackend["checkpointLease"]>[0],
816
+ ): Promise<boolean> {
817
+ return (
818
+ this.client
819
+ .query(`UPDATE ${this.leasesTable} SET cursor=? WHERE name=? AND owner=? AND token=?`)
820
+ .run(input.cursor ?? null, input.name, input.owner, input.token).changes === 1
821
+ )
822
+ }
823
+ async releaseLease(input: Parameters<DurableRecordBackend["releaseLease"]>[0]): Promise<boolean> {
824
+ return (
825
+ this.client
826
+ .query(
827
+ "UPDATE " +
828
+ this.leasesTable +
829
+ " SET owner=NULL,token=NULL,expires_at=0 WHERE name=? AND owner=? AND token=?",
830
+ )
831
+ .run(input.name, input.owner, input.token).changes === 1
832
+ )
833
+ }
834
+ }
835
+
836
+ export class SQLiteDurableExecutionAdapter implements DurableExecutionAdapter {
837
+ readonly effects: DurableEffectStore
838
+ readonly approvals: ApprovalStore
839
+ readonly sagas: SagaStore
840
+ readonly leases: ReconciliationLeaseStore
841
+ readonly backend: SQLiteDurableRecordBackend
842
+ constructor(client: SQLiteClient, options: { readonly tablePrefix?: string } = {}) {
843
+ this.backend = new SQLiteDurableRecordBackend(client, options)
844
+ const views = createDurableExecutionAdapter(this.backend)
845
+ this.effects = views.effects
846
+ this.approvals = views.approvals
847
+ this.sagas = views.sagas
848
+ this.leases = views.leases
849
+ }
850
+ migrate(): void {
851
+ this.backend.migrate()
852
+ }
853
+ }
854
+
855
+ // -------------------------------------------------------------------------------------------------
856
+ // Cloudflare Durable Object storage
857
+
858
+ export interface DurableObjectStorageTransaction {
859
+ get<T = unknown>(key: string): Promise<T | undefined>
860
+ put<T = unknown>(key: string, value: T): Promise<void>
861
+ list<T = unknown>(options?: {
862
+ readonly prefix?: string
863
+ readonly startAfter?: string
864
+ readonly limit?: number
865
+ }): Promise<Map<string, T>>
866
+ }
867
+ export interface DurableObjectStorage extends DurableObjectStorageTransaction {
868
+ transaction<T>(closure: (transaction: DurableObjectStorageTransaction) => Promise<T>): Promise<T>
869
+ }
870
+
871
+ interface DurableObjectLeaseValue {
872
+ owner?: string
873
+ token?: string
874
+ expiresAt: number
875
+ cursor?: string
876
+ }
877
+
878
+ export class DurableObjectRecordBackend implements DurableRecordBackend {
879
+ constructor(private readonly storage: DurableObjectStorage) {}
880
+ private recordKey(kind: DurableRecordKind, id: string): string {
881
+ return `record:${kind}:${encodeURIComponent(id)}`
882
+ }
883
+ private leaseKey(name: string): string {
884
+ return `lease:${encodeURIComponent(name)}`
885
+ }
886
+ async create(
887
+ kind: DurableRecordKind,
888
+ id: string,
889
+ record: {
890
+ readonly version: number
891
+ readonly state?: string
892
+ readonly updatedAt?: number
893
+ },
894
+ ): Promise<boolean> {
895
+ return await this.storage.transaction(async (txn) => {
896
+ const key = this.recordKey(kind, id)
897
+ if ((await txn.get(key)) !== undefined) return false
898
+ await txn.put(key, clone(record))
899
+ return true
900
+ })
901
+ }
902
+ async get<T extends { readonly version: number }>(
903
+ kind: DurableRecordKind,
904
+ id: string,
905
+ ): Promise<T | undefined> {
906
+ const value = await this.storage.get<T>(this.recordKey(kind, id))
907
+ return value === undefined ? undefined : clone(value)
908
+ }
909
+ async compareAndSet(
910
+ kind: DurableRecordKind,
911
+ id: string,
912
+ expectedVersion: number,
913
+ record: {
914
+ readonly version: number
915
+ readonly state?: string
916
+ readonly updatedAt?: number
917
+ },
918
+ ): Promise<boolean> {
919
+ if (record.version !== expectedVersion + 1) return false
920
+ return await this.storage.transaction(async (txn) => {
921
+ const key = this.recordKey(kind, id)
922
+ const current = await txn.get<{ readonly version: number }>(key)
923
+ if (current?.version !== expectedVersion) return false
924
+ await txn.put(key, clone(record))
925
+ return true
926
+ })
927
+ }
928
+ async scan<T extends { readonly state: string; readonly updatedAt: number }>(
929
+ kind: DurableRecordKind,
930
+ input: ReconciliationScanOptions<string>,
931
+ ): Promise<ReconciliationScanPage<T>> {
932
+ const prefix = `record:${kind}:`
933
+ const listed = await this.storage.list<T>({
934
+ prefix,
935
+ ...(input.cursor === undefined
936
+ ? {}
937
+ : { startAfter: prefix + encodeURIComponent(input.cursor) }),
938
+ limit: input.limit + 1,
939
+ })
940
+ const selected: Array<{ id: string; record: T }> = []
941
+ let lastVisited: string | undefined
942
+ for (const [key, record] of listed) {
943
+ lastVisited = decodeURIComponent(key.slice(prefix.length))
944
+ if (
945
+ input.states.includes(record.state) &&
946
+ (input.updatedBefore === undefined || record.updatedAt <= input.updatedBefore)
947
+ ) {
948
+ selected.push({ id: lastVisited, record })
949
+ }
950
+ if (selected.length > input.limit) break
951
+ }
952
+ const hasMore = selected.length > input.limit || listed.size > input.limit
953
+ const page = selected.slice(0, input.limit)
954
+ return Object.freeze({
955
+ records: Object.freeze(page.map(({ record }) => clone(record))),
956
+ ...(hasMore && lastVisited !== undefined ? { cursor: lastVisited } : {}),
957
+ })
958
+ }
959
+ async acquireLease(
960
+ input: Parameters<DurableRecordBackend["acquireLease"]>[0],
961
+ ): Promise<ReconciliationLease | undefined> {
962
+ return await this.storage.transaction(async (txn) => {
963
+ const key = this.leaseKey(input.name)
964
+ const current = await txn.get<DurableObjectLeaseValue>(key)
965
+ if (current?.owner !== undefined && current.expiresAt > input.now) return undefined
966
+ const token = crypto.randomUUID()
967
+ const next: DurableObjectLeaseValue = {
968
+ owner: input.owner,
969
+ token,
970
+ expiresAt: input.now + input.leaseMs,
971
+ ...(current?.cursor === undefined ? {} : { cursor: current.cursor }),
972
+ }
973
+ await txn.put(key, next)
974
+ return Object.freeze({
975
+ name: input.name,
976
+ owner: input.owner,
977
+ token,
978
+ expiresAt: next.expiresAt,
979
+ ...(next.cursor === undefined ? {} : { cursor: next.cursor }),
980
+ })
981
+ })
982
+ }
983
+ async renewLease(input: Parameters<DurableRecordBackend["renewLease"]>[0]): Promise<boolean> {
984
+ return await this.storage.transaction(async (txn) => {
985
+ const key = this.leaseKey(input.name)
986
+ const current = await txn.get<DurableObjectLeaseValue>(key)
987
+ if (
988
+ current?.owner !== input.owner ||
989
+ !safeEqual(current.token, input.token) ||
990
+ current.expiresAt <= input.now
991
+ )
992
+ return false
993
+ await txn.put(key, { ...current, expiresAt: input.now + input.leaseMs })
994
+ return true
995
+ })
996
+ }
997
+ async checkpointLease(
998
+ input: Parameters<DurableRecordBackend["checkpointLease"]>[0],
999
+ ): Promise<boolean> {
1000
+ return await this.storage.transaction(async (txn) => {
1001
+ const key = this.leaseKey(input.name)
1002
+ const current = await txn.get<DurableObjectLeaseValue>(key)
1003
+ if (current?.owner !== input.owner || !safeEqual(current.token, input.token)) return false
1004
+ const next = { ...current }
1005
+ if (input.cursor === undefined) delete next.cursor
1006
+ else next.cursor = input.cursor
1007
+ await txn.put(key, next)
1008
+ return true
1009
+ })
1010
+ }
1011
+ async releaseLease(input: Parameters<DurableRecordBackend["releaseLease"]>[0]): Promise<boolean> {
1012
+ return await this.storage.transaction(async (txn) => {
1013
+ const key = this.leaseKey(input.name)
1014
+ const current = await txn.get<DurableObjectLeaseValue>(key)
1015
+ if (current?.owner !== input.owner || !safeEqual(current.token, input.token)) return false
1016
+ const next = { ...current, expiresAt: 0 }
1017
+ delete next.owner
1018
+ delete next.token
1019
+ await txn.put(key, next)
1020
+ return true
1021
+ })
1022
+ }
1023
+ }
1024
+
1025
+ export class DurableObjectExecutionAdapter implements DurableExecutionAdapter {
1026
+ readonly effects: DurableEffectStore
1027
+ readonly approvals: ApprovalStore
1028
+ readonly sagas: SagaStore
1029
+ readonly leases: ReconciliationLeaseStore
1030
+ readonly backend: DurableObjectRecordBackend
1031
+ constructor(storage: DurableObjectStorage) {
1032
+ this.backend = new DurableObjectRecordBackend(storage)
1033
+ const views = createDurableExecutionAdapter(this.backend)
1034
+ this.effects = views.effects
1035
+ this.approvals = views.approvals
1036
+ this.sagas = views.sagas
1037
+ this.leases = views.leases
1038
+ }
1039
+ }