@nifrajs/core 2.0.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 +27 -0
- 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-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 +5 -0
- package/dist/idempotency-plugin.d.ts.map +1 -1
- package/dist/idempotency-plugin.js +8 -0
- package/dist/idempotency-plugin.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +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/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/server/idempotency-lane.d.ts +1 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +61 -37
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js.map +1 -1
- package/dist/server/server.d.ts +12 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +33 -2
- package/dist/server/server.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 +6 -0
- 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/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/package.json +46 -1
- package/src/capabilities.ts +306 -5
- package/src/durable-adapters.ts +1039 -0
- package/src/durable-execution.ts +1676 -0
- package/src/effect-lifecycle.ts +120 -0
- package/src/effect-scope.ts +267 -0
- package/src/idempotency-plugin.ts +10 -0
- package/src/index.ts +1 -1
- 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/reconciliation-worker.ts +372 -0
- package/src/server/idempotency-lane.ts +68 -34
- package/src/server/respond.ts +1 -0
- package/src/server/server.ts +46 -0
- package/src/server/transport-lane.ts +141 -0
- package/src/server/websocket.ts +15 -1
- 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
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
import { safeEqual } from "./internal/safe-equal.js";
|
|
2
|
+
import { parse as parseWire, stringify as stringifyWire } from "./wire.js";
|
|
3
|
+
function clone(value) {
|
|
4
|
+
return structuredClone(value);
|
|
5
|
+
}
|
|
6
|
+
export function createDurableExecutionAdapter(backend) {
|
|
7
|
+
const effects = {
|
|
8
|
+
durability: "durable",
|
|
9
|
+
create: (record) => backend.create("effect", record.effectId, clone(record)),
|
|
10
|
+
get: (effectId) => backend.get("effect", effectId),
|
|
11
|
+
async transition(input) {
|
|
12
|
+
const current = await backend.get("effect", input.effectId);
|
|
13
|
+
if (current === undefined ||
|
|
14
|
+
current.version !== input.version ||
|
|
15
|
+
current.state !== input.from) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
const next = {
|
|
19
|
+
...current,
|
|
20
|
+
state: input.to,
|
|
21
|
+
updatedAt: input.updatedAt,
|
|
22
|
+
version: current.version + 1,
|
|
23
|
+
...(input.errorCode === undefined ? {} : { errorCode: input.errorCode }),
|
|
24
|
+
};
|
|
25
|
+
return await backend.compareAndSet("effect", input.effectId, input.version, next);
|
|
26
|
+
},
|
|
27
|
+
scan: (input) => backend.scan("effect", input),
|
|
28
|
+
};
|
|
29
|
+
const approvals = {
|
|
30
|
+
durability: "durable",
|
|
31
|
+
create: (record) => backend.create("approval", record.approvalId, clone(record)),
|
|
32
|
+
get: (approvalId) => backend.get("approval", approvalId),
|
|
33
|
+
async decide(input) {
|
|
34
|
+
const current = await backend.get("approval", input.approvalId);
|
|
35
|
+
if (current === undefined ||
|
|
36
|
+
current.tenantId !== input.tenantId ||
|
|
37
|
+
current.state !== "pending" ||
|
|
38
|
+
current.expiresAt <= input.now) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
const decided = {
|
|
42
|
+
...current,
|
|
43
|
+
state: input.decision,
|
|
44
|
+
decidedBy: input.decidedBy,
|
|
45
|
+
updatedAt: input.now,
|
|
46
|
+
version: current.version + 1,
|
|
47
|
+
};
|
|
48
|
+
return await backend.compareAndSet("approval", input.approvalId, current.version, decided);
|
|
49
|
+
},
|
|
50
|
+
async consume(input) {
|
|
51
|
+
const current = await backend.get("approval", input.approvalId);
|
|
52
|
+
if (current === undefined)
|
|
53
|
+
return { state: "missing" };
|
|
54
|
+
if (current.tenantId !== input.tenantId || current.principalId !== input.principalId)
|
|
55
|
+
return { state: "binding" };
|
|
56
|
+
if (current.capability !== input.capability ||
|
|
57
|
+
current.target !== input.target ||
|
|
58
|
+
!safeEqual(current.digest, input.digest))
|
|
59
|
+
return { state: "binding" };
|
|
60
|
+
if (!safeEqual(current.tokenHash, input.tokenHash))
|
|
61
|
+
return { state: "token" };
|
|
62
|
+
if (current.state === "consumed")
|
|
63
|
+
return { state: "replay" };
|
|
64
|
+
if (current.state === "denied")
|
|
65
|
+
return { state: "denied" };
|
|
66
|
+
if (current.state === "pending")
|
|
67
|
+
return { state: "pending" };
|
|
68
|
+
if (current.state === "expired" || current.expiresAt <= input.now)
|
|
69
|
+
return { state: "expired" };
|
|
70
|
+
const consumed = await backend.compareAndSet("approval", input.approvalId, current.version, {
|
|
71
|
+
...current,
|
|
72
|
+
state: "consumed",
|
|
73
|
+
updatedAt: input.now,
|
|
74
|
+
version: current.version + 1,
|
|
75
|
+
});
|
|
76
|
+
return consumed ? { state: "consumed" } : { state: "replay" };
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
const sagas = {
|
|
80
|
+
durability: "durable",
|
|
81
|
+
create: (record) => backend.create("saga", record.sagaId, clone(record)),
|
|
82
|
+
get: (sagaId) => backend.get("saga", sagaId),
|
|
83
|
+
compareAndSet: (input) => backend.compareAndSet("saga", input.sagaId, input.version, clone(input.record)),
|
|
84
|
+
scan: (input) => backend.scan("saga", input),
|
|
85
|
+
};
|
|
86
|
+
const leases = {
|
|
87
|
+
durability: "durable",
|
|
88
|
+
acquire: (input) => backend.acquireLease(input),
|
|
89
|
+
renew: (input) => backend.renewLease(input),
|
|
90
|
+
checkpoint: (input) => backend.checkpointLease(input),
|
|
91
|
+
release: (input) => backend.releaseLease(input),
|
|
92
|
+
};
|
|
93
|
+
return Object.freeze({ effects, approvals, sagas, leases });
|
|
94
|
+
}
|
|
95
|
+
/** Deterministic reference backend for tests and adapter conformance; not for production. */
|
|
96
|
+
export class MemoryDurableRecordBackend {
|
|
97
|
+
records = new Map();
|
|
98
|
+
leases = new Map();
|
|
99
|
+
key(kind, id) {
|
|
100
|
+
return `${kind}:${id}`;
|
|
101
|
+
}
|
|
102
|
+
async create(kind, id, record) {
|
|
103
|
+
const key = this.key(kind, id);
|
|
104
|
+
if (this.records.has(key))
|
|
105
|
+
return false;
|
|
106
|
+
this.records.set(key, clone(record));
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
async get(kind, id) {
|
|
110
|
+
const value = this.records.get(this.key(kind, id));
|
|
111
|
+
return value === undefined ? undefined : clone(value);
|
|
112
|
+
}
|
|
113
|
+
async compareAndSet(kind, id, expectedVersion, record) {
|
|
114
|
+
const key = this.key(kind, id);
|
|
115
|
+
const current = this.records.get(key);
|
|
116
|
+
if (current?.version !== expectedVersion || record.version !== expectedVersion + 1)
|
|
117
|
+
return false;
|
|
118
|
+
this.records.set(key, clone(record));
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
async scan(kind, input) {
|
|
122
|
+
const prefix = `${kind}:`;
|
|
123
|
+
const records = [...this.records.entries()]
|
|
124
|
+
.filter(([key, value]) => {
|
|
125
|
+
if (!key.startsWith(prefix) || !input.states.includes(value.state ?? ""))
|
|
126
|
+
return false;
|
|
127
|
+
return input.updatedBefore === undefined || (value.updatedAt ?? 0) <= input.updatedBefore;
|
|
128
|
+
})
|
|
129
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
130
|
+
.filter(([key]) => input.cursor === undefined || key > prefix + input.cursor);
|
|
131
|
+
const page = records.slice(0, input.limit);
|
|
132
|
+
return Object.freeze({
|
|
133
|
+
records: Object.freeze(page.map(([, value]) => clone(value))),
|
|
134
|
+
...(records.length > input.limit
|
|
135
|
+
? { cursor: (page.at(-1)?.[0] ?? "").slice(prefix.length) }
|
|
136
|
+
: {}),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
async acquireLease(input) {
|
|
140
|
+
const current = this.leases.get(input.name);
|
|
141
|
+
if (current?.owner !== undefined && (current.expiresAt ?? 0) > input.now)
|
|
142
|
+
return undefined;
|
|
143
|
+
const expiresAt = input.now + input.leaseMs;
|
|
144
|
+
const token = crypto.randomUUID();
|
|
145
|
+
const next = {
|
|
146
|
+
owner: input.owner,
|
|
147
|
+
token,
|
|
148
|
+
expiresAt,
|
|
149
|
+
...(current?.cursor === undefined ? {} : { cursor: current.cursor }),
|
|
150
|
+
};
|
|
151
|
+
this.leases.set(input.name, next);
|
|
152
|
+
return Object.freeze({ name: input.name, ...next });
|
|
153
|
+
}
|
|
154
|
+
async renewLease(input) {
|
|
155
|
+
const current = this.leases.get(input.name);
|
|
156
|
+
if (current?.owner !== input.owner ||
|
|
157
|
+
!safeEqual(current.token, input.token) ||
|
|
158
|
+
(current.expiresAt ?? 0) <= input.now)
|
|
159
|
+
return false;
|
|
160
|
+
current.expiresAt = input.now + input.leaseMs;
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
async checkpointLease(input) {
|
|
164
|
+
const current = this.leases.get(input.name);
|
|
165
|
+
if (current?.owner !== input.owner || !safeEqual(current.token, input.token))
|
|
166
|
+
return false;
|
|
167
|
+
if (input.cursor === undefined)
|
|
168
|
+
delete current.cursor;
|
|
169
|
+
else
|
|
170
|
+
current.cursor = input.cursor;
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
async releaseLease(input) {
|
|
174
|
+
const current = this.leases.get(input.name);
|
|
175
|
+
if (current?.owner !== input.owner || !safeEqual(current.token, input.token))
|
|
176
|
+
return false;
|
|
177
|
+
delete current.owner;
|
|
178
|
+
delete current.token;
|
|
179
|
+
delete current.expiresAt;
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function requireConformance(condition, message) {
|
|
184
|
+
if (!condition)
|
|
185
|
+
throw new Error(`durable adapter conformance: ${message}`);
|
|
186
|
+
}
|
|
187
|
+
/** Runtime-independent conformance suite reusable by adapter authors and CI. */
|
|
188
|
+
export async function runDurableExecutionAdapterConformance(adapter) {
|
|
189
|
+
const prefix = crypto.randomUUID();
|
|
190
|
+
const effectId = `${prefix}-effect`;
|
|
191
|
+
const effect = {
|
|
192
|
+
effectId,
|
|
193
|
+
capability: "test.write",
|
|
194
|
+
state: "admission",
|
|
195
|
+
createdAt: 1,
|
|
196
|
+
updatedAt: 1,
|
|
197
|
+
version: 1,
|
|
198
|
+
};
|
|
199
|
+
requireConformance(await adapter.effects.create(effect), "effect create rejected");
|
|
200
|
+
requireConformance(!(await adapter.effects.create(effect)), "duplicate effect create accepted");
|
|
201
|
+
requireConformance(await adapter.effects.transition({
|
|
202
|
+
effectId,
|
|
203
|
+
version: 1,
|
|
204
|
+
from: "admission",
|
|
205
|
+
to: "executing",
|
|
206
|
+
updatedAt: 2,
|
|
207
|
+
}), "effect CAS rejected");
|
|
208
|
+
requireConformance(!(await adapter.effects.transition({
|
|
209
|
+
effectId,
|
|
210
|
+
version: 1,
|
|
211
|
+
from: "admission",
|
|
212
|
+
to: "failed",
|
|
213
|
+
updatedAt: 3,
|
|
214
|
+
})), "stale effect CAS accepted");
|
|
215
|
+
const approvalId = `${prefix}-approval`;
|
|
216
|
+
const approval = {
|
|
217
|
+
approvalId,
|
|
218
|
+
effectId,
|
|
219
|
+
capability: "test.write",
|
|
220
|
+
tenantId: "tenant",
|
|
221
|
+
principalId: "principal",
|
|
222
|
+
tokenHash: "hash",
|
|
223
|
+
state: "pending",
|
|
224
|
+
createdAt: 1,
|
|
225
|
+
expiresAt: 100,
|
|
226
|
+
updatedAt: 1,
|
|
227
|
+
version: 1,
|
|
228
|
+
};
|
|
229
|
+
requireConformance(await adapter.approvals.create(approval), "approval create rejected");
|
|
230
|
+
requireConformance(await adapter.approvals.decide({
|
|
231
|
+
approvalId,
|
|
232
|
+
tenantId: "tenant",
|
|
233
|
+
decision: "approved",
|
|
234
|
+
decidedBy: "operator",
|
|
235
|
+
now: 2,
|
|
236
|
+
}), "approval decision rejected");
|
|
237
|
+
const consumed = await adapter.approvals.consume({
|
|
238
|
+
approvalId,
|
|
239
|
+
tenantId: "tenant",
|
|
240
|
+
principalId: "principal",
|
|
241
|
+
capability: "test.write",
|
|
242
|
+
tokenHash: "hash",
|
|
243
|
+
now: 3,
|
|
244
|
+
});
|
|
245
|
+
requireConformance(consumed.state === "consumed", "approval consume rejected");
|
|
246
|
+
const replay = await adapter.approvals.consume({
|
|
247
|
+
approvalId,
|
|
248
|
+
tenantId: "tenant",
|
|
249
|
+
principalId: "principal",
|
|
250
|
+
capability: "test.write",
|
|
251
|
+
tokenHash: "hash",
|
|
252
|
+
now: 4,
|
|
253
|
+
});
|
|
254
|
+
requireConformance(replay.state === "replay", "approval replay not detected");
|
|
255
|
+
const sagaId = `${prefix}-saga`;
|
|
256
|
+
const saga = {
|
|
257
|
+
sagaId,
|
|
258
|
+
definition: "test",
|
|
259
|
+
state: "running",
|
|
260
|
+
input: null,
|
|
261
|
+
steps: [],
|
|
262
|
+
createdAt: 1,
|
|
263
|
+
updatedAt: 1,
|
|
264
|
+
version: 1,
|
|
265
|
+
};
|
|
266
|
+
requireConformance(await adapter.sagas.create(saga), "saga create rejected");
|
|
267
|
+
requireConformance(await adapter.sagas.compareAndSet({
|
|
268
|
+
sagaId,
|
|
269
|
+
version: 1,
|
|
270
|
+
record: { ...saga, state: "completed", updatedAt: 2, version: 2 },
|
|
271
|
+
}), "saga CAS rejected");
|
|
272
|
+
const lease = await adapter.leases.acquire({
|
|
273
|
+
name: `${prefix}-worker`,
|
|
274
|
+
owner: "a",
|
|
275
|
+
now: 1,
|
|
276
|
+
leaseMs: 100,
|
|
277
|
+
});
|
|
278
|
+
requireConformance(lease !== undefined, "lease acquire rejected");
|
|
279
|
+
requireConformance((await adapter.leases.acquire({
|
|
280
|
+
name: `${prefix}-worker`,
|
|
281
|
+
owner: "b",
|
|
282
|
+
now: 2,
|
|
283
|
+
leaseMs: 100,
|
|
284
|
+
})) === undefined, "live lease stolen");
|
|
285
|
+
requireConformance(await adapter.leases.checkpoint({
|
|
286
|
+
name: lease.name,
|
|
287
|
+
owner: lease.owner,
|
|
288
|
+
token: lease.token,
|
|
289
|
+
cursor: "next",
|
|
290
|
+
}), "lease checkpoint rejected");
|
|
291
|
+
requireConformance(await adapter.leases.release({ name: lease.name, owner: lease.owner, token: lease.token }), "lease release rejected");
|
|
292
|
+
return { effects: true, approvals: true, sagas: true, leases: true };
|
|
293
|
+
}
|
|
294
|
+
function sqlIdentifier(value) {
|
|
295
|
+
if (!/^[a-z_][a-z0-9_]{0,62}$/u.test(value))
|
|
296
|
+
throw new TypeError("invalid SQL table prefix");
|
|
297
|
+
return value;
|
|
298
|
+
}
|
|
299
|
+
function recordFromRow(row) {
|
|
300
|
+
if (row === undefined)
|
|
301
|
+
return undefined;
|
|
302
|
+
const payload = row.payload;
|
|
303
|
+
const text = typeof payload === "string" ? payload : JSON.stringify(payload);
|
|
304
|
+
return clone(parseWire(text));
|
|
305
|
+
}
|
|
306
|
+
function serializeRecord(record) {
|
|
307
|
+
return stringifyWire(record);
|
|
308
|
+
}
|
|
309
|
+
export class PostgresDurableRecordBackend {
|
|
310
|
+
client;
|
|
311
|
+
recordsTable;
|
|
312
|
+
leasesTable;
|
|
313
|
+
constructor(client, options = {}) {
|
|
314
|
+
this.client = client;
|
|
315
|
+
const prefix = sqlIdentifier(options.tablePrefix ?? "nifra_durable");
|
|
316
|
+
this.recordsTable = `${prefix}_records`;
|
|
317
|
+
this.leasesTable = `${prefix}_leases`;
|
|
318
|
+
}
|
|
319
|
+
async migrate() {
|
|
320
|
+
await this.client.query("CREATE TABLE IF NOT EXISTS " +
|
|
321
|
+
this.recordsTable +
|
|
322
|
+
" (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))");
|
|
323
|
+
await this.client.query("CREATE INDEX IF NOT EXISTS " +
|
|
324
|
+
this.recordsTable +
|
|
325
|
+
"_reconcile ON " +
|
|
326
|
+
this.recordsTable +
|
|
327
|
+
" (kind, state, updated_at, id)");
|
|
328
|
+
await this.client.query("CREATE TABLE IF NOT EXISTS " +
|
|
329
|
+
this.leasesTable +
|
|
330
|
+
" (name TEXT PRIMARY KEY, owner TEXT, token TEXT, expires_at BIGINT NOT NULL DEFAULT 0, cursor TEXT)");
|
|
331
|
+
}
|
|
332
|
+
async create(kind, id, record) {
|
|
333
|
+
const result = await this.client.query("INSERT INTO " +
|
|
334
|
+
this.recordsTable +
|
|
335
|
+
" (kind,id,state,updated_at,version,payload) VALUES ($1,$2,$3,$4,$5,$6::jsonb) ON CONFLICT DO NOTHING RETURNING id", [
|
|
336
|
+
kind,
|
|
337
|
+
id,
|
|
338
|
+
record.state ?? "",
|
|
339
|
+
record.updatedAt ?? 0,
|
|
340
|
+
record.version,
|
|
341
|
+
serializeRecord(record),
|
|
342
|
+
]);
|
|
343
|
+
return result.rows.length === 1;
|
|
344
|
+
}
|
|
345
|
+
async get(kind, id) {
|
|
346
|
+
const result = await this.client.query(`SELECT payload FROM ${this.recordsTable} WHERE kind=$1 AND id=$2`, [kind, id]);
|
|
347
|
+
return recordFromRow(result.rows[0]);
|
|
348
|
+
}
|
|
349
|
+
async compareAndSet(kind, id, expectedVersion, record) {
|
|
350
|
+
if (record.version !== expectedVersion + 1)
|
|
351
|
+
return false;
|
|
352
|
+
const result = await this.client.query("UPDATE " +
|
|
353
|
+
this.recordsTable +
|
|
354
|
+
" SET state=$4,updated_at=$5,version=$6,payload=$7::jsonb WHERE kind=$1 AND id=$2 AND version=$3 RETURNING id", [
|
|
355
|
+
kind,
|
|
356
|
+
id,
|
|
357
|
+
expectedVersion,
|
|
358
|
+
record.state ?? "",
|
|
359
|
+
record.updatedAt ?? 0,
|
|
360
|
+
record.version,
|
|
361
|
+
serializeRecord(record),
|
|
362
|
+
]);
|
|
363
|
+
return result.rows.length === 1;
|
|
364
|
+
}
|
|
365
|
+
async scan(kind, input) {
|
|
366
|
+
const result = await this.client.query("SELECT id,payload FROM " +
|
|
367
|
+
this.recordsTable +
|
|
368
|
+
" 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", [kind, input.states, input.updatedBefore ?? null, input.cursor ?? null, input.limit + 1]);
|
|
369
|
+
const hasMore = result.rows.length > input.limit;
|
|
370
|
+
const rows = result.rows.slice(0, input.limit);
|
|
371
|
+
return Object.freeze({
|
|
372
|
+
records: Object.freeze(rows.map((row) => recordFromRow(row))),
|
|
373
|
+
...(hasMore ? { cursor: String(rows.at(-1)?.id) } : {}),
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
async acquireLease(input) {
|
|
377
|
+
const token = crypto.randomUUID();
|
|
378
|
+
const expiresAt = input.now + input.leaseMs;
|
|
379
|
+
const result = await this.client.query("INSERT INTO " +
|
|
380
|
+
this.leasesTable +
|
|
381
|
+
" (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 " +
|
|
382
|
+
this.leasesTable +
|
|
383
|
+
".expires_at <= $5 RETURNING name,owner,token,expires_at,cursor", [input.name, input.owner, token, expiresAt, input.now]);
|
|
384
|
+
const row = result.rows[0];
|
|
385
|
+
if (row === undefined)
|
|
386
|
+
return undefined;
|
|
387
|
+
return Object.freeze({
|
|
388
|
+
name: String(row.name),
|
|
389
|
+
owner: String(row.owner),
|
|
390
|
+
token: String(row.token),
|
|
391
|
+
expiresAt: Number(row.expires_at),
|
|
392
|
+
...(row.cursor === null || row.cursor === undefined ? {} : { cursor: String(row.cursor) }),
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
async renewLease(input) {
|
|
396
|
+
const result = await this.client.query("UPDATE " +
|
|
397
|
+
this.leasesTable +
|
|
398
|
+
" SET expires_at=$4 WHERE name=$1 AND owner=$2 AND token=$3 AND expires_at>$5 RETURNING name", [input.name, input.owner, input.token, input.now + input.leaseMs, input.now]);
|
|
399
|
+
return result.rows.length === 1;
|
|
400
|
+
}
|
|
401
|
+
async checkpointLease(input) {
|
|
402
|
+
const result = await this.client.query("UPDATE " +
|
|
403
|
+
this.leasesTable +
|
|
404
|
+
" SET cursor=$4 WHERE name=$1 AND owner=$2 AND token=$3 RETURNING name", [input.name, input.owner, input.token, input.cursor ?? null]);
|
|
405
|
+
return result.rows.length === 1;
|
|
406
|
+
}
|
|
407
|
+
async releaseLease(input) {
|
|
408
|
+
const result = await this.client.query("UPDATE " +
|
|
409
|
+
this.leasesTable +
|
|
410
|
+
" SET owner=NULL,token=NULL,expires_at=0 WHERE name=$1 AND owner=$2 AND token=$3 RETURNING name", [input.name, input.owner, input.token]);
|
|
411
|
+
return result.rows.length === 1;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
export class PostgresDurableExecutionAdapter {
|
|
415
|
+
effects;
|
|
416
|
+
approvals;
|
|
417
|
+
sagas;
|
|
418
|
+
leases;
|
|
419
|
+
backend;
|
|
420
|
+
constructor(client, options = {}) {
|
|
421
|
+
this.backend = new PostgresDurableRecordBackend(client, options);
|
|
422
|
+
const views = createDurableExecutionAdapter(this.backend);
|
|
423
|
+
this.effects = views.effects;
|
|
424
|
+
this.approvals = views.approvals;
|
|
425
|
+
this.sagas = views.sagas;
|
|
426
|
+
this.leases = views.leases;
|
|
427
|
+
}
|
|
428
|
+
migrate() {
|
|
429
|
+
return this.backend.migrate();
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
export class SQLiteDurableRecordBackend {
|
|
433
|
+
client;
|
|
434
|
+
recordsTable;
|
|
435
|
+
leasesTable;
|
|
436
|
+
constructor(client, options = {}) {
|
|
437
|
+
this.client = client;
|
|
438
|
+
const prefix = sqlIdentifier(options.tablePrefix ?? "nifra_durable");
|
|
439
|
+
this.recordsTable = `${prefix}_records`;
|
|
440
|
+
this.leasesTable = `${prefix}_leases`;
|
|
441
|
+
}
|
|
442
|
+
migrate() {
|
|
443
|
+
this.client.exec("CREATE TABLE IF NOT EXISTS " +
|
|
444
|
+
this.recordsTable +
|
|
445
|
+
" (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));" +
|
|
446
|
+
"CREATE INDEX IF NOT EXISTS " +
|
|
447
|
+
this.recordsTable +
|
|
448
|
+
"_reconcile ON " +
|
|
449
|
+
this.recordsTable +
|
|
450
|
+
" (kind,state,updated_at,id);" +
|
|
451
|
+
"CREATE TABLE IF NOT EXISTS " +
|
|
452
|
+
this.leasesTable +
|
|
453
|
+
" (name TEXT PRIMARY KEY, owner TEXT, token TEXT, expires_at INTEGER NOT NULL DEFAULT 0, cursor TEXT);");
|
|
454
|
+
}
|
|
455
|
+
async create(kind, id, record) {
|
|
456
|
+
const result = this.client
|
|
457
|
+
.query("INSERT OR IGNORE INTO " +
|
|
458
|
+
this.recordsTable +
|
|
459
|
+
" (kind,id,state,updated_at,version,payload) VALUES (?,?,?,?,?,?)")
|
|
460
|
+
.run(kind, id, record.state ?? "", record.updatedAt ?? 0, record.version, serializeRecord(record));
|
|
461
|
+
return result.changes === 1;
|
|
462
|
+
}
|
|
463
|
+
async get(kind, id) {
|
|
464
|
+
const row = this.client
|
|
465
|
+
.query(`SELECT payload FROM ${this.recordsTable} WHERE kind=? AND id=?`)
|
|
466
|
+
.get(kind, id);
|
|
467
|
+
return recordFromRow(row ?? undefined);
|
|
468
|
+
}
|
|
469
|
+
async compareAndSet(kind, id, expectedVersion, record) {
|
|
470
|
+
if (record.version !== expectedVersion + 1)
|
|
471
|
+
return false;
|
|
472
|
+
const result = this.client
|
|
473
|
+
.query("UPDATE " +
|
|
474
|
+
this.recordsTable +
|
|
475
|
+
" SET state=?,updated_at=?,version=?,payload=? WHERE kind=? AND id=? AND version=?")
|
|
476
|
+
.run(record.state ?? "", record.updatedAt ?? 0, record.version, serializeRecord(record), kind, id, expectedVersion);
|
|
477
|
+
return result.changes === 1;
|
|
478
|
+
}
|
|
479
|
+
async scan(kind, input) {
|
|
480
|
+
if (input.states.length === 0)
|
|
481
|
+
return { records: [] };
|
|
482
|
+
const placeholders = input.states.map(() => "?").join(",");
|
|
483
|
+
const rows = this.client
|
|
484
|
+
.query("SELECT id,payload FROM " +
|
|
485
|
+
this.recordsTable +
|
|
486
|
+
" WHERE kind=? AND state IN (" +
|
|
487
|
+
placeholders +
|
|
488
|
+
") AND (? IS NULL OR updated_at <= ?) AND (? IS NULL OR id > ?) ORDER BY id LIMIT ?")
|
|
489
|
+
.all(kind, ...input.states, input.updatedBefore ?? null, input.updatedBefore ?? null, input.cursor ?? null, input.cursor ?? null, input.limit + 1);
|
|
490
|
+
const present = rows.filter((row) => row !== undefined);
|
|
491
|
+
const hasMore = present.length > input.limit;
|
|
492
|
+
const page = present.slice(0, input.limit);
|
|
493
|
+
return Object.freeze({
|
|
494
|
+
records: Object.freeze(page.map((row) => recordFromRow(row))),
|
|
495
|
+
...(hasMore ? { cursor: String(page.at(-1)?.id) } : {}),
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
async acquireLease(input) {
|
|
499
|
+
const token = crypto.randomUUID();
|
|
500
|
+
const expiresAt = input.now + input.leaseMs;
|
|
501
|
+
const row = this.client
|
|
502
|
+
.query("INSERT INTO " +
|
|
503
|
+
this.leasesTable +
|
|
504
|
+
" (name,owner,token,expires_at) VALUES (?,?,?,?) ON CONFLICT(name) DO UPDATE SET owner=excluded.owner,token=excluded.token,expires_at=excluded.expires_at WHERE " +
|
|
505
|
+
this.leasesTable +
|
|
506
|
+
".expires_at <= ? RETURNING name,owner,token,expires_at,cursor")
|
|
507
|
+
.get(input.name, input.owner, token, expiresAt, input.now);
|
|
508
|
+
if (row === undefined || row === null)
|
|
509
|
+
return undefined;
|
|
510
|
+
return Object.freeze({
|
|
511
|
+
name: String(row.name),
|
|
512
|
+
owner: String(row.owner),
|
|
513
|
+
token: String(row.token),
|
|
514
|
+
expiresAt: Number(row.expires_at),
|
|
515
|
+
...(row.cursor === null || row.cursor === undefined ? {} : { cursor: String(row.cursor) }),
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
async renewLease(input) {
|
|
519
|
+
return (this.client
|
|
520
|
+
.query(`UPDATE ${this.leasesTable} SET expires_at=? WHERE name=? AND owner=? AND token=? AND expires_at>?`)
|
|
521
|
+
.run(input.now + input.leaseMs, input.name, input.owner, input.token, input.now).changes ===
|
|
522
|
+
1);
|
|
523
|
+
}
|
|
524
|
+
async checkpointLease(input) {
|
|
525
|
+
return (this.client
|
|
526
|
+
.query(`UPDATE ${this.leasesTable} SET cursor=? WHERE name=? AND owner=? AND token=?`)
|
|
527
|
+
.run(input.cursor ?? null, input.name, input.owner, input.token).changes === 1);
|
|
528
|
+
}
|
|
529
|
+
async releaseLease(input) {
|
|
530
|
+
return (this.client
|
|
531
|
+
.query("UPDATE " +
|
|
532
|
+
this.leasesTable +
|
|
533
|
+
" SET owner=NULL,token=NULL,expires_at=0 WHERE name=? AND owner=? AND token=?")
|
|
534
|
+
.run(input.name, input.owner, input.token).changes === 1);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
export class SQLiteDurableExecutionAdapter {
|
|
538
|
+
effects;
|
|
539
|
+
approvals;
|
|
540
|
+
sagas;
|
|
541
|
+
leases;
|
|
542
|
+
backend;
|
|
543
|
+
constructor(client, options = {}) {
|
|
544
|
+
this.backend = new SQLiteDurableRecordBackend(client, options);
|
|
545
|
+
const views = createDurableExecutionAdapter(this.backend);
|
|
546
|
+
this.effects = views.effects;
|
|
547
|
+
this.approvals = views.approvals;
|
|
548
|
+
this.sagas = views.sagas;
|
|
549
|
+
this.leases = views.leases;
|
|
550
|
+
}
|
|
551
|
+
migrate() {
|
|
552
|
+
this.backend.migrate();
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
export class DurableObjectRecordBackend {
|
|
556
|
+
storage;
|
|
557
|
+
constructor(storage) {
|
|
558
|
+
this.storage = storage;
|
|
559
|
+
}
|
|
560
|
+
recordKey(kind, id) {
|
|
561
|
+
return `record:${kind}:${encodeURIComponent(id)}`;
|
|
562
|
+
}
|
|
563
|
+
leaseKey(name) {
|
|
564
|
+
return `lease:${encodeURIComponent(name)}`;
|
|
565
|
+
}
|
|
566
|
+
async create(kind, id, record) {
|
|
567
|
+
return await this.storage.transaction(async (txn) => {
|
|
568
|
+
const key = this.recordKey(kind, id);
|
|
569
|
+
if ((await txn.get(key)) !== undefined)
|
|
570
|
+
return false;
|
|
571
|
+
await txn.put(key, clone(record));
|
|
572
|
+
return true;
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
async get(kind, id) {
|
|
576
|
+
const value = await this.storage.get(this.recordKey(kind, id));
|
|
577
|
+
return value === undefined ? undefined : clone(value);
|
|
578
|
+
}
|
|
579
|
+
async compareAndSet(kind, id, expectedVersion, record) {
|
|
580
|
+
if (record.version !== expectedVersion + 1)
|
|
581
|
+
return false;
|
|
582
|
+
return await this.storage.transaction(async (txn) => {
|
|
583
|
+
const key = this.recordKey(kind, id);
|
|
584
|
+
const current = await txn.get(key);
|
|
585
|
+
if (current?.version !== expectedVersion)
|
|
586
|
+
return false;
|
|
587
|
+
await txn.put(key, clone(record));
|
|
588
|
+
return true;
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
async scan(kind, input) {
|
|
592
|
+
const prefix = `record:${kind}:`;
|
|
593
|
+
const listed = await this.storage.list({
|
|
594
|
+
prefix,
|
|
595
|
+
...(input.cursor === undefined
|
|
596
|
+
? {}
|
|
597
|
+
: { startAfter: prefix + encodeURIComponent(input.cursor) }),
|
|
598
|
+
limit: input.limit + 1,
|
|
599
|
+
});
|
|
600
|
+
const selected = [];
|
|
601
|
+
let lastVisited;
|
|
602
|
+
for (const [key, record] of listed) {
|
|
603
|
+
lastVisited = decodeURIComponent(key.slice(prefix.length));
|
|
604
|
+
if (input.states.includes(record.state) &&
|
|
605
|
+
(input.updatedBefore === undefined || record.updatedAt <= input.updatedBefore)) {
|
|
606
|
+
selected.push({ id: lastVisited, record });
|
|
607
|
+
}
|
|
608
|
+
if (selected.length > input.limit)
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
611
|
+
const hasMore = selected.length > input.limit || listed.size > input.limit;
|
|
612
|
+
const page = selected.slice(0, input.limit);
|
|
613
|
+
return Object.freeze({
|
|
614
|
+
records: Object.freeze(page.map(({ record }) => clone(record))),
|
|
615
|
+
...(hasMore && lastVisited !== undefined ? { cursor: lastVisited } : {}),
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
async acquireLease(input) {
|
|
619
|
+
return await this.storage.transaction(async (txn) => {
|
|
620
|
+
const key = this.leaseKey(input.name);
|
|
621
|
+
const current = await txn.get(key);
|
|
622
|
+
if (current?.owner !== undefined && current.expiresAt > input.now)
|
|
623
|
+
return undefined;
|
|
624
|
+
const token = crypto.randomUUID();
|
|
625
|
+
const next = {
|
|
626
|
+
owner: input.owner,
|
|
627
|
+
token,
|
|
628
|
+
expiresAt: input.now + input.leaseMs,
|
|
629
|
+
...(current?.cursor === undefined ? {} : { cursor: current.cursor }),
|
|
630
|
+
};
|
|
631
|
+
await txn.put(key, next);
|
|
632
|
+
return Object.freeze({
|
|
633
|
+
name: input.name,
|
|
634
|
+
owner: input.owner,
|
|
635
|
+
token,
|
|
636
|
+
expiresAt: next.expiresAt,
|
|
637
|
+
...(next.cursor === undefined ? {} : { cursor: next.cursor }),
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
async renewLease(input) {
|
|
642
|
+
return await this.storage.transaction(async (txn) => {
|
|
643
|
+
const key = this.leaseKey(input.name);
|
|
644
|
+
const current = await txn.get(key);
|
|
645
|
+
if (current?.owner !== input.owner ||
|
|
646
|
+
!safeEqual(current.token, input.token) ||
|
|
647
|
+
current.expiresAt <= input.now)
|
|
648
|
+
return false;
|
|
649
|
+
await txn.put(key, { ...current, expiresAt: input.now + input.leaseMs });
|
|
650
|
+
return true;
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
async checkpointLease(input) {
|
|
654
|
+
return await this.storage.transaction(async (txn) => {
|
|
655
|
+
const key = this.leaseKey(input.name);
|
|
656
|
+
const current = await txn.get(key);
|
|
657
|
+
if (current?.owner !== input.owner || !safeEqual(current.token, input.token))
|
|
658
|
+
return false;
|
|
659
|
+
const next = { ...current };
|
|
660
|
+
if (input.cursor === undefined)
|
|
661
|
+
delete next.cursor;
|
|
662
|
+
else
|
|
663
|
+
next.cursor = input.cursor;
|
|
664
|
+
await txn.put(key, next);
|
|
665
|
+
return true;
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
async releaseLease(input) {
|
|
669
|
+
return await this.storage.transaction(async (txn) => {
|
|
670
|
+
const key = this.leaseKey(input.name);
|
|
671
|
+
const current = await txn.get(key);
|
|
672
|
+
if (current?.owner !== input.owner || !safeEqual(current.token, input.token))
|
|
673
|
+
return false;
|
|
674
|
+
const next = { ...current, expiresAt: 0 };
|
|
675
|
+
delete next.owner;
|
|
676
|
+
delete next.token;
|
|
677
|
+
await txn.put(key, next);
|
|
678
|
+
return true;
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
export class DurableObjectExecutionAdapter {
|
|
683
|
+
effects;
|
|
684
|
+
approvals;
|
|
685
|
+
sagas;
|
|
686
|
+
leases;
|
|
687
|
+
backend;
|
|
688
|
+
constructor(storage) {
|
|
689
|
+
this.backend = new DurableObjectRecordBackend(storage);
|
|
690
|
+
const views = createDurableExecutionAdapter(this.backend);
|
|
691
|
+
this.effects = views.effects;
|
|
692
|
+
this.approvals = views.approvals;
|
|
693
|
+
this.sagas = views.sagas;
|
|
694
|
+
this.leases = views.leases;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
//# sourceMappingURL=durable-adapters.js.map
|