@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.
- package/README.md +37 -1
- package/dist/assurance.d.ts +11 -2
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +20 -0
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +54 -2
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +240 -5
- package/dist/capabilities.js.map +1 -1
- package/dist/durable-adapters.d.ts +241 -0
- package/dist/durable-adapters.d.ts.map +1 -0
- package/dist/durable-adapters.js +697 -0
- package/dist/durable-adapters.js.map +1 -0
- package/dist/durable-execution.d.ts +349 -0
- package/dist/durable-execution.d.ts.map +1 -0
- package/dist/durable-execution.js +1173 -0
- package/dist/durable-execution.js.map +1 -0
- package/dist/effect-ledger.d.ts +9 -0
- package/dist/effect-ledger.d.ts.map +1 -0
- package/dist/effect-ledger.js +8 -0
- package/dist/effect-ledger.js.map +1 -0
- package/dist/effect-lifecycle.d.ts +42 -0
- package/dist/effect-lifecycle.d.ts.map +1 -0
- package/dist/effect-lifecycle.js +66 -0
- package/dist/effect-lifecycle.js.map +1 -0
- package/dist/effect-scope.d.ts +75 -0
- package/dist/effect-scope.d.ts.map +1 -0
- package/dist/effect-scope.js +177 -0
- package/dist/effect-scope.js.map +1 -0
- package/dist/idempotency-plugin.d.ts +13 -0
- package/dist/idempotency-plugin.d.ts.map +1 -0
- package/dist/idempotency-plugin.js +16 -0
- package/dist/idempotency-plugin.js.map +1 -0
- package/dist/index.d.ts +6 -35
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -31
- package/dist/index.js.map +1 -1
- package/dist/internal/capability-runtime.d.ts +62 -1
- package/dist/internal/capability-runtime.d.ts.map +1 -1
- package/dist/internal/capability-runtime.js +11 -2
- package/dist/internal/capability-runtime.js.map +1 -1
- package/dist/internal/effect-execution.d.ts +16 -0
- package/dist/internal/effect-execution.d.ts.map +1 -0
- package/dist/internal/effect-execution.js +50 -0
- package/dist/internal/effect-execution.js.map +1 -0
- package/dist/internal/safe-equal.d.ts +20 -0
- package/dist/internal/safe-equal.d.ts.map +1 -0
- package/dist/internal/safe-equal.js +31 -0
- package/dist/internal/safe-equal.js.map +1 -0
- package/dist/ledger.d.ts +14 -6
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +31 -5
- package/dist/ledger.js.map +1 -1
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +56 -0
- package/dist/mcp.js.map +1 -0
- package/dist/node-direct.d.ts +5 -0
- package/dist/node-direct.d.ts.map +1 -0
- package/dist/node-direct.js +27 -0
- package/dist/node-direct.js.map +1 -0
- package/dist/reconciliation-worker.d.ts +108 -0
- package/dist/reconciliation-worker.d.ts.map +1 -0
- package/dist/reconciliation-worker.js +239 -0
- package/dist/reconciliation-worker.js.map +1 -0
- package/dist/reflection.d.ts +5 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +2 -0
- package/dist/reflection.js.map +1 -1
- package/dist/server/client-ip.d.ts +35 -0
- package/dist/server/client-ip.d.ts.map +1 -0
- package/dist/server/client-ip.js +42 -0
- package/dist/server/client-ip.js.map +1 -0
- package/dist/server/context.d.ts +47 -2
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +14 -4
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +8 -2
- package/dist/server/contract.js.map +1 -1
- package/dist/server/http.d.ts +14 -0
- package/dist/server/http.d.ts.map +1 -0
- package/dist/server/http.js +45 -0
- package/dist/server/http.js.map +1 -0
- package/dist/server/idempotency-lane.d.ts +45 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -0
- package/dist/server/idempotency-lane.js +205 -0
- package/dist/server/idempotency-lane.js.map +1 -0
- package/dist/server/install.d.ts +21 -0
- package/dist/server/install.d.ts.map +1 -0
- package/dist/server/install.js +21 -0
- package/dist/server/install.js.map +1 -0
- package/dist/server/ledger-lane.d.ts +53 -0
- package/dist/server/ledger-lane.d.ts.map +1 -0
- package/dist/server/ledger-lane.js +82 -0
- package/dist/server/ledger-lane.js.map +1 -0
- package/dist/server/mcp-hook.d.ts +28 -0
- package/dist/server/mcp-hook.d.ts.map +1 -0
- package/dist/server/mcp-hook.js +2 -0
- package/dist/server/mcp-hook.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts +9 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -0
- package/dist/server/node-outcome-hook.js +2 -0
- package/dist/server/node-outcome-hook.js.map +1 -0
- package/dist/server/node-outcome.d.ts +37 -0
- package/dist/server/node-outcome.d.ts.map +1 -0
- package/dist/server/node-outcome.js +75 -0
- package/dist/server/node-outcome.js.map +1 -0
- package/dist/server/plugin.d.ts +78 -0
- package/dist/server/plugin.d.ts.map +1 -0
- package/dist/server/plugin.js +57 -0
- package/dist/server/plugin.js.map +1 -0
- package/dist/server/query.d.ts +18 -0
- package/dist/server/query.d.ts.map +1 -0
- package/dist/server/query.js +102 -0
- package/dist/server/query.js.map +1 -0
- package/dist/server/registry.d.ts +31 -6
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +51 -0
- package/dist/server/request-context.d.ts.map +1 -0
- package/dist/server/request-context.js +184 -0
- package/dist/server/request-context.js.map +1 -0
- package/dist/server/respond.d.ts +17 -0
- package/dist/server/respond.d.ts.map +1 -0
- package/dist/server/respond.js +81 -0
- package/dist/server/respond.js.map +1 -0
- package/dist/server/runtime-core.d.ts +36 -0
- package/dist/server/runtime-core.d.ts.map +1 -0
- package/dist/server/runtime-core.js +51 -0
- package/dist/server/runtime-core.js.map +1 -0
- package/dist/server/server-types.d.ts +203 -0
- package/dist/server/server-types.d.ts.map +1 -0
- package/dist/server/server-types.js +2 -0
- package/dist/server/server-types.js.map +1 -0
- package/dist/server/server.d.ts +119 -345
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +247 -833
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse-hook.d.ts +10 -0
- package/dist/server/sse-hook.d.ts.map +1 -0
- package/dist/server/sse-hook.js +2 -0
- package/dist/server/sse-hook.js.map +1 -0
- package/dist/server/sse.d.ts +7 -23
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +18 -0
- package/dist/server/sse.js.map +1 -1
- package/dist/server/transport-lane.d.ts +28 -0
- package/dist/server/transport-lane.d.ts.map +1 -0
- package/dist/server/transport-lane.js +106 -0
- package/dist/server/transport-lane.js.map +1 -0
- package/dist/server/websocket.d.ts +23 -1
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +10 -1
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-hook.d.ts +8 -11
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server/ws-hook.js +7 -14
- package/dist/server/ws-hook.js.map +1 -1
- package/dist/server.d.ts +21 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +16 -0
- package/dist/server.js.map +1 -0
- package/dist/transport-codec-rich.d.ts +8 -0
- package/dist/transport-codec-rich.d.ts.map +1 -0
- package/dist/transport-codec-rich.js +14 -0
- package/dist/transport-codec-rich.js.map +1 -0
- package/dist/transport-codec.d.ts +31 -0
- package/dist/transport-codec.d.ts.map +1 -0
- package/dist/transport-codec.js +190 -0
- package/dist/transport-codec.js.map +1 -0
- package/dist/transport-plugin.d.ts +3 -0
- package/dist/transport-plugin.d.ts.map +1 -0
- package/dist/transport-plugin.js +3 -0
- package/dist/transport-plugin.js.map +1 -0
- package/dist/wire.d.ts +58 -0
- package/dist/wire.d.ts.map +1 -0
- package/dist/wire.js +445 -0
- package/dist/wire.js.map +1 -0
- package/dist/ws.d.ts +16 -7
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +25 -10
- package/dist/ws.js.map +1 -1
- package/package.json +122 -6
- package/src/assurance.ts +36 -1
- package/src/capabilities.ts +306 -5
- package/src/durable-adapters.ts +1039 -0
- package/src/durable-execution.ts +1676 -0
- package/src/effect-ledger.ts +9 -0
- package/src/effect-lifecycle.ts +120 -0
- package/src/effect-scope.ts +267 -0
- package/src/idempotency-plugin.ts +17 -0
- package/src/index.ts +6 -133
- package/src/internal/capability-runtime.ts +87 -1
- package/src/internal/effect-execution.ts +70 -0
- package/src/internal/safe-equal.ts +27 -0
- package/src/ledger.ts +45 -11
- package/src/mcp.ts +65 -0
- package/src/node-direct.ts +37 -0
- package/src/reconciliation-worker.ts +372 -0
- package/src/reflection.ts +7 -0
- package/src/server/client-ip.ts +60 -0
- package/src/server/context.ts +50 -2
- package/src/server/contract.ts +26 -7
- package/src/server/http.ts +58 -0
- package/src/server/idempotency-lane.ts +321 -0
- package/src/server/install.ts +28 -0
- package/src/server/ledger-lane.ts +125 -0
- package/src/server/mcp-hook.ts +41 -0
- package/src/server/node-outcome-hook.ts +9 -0
- package/src/server/node-outcome.ts +112 -0
- package/src/server/plugin.ts +92 -0
- package/src/server/query.ts +119 -0
- package/src/server/registry.ts +36 -6
- package/src/server/request-context.ts +271 -0
- package/src/server/respond.ts +94 -0
- package/src/server/runtime-core.ts +74 -0
- package/src/server/server-types.ts +204 -0
- package/src/server/server.ts +448 -1314
- package/src/server/sse-hook.ts +14 -0
- package/src/server/sse.ts +26 -0
- package/src/server/transport-lane.ts +141 -0
- package/src/server/websocket.ts +35 -1
- package/src/server/ws-hook.ts +8 -19
- package/src/server.ts +92 -0
- package/src/transport-codec-rich.ts +20 -0
- package/src/transport-codec.ts +250 -0
- package/src/transport-plugin.ts +2 -0
- package/src/wire.ts +474 -0
- package/src/ws.ts +33 -10
- package/dist/invariants.d.ts +0 -70
- package/dist/invariants.d.ts.map +0 -1
- package/dist/invariants.js +0 -487
- package/dist/invariants.js.map +0 -1
- 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
|
+
}
|