@capaxle/runtime 0.1.0-alpha.1
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/LICENSE +202 -0
- package/README.md +5 -0
- package/dist/authorization.d.ts +12 -0
- package/dist/authorization.d.ts.map +1 -0
- package/dist/authorization.js +23 -0
- package/dist/authorization.js.map +1 -0
- package/dist/base64url.d.ts +4 -0
- package/dist/base64url.d.ts.map +1 -0
- package/dist/base64url.js +11 -0
- package/dist/base64url.js.map +1 -0
- package/dist/confirmation-types.d.ts +255 -0
- package/dist/confirmation-types.d.ts.map +1 -0
- package/dist/confirmation-types.js +2 -0
- package/dist/confirmation-types.js.map +1 -0
- package/dist/confirmation.d.ts +6 -0
- package/dist/confirmation.d.ts.map +1 -0
- package/dist/confirmation.js +1205 -0
- package/dist/confirmation.js.map +1 -0
- package/dist/idempotency-types.d.ts +130 -0
- package/dist/idempotency-types.d.ts.map +1 -0
- package/dist/idempotency-types.js +2 -0
- package/dist/idempotency-types.js.map +1 -0
- package/dist/idempotency.d.ts +4 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/idempotency.js +798 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/ingress.d.ts +33 -0
- package/dist/ingress.d.ts.map +1 -0
- package/dist/ingress.js +325 -0
- package/dist/ingress.js.map +1 -0
- package/dist/kernel.d.ts +5 -0
- package/dist/kernel.d.ts.map +1 -0
- package/dist/kernel.js +2872 -0
- package/dist/kernel.js.map +1 -0
- package/dist/principal.d.ts +7 -0
- package/dist/principal.d.ts.map +1 -0
- package/dist/principal.js +130 -0
- package/dist/principal.js.map +1 -0
- package/dist/rate-limit.d.ts +12 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +82 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/redaction.d.ts +19 -0
- package/dist/redaction.d.ts.map +1 -0
- package/dist/redaction.js +338 -0
- package/dist/redaction.js.map +1 -0
- package/dist/registry.d.ts +23 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +188 -0
- package/dist/registry.js.map +1 -0
- package/dist/types.d.ts +401 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +33 -0
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
2
|
+
import { types } from "node:util";
|
|
3
|
+
import { jcs } from "@capaxle/ir";
|
|
4
|
+
import { copyJson, ownData, RuntimeConfigurationError } from "./registry.js";
|
|
5
|
+
const MAX_TIME = 253402300799999;
|
|
6
|
+
const digestPattern = /^[A-Za-z0-9_-]{43}$/;
|
|
7
|
+
class Rejection extends Error {
|
|
8
|
+
code;
|
|
9
|
+
status;
|
|
10
|
+
constructor(code, status = "failed_precondition") {
|
|
11
|
+
super(code);
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.status = status;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
class StaleCompletion extends Error {
|
|
17
|
+
}
|
|
18
|
+
class CompletionMismatch extends Error {
|
|
19
|
+
}
|
|
20
|
+
const rejected = (code, status = "failed_precondition", retryable = false) => ({
|
|
21
|
+
ok: false,
|
|
22
|
+
error: {
|
|
23
|
+
code,
|
|
24
|
+
status,
|
|
25
|
+
message: "Idempotency admission or persistence failed.",
|
|
26
|
+
retryable,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
function controlsLive(controls) {
|
|
30
|
+
if (!controls)
|
|
31
|
+
return;
|
|
32
|
+
const data = ownData(controls, [
|
|
33
|
+
"signal",
|
|
34
|
+
"deadlineMs",
|
|
35
|
+
"refreshCancellation",
|
|
36
|
+
"kernelInvocationId",
|
|
37
|
+
"correlationId",
|
|
38
|
+
]);
|
|
39
|
+
if (data.refreshCancellation !== undefined) {
|
|
40
|
+
if (typeof data.refreshCancellation !== "function")
|
|
41
|
+
throw new Error("controls");
|
|
42
|
+
data.refreshCancellation();
|
|
43
|
+
}
|
|
44
|
+
if (data.signal !== undefined) {
|
|
45
|
+
if (types.isProxy(data.signal))
|
|
46
|
+
throw new Error("signal");
|
|
47
|
+
const aborted = Object.getOwnPropertyDescriptor(AbortSignal.prototype, "aborted").get.call(data.signal);
|
|
48
|
+
if (aborted)
|
|
49
|
+
throw new Rejection("CAP_CANCELLED", "cancelled");
|
|
50
|
+
}
|
|
51
|
+
if (data.deadlineMs !== undefined) {
|
|
52
|
+
if (!Number.isSafeInteger(data.deadlineMs))
|
|
53
|
+
throw new Error("deadline");
|
|
54
|
+
if (Date.now() >= data.deadlineMs)
|
|
55
|
+
throw new Rejection("CAP_DEADLINE_EXCEEDED", "deadline_exceeded");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function native(work) {
|
|
59
|
+
if (types.isProxy(work) || !types.isPromise(work))
|
|
60
|
+
throw new Error("provider_promise");
|
|
61
|
+
return await work;
|
|
62
|
+
}
|
|
63
|
+
export function createIdempotencyState(service, deployment) {
|
|
64
|
+
if (!service || !deployment)
|
|
65
|
+
throw new RuntimeConfigurationError("CAP_IDEMPOTENCY_CONFIGURATION_INVALID");
|
|
66
|
+
return {
|
|
67
|
+
service,
|
|
68
|
+
deployment,
|
|
69
|
+
timeFloor: 0,
|
|
70
|
+
records: Object.create(null),
|
|
71
|
+
ownerReservations: Object.create(null),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export function createIdempotencyProvider(options) {
|
|
75
|
+
let o;
|
|
76
|
+
let policy;
|
|
77
|
+
try {
|
|
78
|
+
o = ownData(options, [
|
|
79
|
+
"service",
|
|
80
|
+
"deployment",
|
|
81
|
+
"digestKey",
|
|
82
|
+
"store",
|
|
83
|
+
"codec",
|
|
84
|
+
"policy",
|
|
85
|
+
"confirmationProvider",
|
|
86
|
+
"fenceLostOwner",
|
|
87
|
+
"clock",
|
|
88
|
+
"randomBytes",
|
|
89
|
+
]);
|
|
90
|
+
const p = ownData(o.policy, [
|
|
91
|
+
"keyRetentionMs",
|
|
92
|
+
"resultRetentionMs",
|
|
93
|
+
"maxKeyBytes",
|
|
94
|
+
"maxResultBytes",
|
|
95
|
+
"maxPayloadBytes",
|
|
96
|
+
"maxRecords",
|
|
97
|
+
"maxOwnerReservations",
|
|
98
|
+
"cacheDeclaredErrors",
|
|
99
|
+
"cacheUnexpectedErrors",
|
|
100
|
+
]);
|
|
101
|
+
for (const name of [
|
|
102
|
+
"keyRetentionMs",
|
|
103
|
+
"resultRetentionMs",
|
|
104
|
+
"maxKeyBytes",
|
|
105
|
+
"maxResultBytes",
|
|
106
|
+
"maxPayloadBytes",
|
|
107
|
+
"maxRecords",
|
|
108
|
+
"maxOwnerReservations",
|
|
109
|
+
])
|
|
110
|
+
if (!Number.isSafeInteger(p[name]) ||
|
|
111
|
+
p[name] <= 0 ||
|
|
112
|
+
p[name] > MAX_TIME)
|
|
113
|
+
throw new Error("bounds");
|
|
114
|
+
if (p.resultRetentionMs > p.keyRetentionMs ||
|
|
115
|
+
typeof p.cacheDeclaredErrors !== "boolean" ||
|
|
116
|
+
typeof p.cacheUnexpectedErrors !== "boolean")
|
|
117
|
+
throw new Error("policy");
|
|
118
|
+
policy = Object.freeze(p);
|
|
119
|
+
const codec = ownData(o.codec);
|
|
120
|
+
const store = ownData(o.store);
|
|
121
|
+
if (typeof o.service !== "string" ||
|
|
122
|
+
!o.service ||
|
|
123
|
+
typeof o.deployment !== "string" ||
|
|
124
|
+
!o.deployment ||
|
|
125
|
+
types.isProxy(o.digestKey) ||
|
|
126
|
+
!(o.digestKey instanceof Uint8Array) ||
|
|
127
|
+
o.digestKey.length !== 32 ||
|
|
128
|
+
!o.digestKey.some((v) => v !== 0) ||
|
|
129
|
+
typeof store.atomic !== "function" ||
|
|
130
|
+
typeof codec.encrypt !== "function" ||
|
|
131
|
+
typeof codec.decrypt !== "function")
|
|
132
|
+
throw new Error("options");
|
|
133
|
+
if (o.confirmationProvider !== undefined &&
|
|
134
|
+
typeof ownData(o.confirmationProvider).queryExecutionCompletion !==
|
|
135
|
+
"function")
|
|
136
|
+
throw new Error("confirmation");
|
|
137
|
+
for (const name of ["clock", "randomBytes", "fenceLostOwner"])
|
|
138
|
+
if (o[name] !== undefined && typeof o[name] !== "function")
|
|
139
|
+
throw new Error("function");
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
throw new RuntimeConfigurationError("CAP_IDEMPOTENCY_CONFIGURATION_INVALID");
|
|
143
|
+
}
|
|
144
|
+
const key = Buffer.from(o.digestKey);
|
|
145
|
+
const store = o.store;
|
|
146
|
+
const codec = o.codec;
|
|
147
|
+
const clock = o.clock ?? Date.now;
|
|
148
|
+
const random = o.randomBytes ?? randomBytes;
|
|
149
|
+
const confirmationProvider = o.confirmationProvider;
|
|
150
|
+
const fenceLostOwner = o.fenceLostOwner;
|
|
151
|
+
const claims = new WeakSet();
|
|
152
|
+
const enteredClaims = new WeakSet();
|
|
153
|
+
const pending = new Map();
|
|
154
|
+
let unhealthy = false;
|
|
155
|
+
function checked(state) {
|
|
156
|
+
const s = ownData(state, [
|
|
157
|
+
"service",
|
|
158
|
+
"deployment",
|
|
159
|
+
"timeFloor",
|
|
160
|
+
"records",
|
|
161
|
+
"ownerReservations",
|
|
162
|
+
]);
|
|
163
|
+
if (s.service !== o.service ||
|
|
164
|
+
s.deployment !== o.deployment ||
|
|
165
|
+
!Number.isSafeInteger(s.timeFloor) ||
|
|
166
|
+
s.timeFloor < 0 ||
|
|
167
|
+
s.timeFloor > MAX_TIME)
|
|
168
|
+
throw new Error("state");
|
|
169
|
+
ownData(s.records);
|
|
170
|
+
ownData(s.ownerReservations);
|
|
171
|
+
if (Object.keys(state.records).length > policy.maxRecords ||
|
|
172
|
+
Object.keys(state.ownerReservations).length > policy.maxOwnerReservations)
|
|
173
|
+
throw new Error("capacity_integrity");
|
|
174
|
+
for (const [id, reserved] of Object.entries(ownData(s.ownerReservations)))
|
|
175
|
+
if (!digestPattern.test(id) || reserved !== true)
|
|
176
|
+
throw new Error("reservation_integrity");
|
|
177
|
+
}
|
|
178
|
+
function time(state) {
|
|
179
|
+
const t = clock();
|
|
180
|
+
if (!Number.isSafeInteger(t) || t < 0 || t > MAX_TIME) {
|
|
181
|
+
unhealthy = true;
|
|
182
|
+
throw new Error("clock");
|
|
183
|
+
}
|
|
184
|
+
state.timeFloor = Math.max(t, state.timeFloor);
|
|
185
|
+
return state.timeFloor;
|
|
186
|
+
}
|
|
187
|
+
async function atomic(fn, controls, postEntry = false) {
|
|
188
|
+
if (unhealthy && !postEntry)
|
|
189
|
+
throw new Error("unhealthy");
|
|
190
|
+
controlsLive(controls);
|
|
191
|
+
const outcome = await native(store.atomic((state) => {
|
|
192
|
+
checked(state);
|
|
193
|
+
controlsLive(controls);
|
|
194
|
+
try {
|
|
195
|
+
return {
|
|
196
|
+
value: fn(state, postEntry ? state.timeFloor : time(state)),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
// Semantic denial may follow durable payload expiration. Commit that
|
|
201
|
+
// safe housekeeping; dependency/cancellation failures still abort.
|
|
202
|
+
if (error instanceof Rejection)
|
|
203
|
+
return { rejection: error };
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
}, controls));
|
|
207
|
+
if ("rejection" in outcome)
|
|
208
|
+
throw outcome.rejection;
|
|
209
|
+
return outcome.value;
|
|
210
|
+
}
|
|
211
|
+
function failure(error, postEntry = false) {
|
|
212
|
+
return error instanceof Rejection
|
|
213
|
+
? rejected(error.code, error.status)
|
|
214
|
+
: rejected("CAP_DEPENDENCY_UNAVAILABLE", "unavailable", !postEntry);
|
|
215
|
+
}
|
|
216
|
+
function hash(domain, value) {
|
|
217
|
+
return createHmac("sha256", key)
|
|
218
|
+
.update(`capaxle.idempotency.v1.${domain}\0`)
|
|
219
|
+
.update(jcs(value))
|
|
220
|
+
.digest("base64url");
|
|
221
|
+
}
|
|
222
|
+
function action(value) {
|
|
223
|
+
const a = ownData(value, [
|
|
224
|
+
"capabilityId",
|
|
225
|
+
"version",
|
|
226
|
+
"irHash",
|
|
227
|
+
"impact",
|
|
228
|
+
"confirmation",
|
|
229
|
+
"identity",
|
|
230
|
+
"input",
|
|
231
|
+
"key",
|
|
232
|
+
]);
|
|
233
|
+
if (typeof a.key !== "string" ||
|
|
234
|
+
!a.key ||
|
|
235
|
+
Buffer.byteLength(a.key) > policy.maxKeyBytes ||
|
|
236
|
+
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(a.key))
|
|
237
|
+
throw new Rejection("CAP_IDEMPOTENCY_KEY_REQUIRED", "invalid_argument");
|
|
238
|
+
for (const name of [
|
|
239
|
+
"capabilityId",
|
|
240
|
+
"version",
|
|
241
|
+
"irHash",
|
|
242
|
+
"impact",
|
|
243
|
+
"confirmation",
|
|
244
|
+
])
|
|
245
|
+
if (typeof a[name] !== "string" || !a[name])
|
|
246
|
+
throw new Error("action");
|
|
247
|
+
if (!/^\d+\.\d+\.\d+(?:[-+].*)?$/.test(a.version) ||
|
|
248
|
+
!["read", "write", "destructive"].includes(a.impact) ||
|
|
249
|
+
!["none", "required"].includes(a.confirmation))
|
|
250
|
+
throw new Error("action");
|
|
251
|
+
const identity = ownData(a.identity, [
|
|
252
|
+
"originating",
|
|
253
|
+
"effective",
|
|
254
|
+
"authorityChain",
|
|
255
|
+
]);
|
|
256
|
+
const identifier = (principal) => {
|
|
257
|
+
const p = ownData(principal);
|
|
258
|
+
for (const name of ["providerId", "type", "subject"])
|
|
259
|
+
if (typeof p[name] !== "string" || !p[name])
|
|
260
|
+
throw new Error("identity");
|
|
261
|
+
if (p.tenant !== undefined && typeof p.tenant !== "string")
|
|
262
|
+
throw new Error("tenant");
|
|
263
|
+
return {
|
|
264
|
+
providerId: p.providerId,
|
|
265
|
+
type: p.type,
|
|
266
|
+
subject: p.subject,
|
|
267
|
+
tenant: p.tenant === undefined
|
|
268
|
+
? { present: false }
|
|
269
|
+
: { present: true, value: p.tenant },
|
|
270
|
+
};
|
|
271
|
+
};
|
|
272
|
+
if (!Array.isArray(identity.authorityChain) ||
|
|
273
|
+
types.isProxy(identity.authorityChain) ||
|
|
274
|
+
!identity.authorityChain.length)
|
|
275
|
+
throw new Error("identity");
|
|
276
|
+
const chain = copyJson(identity.authorityChain).map(identifier);
|
|
277
|
+
return {
|
|
278
|
+
index: hash("index", {
|
|
279
|
+
service: o.service,
|
|
280
|
+
deployment: o.deployment,
|
|
281
|
+
capabilityId: a.capabilityId,
|
|
282
|
+
major: a.version.split(".")[0],
|
|
283
|
+
key: a.key,
|
|
284
|
+
originating: identifier(identity.originating),
|
|
285
|
+
effective: identifier(identity.effective),
|
|
286
|
+
authorityChain: chain,
|
|
287
|
+
}),
|
|
288
|
+
fingerprint: hash("action", {
|
|
289
|
+
capabilityId: a.capabilityId,
|
|
290
|
+
version: a.version,
|
|
291
|
+
irHash: a.irHash,
|
|
292
|
+
impact: a.impact,
|
|
293
|
+
confirmation: a.confirmation,
|
|
294
|
+
input: copyJson(a.input),
|
|
295
|
+
}),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
function record(state, index) {
|
|
299
|
+
if (!Object.hasOwn(state.records, index))
|
|
300
|
+
return undefined;
|
|
301
|
+
const r = state.records[index];
|
|
302
|
+
const data = ownData(r, [
|
|
303
|
+
"index",
|
|
304
|
+
"fingerprint",
|
|
305
|
+
"ownerId",
|
|
306
|
+
"requiresConfirmation",
|
|
307
|
+
"state",
|
|
308
|
+
"confirmation",
|
|
309
|
+
"classification",
|
|
310
|
+
"terminalDigest",
|
|
311
|
+
"payload",
|
|
312
|
+
"replayable",
|
|
313
|
+
"completedAt",
|
|
314
|
+
"resultExpiresAt",
|
|
315
|
+
"keyExpiresAt",
|
|
316
|
+
"integrityTag",
|
|
317
|
+
]);
|
|
318
|
+
if (typeof data.integrityTag !== "string" ||
|
|
319
|
+
!digestPattern.test(data.integrityTag) ||
|
|
320
|
+
data.integrityTag !== integrity(r))
|
|
321
|
+
throw new Error("record_integrity");
|
|
322
|
+
if (data.index !== index ||
|
|
323
|
+
typeof data.requiresConfirmation !== "boolean" ||
|
|
324
|
+
typeof data.fingerprint !== "string" ||
|
|
325
|
+
!digestPattern.test(data.fingerprint) ||
|
|
326
|
+
typeof data.ownerId !== "string" ||
|
|
327
|
+
!digestPattern.test(data.ownerId) ||
|
|
328
|
+
!Object.hasOwn(state.ownerReservations, data.ownerId) ||
|
|
329
|
+
state.ownerReservations[data.ownerId] !== true ||
|
|
330
|
+
![
|
|
331
|
+
"CLAIMED",
|
|
332
|
+
"HANDLER_ENTRY_AUTHORIZED",
|
|
333
|
+
"COMPLETION_PENDING",
|
|
334
|
+
"COMPLETED",
|
|
335
|
+
"BLOCKED_TERMINAL",
|
|
336
|
+
"AMBIGUOUS",
|
|
337
|
+
].includes(data.state))
|
|
338
|
+
throw new Error("record");
|
|
339
|
+
if (r.payload !== undefined &&
|
|
340
|
+
(typeof r.payload !== "string" ||
|
|
341
|
+
Buffer.byteLength(r.payload) > policy.maxPayloadBytes))
|
|
342
|
+
throw new Error("payload");
|
|
343
|
+
if (r.confirmation)
|
|
344
|
+
linkage(r.confirmation);
|
|
345
|
+
if (["COMPLETION_PENDING", "COMPLETED", "BLOCKED_TERMINAL"].includes(r.state) &&
|
|
346
|
+
(!["succeeded", "failed"].includes(r.classification ?? "") ||
|
|
347
|
+
typeof r.terminalDigest !== "string" ||
|
|
348
|
+
!digestPattern.test(r.terminalDigest) ||
|
|
349
|
+
typeof r.replayable !== "boolean" ||
|
|
350
|
+
(r.requiresConfirmation && !r.confirmation)))
|
|
351
|
+
throw new Error("terminal_integrity");
|
|
352
|
+
if (["COMPLETED", "BLOCKED_TERMINAL"].includes(r.state)) {
|
|
353
|
+
for (const name of [
|
|
354
|
+
"completedAt",
|
|
355
|
+
"resultExpiresAt",
|
|
356
|
+
"keyExpiresAt",
|
|
357
|
+
])
|
|
358
|
+
if (!Number.isSafeInteger(r[name]) ||
|
|
359
|
+
r[name] < 0 ||
|
|
360
|
+
r[name] > MAX_TIME)
|
|
361
|
+
throw new Error("retention");
|
|
362
|
+
if (r.resultExpiresAt > r.keyExpiresAt ||
|
|
363
|
+
r.completedAt > r.resultExpiresAt)
|
|
364
|
+
throw new Error("retention");
|
|
365
|
+
}
|
|
366
|
+
return r;
|
|
367
|
+
}
|
|
368
|
+
function linkage(value) {
|
|
369
|
+
const data = ownData(value, [
|
|
370
|
+
"recordId",
|
|
371
|
+
"confirmationRef",
|
|
372
|
+
"executionAttemptId",
|
|
373
|
+
]);
|
|
374
|
+
if (typeof data.recordId !== "string" ||
|
|
375
|
+
!digestPattern.test(data.recordId) ||
|
|
376
|
+
typeof data.executionAttemptId !== "string" ||
|
|
377
|
+
!digestPattern.test(data.executionAttemptId) ||
|
|
378
|
+
typeof data.confirmationRef !== "string" ||
|
|
379
|
+
!/^capr1\.[A-Za-z0-9_-]{43}$/.test(data.confirmationRef))
|
|
380
|
+
throw new Error("linkage");
|
|
381
|
+
return Object.freeze({ ...data });
|
|
382
|
+
}
|
|
383
|
+
function terminal(value) {
|
|
384
|
+
const data = ownData(copyJson(value), ["kind", "value", "error"]);
|
|
385
|
+
if (data.kind === "success") {
|
|
386
|
+
if (!Object.hasOwn(data, "value") || Object.hasOwn(data, "error"))
|
|
387
|
+
throw new Error("terminal");
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
if (!["declared_error", "unexpected_error"].includes(data.kind) ||
|
|
391
|
+
Object.hasOwn(data, "value"))
|
|
392
|
+
throw new Error("terminal");
|
|
393
|
+
const error = ownData(data.error, [
|
|
394
|
+
"code",
|
|
395
|
+
"status",
|
|
396
|
+
"message",
|
|
397
|
+
"retryable",
|
|
398
|
+
"details",
|
|
399
|
+
]);
|
|
400
|
+
for (const name of ["code", "status", "message"])
|
|
401
|
+
if (typeof error[name] !== "string" || !error[name])
|
|
402
|
+
throw new Error("error");
|
|
403
|
+
if (typeof error.retryable !== "boolean")
|
|
404
|
+
throw new Error("error");
|
|
405
|
+
}
|
|
406
|
+
if (Buffer.byteLength(jcs(copyJson(data))) > policy.maxResultBytes)
|
|
407
|
+
throw new Error("size");
|
|
408
|
+
return copyJson(data);
|
|
409
|
+
}
|
|
410
|
+
function integrity(r) {
|
|
411
|
+
const data = ownData(r);
|
|
412
|
+
delete data.integrityTag;
|
|
413
|
+
return hash("record-integrity", copyJson(data));
|
|
414
|
+
}
|
|
415
|
+
function seal(r) {
|
|
416
|
+
r.integrityTag = integrity(r);
|
|
417
|
+
}
|
|
418
|
+
async function decode(r) {
|
|
419
|
+
if (!r.payload)
|
|
420
|
+
throw new Error("payload");
|
|
421
|
+
const data = ownData(copyJson(await native(codec.decrypt(r.payload))), [
|
|
422
|
+
"index",
|
|
423
|
+
"fingerprint",
|
|
424
|
+
"ownerId",
|
|
425
|
+
"classification",
|
|
426
|
+
"requiresConfirmation",
|
|
427
|
+
"confirmation",
|
|
428
|
+
"terminal",
|
|
429
|
+
]);
|
|
430
|
+
if (data.index !== r.index ||
|
|
431
|
+
data.fingerprint !== r.fingerprint ||
|
|
432
|
+
data.ownerId !== r.ownerId ||
|
|
433
|
+
data.classification !== r.classification ||
|
|
434
|
+
data.requiresConfirmation !== r.requiresConfirmation ||
|
|
435
|
+
jcs(copyJson(data.confirmation)) !== jcs(copyJson(r.confirmation ?? null)))
|
|
436
|
+
throw new Error("payload_binding");
|
|
437
|
+
const value = terminal(data.terminal);
|
|
438
|
+
if ((value.kind === "success" ? "succeeded" : "failed") !==
|
|
439
|
+
r.classification ||
|
|
440
|
+
hash("terminal", copyJson(value)) !== r.terminalDigest)
|
|
441
|
+
throw new Error("terminal_classification");
|
|
442
|
+
return value;
|
|
443
|
+
}
|
|
444
|
+
function expire(state, t) {
|
|
445
|
+
for (const index of Object.keys(state.records)) {
|
|
446
|
+
const r = record(state, index);
|
|
447
|
+
if (["COMPLETED", "BLOCKED_TERMINAL"].includes(r.state)) {
|
|
448
|
+
if (t >= r.keyExpiresAt)
|
|
449
|
+
delete state.records[index];
|
|
450
|
+
else if (t >= r.resultExpiresAt) {
|
|
451
|
+
delete r.payload;
|
|
452
|
+
r.state = "BLOCKED_TERMINAL";
|
|
453
|
+
seal(r);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
function owner(state, claim) {
|
|
459
|
+
if (!claims.has(claim))
|
|
460
|
+
throw new Error("claim");
|
|
461
|
+
const r = record(state, claim.index);
|
|
462
|
+
if (!r || r.ownerId !== claim.ownerId)
|
|
463
|
+
throw new Error("fence");
|
|
464
|
+
return r;
|
|
465
|
+
}
|
|
466
|
+
async function proof(r) {
|
|
467
|
+
if (!r.confirmation)
|
|
468
|
+
return !r.requiresConfirmation;
|
|
469
|
+
if (!confirmationProvider || !r.classification)
|
|
470
|
+
return false;
|
|
471
|
+
const result = ownData(await native(confirmationProvider.queryExecutionCompletion(r.confirmation, r.classification)), ["ok", "completed", "error"]);
|
|
472
|
+
if (result.ok === false)
|
|
473
|
+
throw new Error("confirmation");
|
|
474
|
+
if (result.ok !== true ||
|
|
475
|
+
typeof result.completed !== "boolean" ||
|
|
476
|
+
Object.hasOwn(result, "error"))
|
|
477
|
+
throw new Error("proof");
|
|
478
|
+
return result.completed;
|
|
479
|
+
}
|
|
480
|
+
async function promote(index) {
|
|
481
|
+
const snapshot = await atomic((state) => record(state, index)
|
|
482
|
+
? copyJson(record(state, index))
|
|
483
|
+
: undefined, undefined, true);
|
|
484
|
+
if (!snapshot ||
|
|
485
|
+
snapshot.state !== "COMPLETION_PENDING" ||
|
|
486
|
+
!snapshot.classification)
|
|
487
|
+
return;
|
|
488
|
+
if (snapshot.replayable)
|
|
489
|
+
await decode(snapshot);
|
|
490
|
+
if (!(await proof(snapshot)))
|
|
491
|
+
return;
|
|
492
|
+
await atomic((state, t) => {
|
|
493
|
+
const r = record(state, index);
|
|
494
|
+
// Another observer may have promoted or safely expired this snapshot.
|
|
495
|
+
// Never rewrite settled retention or touch a replacement owner.
|
|
496
|
+
if (!r || r.ownerId !== snapshot.ownerId)
|
|
497
|
+
return;
|
|
498
|
+
if (r.fingerprint !== snapshot.fingerprint ||
|
|
499
|
+
r.terminalDigest !== snapshot.terminalDigest ||
|
|
500
|
+
r.requiresConfirmation !== snapshot.requiresConfirmation ||
|
|
501
|
+
r.replayable !== snapshot.replayable ||
|
|
502
|
+
r.classification !== snapshot.classification ||
|
|
503
|
+
jcs(copyJson(r.confirmation ?? null)) !==
|
|
504
|
+
jcs(copyJson(snapshot.confirmation ?? null)))
|
|
505
|
+
throw new Error("fence");
|
|
506
|
+
if (r.state === "COMPLETED" || r.state === "BLOCKED_TERMINAL")
|
|
507
|
+
return;
|
|
508
|
+
if (r.state !== "COMPLETION_PENDING" || r.payload !== snapshot.payload)
|
|
509
|
+
throw new Error("fence");
|
|
510
|
+
// Retention begins after the authoritative confirmation completion gate.
|
|
511
|
+
t = time(state);
|
|
512
|
+
if (t + policy.keyRetentionMs > MAX_TIME)
|
|
513
|
+
throw new Error("overflow");
|
|
514
|
+
r.completedAt = t;
|
|
515
|
+
r.resultExpiresAt = t + policy.resultRetentionMs;
|
|
516
|
+
r.keyExpiresAt = t + policy.keyRetentionMs;
|
|
517
|
+
r.state = r.replayable ? "COMPLETED" : "BLOCKED_TERMINAL";
|
|
518
|
+
seal(r);
|
|
519
|
+
}, undefined, true);
|
|
520
|
+
}
|
|
521
|
+
async function inspect(value, controls) {
|
|
522
|
+
try {
|
|
523
|
+
const a = action(value);
|
|
524
|
+
controlsLive(controls);
|
|
525
|
+
await promote(a.index);
|
|
526
|
+
controlsLive(controls);
|
|
527
|
+
const r = await atomic((state, t) => {
|
|
528
|
+
expire(state, t);
|
|
529
|
+
const r = record(state, a.index);
|
|
530
|
+
if (!r)
|
|
531
|
+
return undefined;
|
|
532
|
+
if (r.fingerprint !== a.fingerprint)
|
|
533
|
+
throw new Rejection("CAP_IDEMPOTENCY_CONFLICT");
|
|
534
|
+
if (r.state === "AMBIGUOUS")
|
|
535
|
+
throw new Rejection("CAP_IDEMPOTENCY_AMBIGUOUS");
|
|
536
|
+
if (r.state === "BLOCKED_TERMINAL")
|
|
537
|
+
throw new Rejection("CAP_IDEMPOTENCY_RESULT_UNAVAILABLE");
|
|
538
|
+
if (r.state !== "COMPLETED")
|
|
539
|
+
throw new Rejection("CAP_IDEMPOTENCY_IN_PROGRESS");
|
|
540
|
+
if (!r.payload || r.replayable !== true)
|
|
541
|
+
throw new Error("completion");
|
|
542
|
+
return copyJson(r);
|
|
543
|
+
}, controls);
|
|
544
|
+
if (!r)
|
|
545
|
+
return { ok: true, outcome: "absent" };
|
|
546
|
+
const replayed = await decode(r);
|
|
547
|
+
controlsLive(controls);
|
|
548
|
+
return {
|
|
549
|
+
ok: true,
|
|
550
|
+
outcome: "replay",
|
|
551
|
+
terminal: replayed,
|
|
552
|
+
...(r.confirmation ? { confirmation: r.confirmation } : {}),
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
catch (e) {
|
|
556
|
+
return failure(e);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
async function persist(claim, value) {
|
|
560
|
+
const normalized = terminal(value);
|
|
561
|
+
const terminalDigest = hash("terminal", copyJson(normalized));
|
|
562
|
+
const completionOwner = (state) => {
|
|
563
|
+
if (!claims.has(claim))
|
|
564
|
+
throw new StaleCompletion();
|
|
565
|
+
const r = record(state, claim.index);
|
|
566
|
+
if (!r || r.ownerId !== claim.ownerId)
|
|
567
|
+
throw new StaleCompletion();
|
|
568
|
+
if (r.state !== "HANDLER_ENTRY_AUTHORIZED" &&
|
|
569
|
+
(!["COMPLETION_PENDING", "COMPLETED", "BLOCKED_TERMINAL"].includes(r.state) ||
|
|
570
|
+
r.terminalDigest !== terminalDigest))
|
|
571
|
+
throw new CompletionMismatch();
|
|
572
|
+
return r;
|
|
573
|
+
};
|
|
574
|
+
const replayable = normalized.kind === "success" ||
|
|
575
|
+
(normalized.kind === "declared_error"
|
|
576
|
+
? policy.cacheDeclaredErrors
|
|
577
|
+
: policy.cacheUnexpectedErrors);
|
|
578
|
+
const snapshot = await atomic((state) => {
|
|
579
|
+
const r = completionOwner(state);
|
|
580
|
+
return copyJson(r);
|
|
581
|
+
}, undefined, true);
|
|
582
|
+
if (snapshot.state !== "HANDLER_ENTRY_AUTHORIZED") {
|
|
583
|
+
// A durable commit can lose its acknowledgement. Equality is authenticated
|
|
584
|
+
// even without a cached payload; never replace an outcome or extend TTLs.
|
|
585
|
+
pending.delete(claim.ownerId);
|
|
586
|
+
await promote(claim.index);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const classification = normalized.kind === "success" ? "succeeded" : "failed";
|
|
590
|
+
const payload = replayable
|
|
591
|
+
? await native(codec.encrypt({
|
|
592
|
+
index: claim.index,
|
|
593
|
+
ownerId: claim.ownerId,
|
|
594
|
+
fingerprint: snapshot.fingerprint,
|
|
595
|
+
classification,
|
|
596
|
+
requiresConfirmation: snapshot.requiresConfirmation,
|
|
597
|
+
confirmation: copyJson(snapshot.confirmation ?? null),
|
|
598
|
+
terminal: copyJson(normalized),
|
|
599
|
+
}))
|
|
600
|
+
: undefined;
|
|
601
|
+
if (payload !== undefined &&
|
|
602
|
+
(typeof payload !== "string" ||
|
|
603
|
+
!payload ||
|
|
604
|
+
Buffer.byteLength(payload) > policy.maxPayloadBytes))
|
|
605
|
+
throw new Error("payload");
|
|
606
|
+
await atomic((state) => {
|
|
607
|
+
const r = completionOwner(state);
|
|
608
|
+
if (r.state !== "HANDLER_ENTRY_AUTHORIZED")
|
|
609
|
+
return;
|
|
610
|
+
r.state = "COMPLETION_PENDING";
|
|
611
|
+
r.classification = classification;
|
|
612
|
+
r.terminalDigest = terminalDigest;
|
|
613
|
+
r.replayable = replayable;
|
|
614
|
+
if (payload !== undefined)
|
|
615
|
+
r.payload = payload;
|
|
616
|
+
seal(r);
|
|
617
|
+
}, undefined, true);
|
|
618
|
+
pending.delete(claim.ownerId);
|
|
619
|
+
await promote(claim.index);
|
|
620
|
+
}
|
|
621
|
+
const provider = {
|
|
622
|
+
inspect,
|
|
623
|
+
async claim(value, controls) {
|
|
624
|
+
const inspected = await inspect(value, controls);
|
|
625
|
+
if (!inspected.ok || inspected.outcome === "replay")
|
|
626
|
+
return inspected;
|
|
627
|
+
try {
|
|
628
|
+
const a = action(value);
|
|
629
|
+
const result = await atomic((state, t) => {
|
|
630
|
+
expire(state, t);
|
|
631
|
+
const existing = record(state, a.index);
|
|
632
|
+
if (existing) {
|
|
633
|
+
if (existing.fingerprint !== a.fingerprint)
|
|
634
|
+
throw new Rejection("CAP_IDEMPOTENCY_CONFLICT");
|
|
635
|
+
throw new Rejection("CAP_IDEMPOTENCY_IN_PROGRESS");
|
|
636
|
+
}
|
|
637
|
+
if (Object.keys(state.records).length >= policy.maxRecords)
|
|
638
|
+
throw new Error("capacity");
|
|
639
|
+
if (Object.keys(state.ownerReservations).length >=
|
|
640
|
+
policy.maxOwnerReservations)
|
|
641
|
+
throw new Error("reservation_capacity");
|
|
642
|
+
const bytes = random(32);
|
|
643
|
+
if (types.isProxy(bytes) ||
|
|
644
|
+
!(bytes instanceof Uint8Array) ||
|
|
645
|
+
bytes.length !== 32 ||
|
|
646
|
+
!bytes.some((v) => v !== 0)) {
|
|
647
|
+
unhealthy = true;
|
|
648
|
+
throw new Error("random");
|
|
649
|
+
}
|
|
650
|
+
const ownerId = Buffer.from(bytes).toString("base64url");
|
|
651
|
+
if (Object.hasOwn(state.ownerReservations, ownerId)) {
|
|
652
|
+
unhealthy = true;
|
|
653
|
+
throw new Error("collision");
|
|
654
|
+
}
|
|
655
|
+
state.ownerReservations[ownerId] = true;
|
|
656
|
+
state.records[a.index] = {
|
|
657
|
+
...a,
|
|
658
|
+
ownerId,
|
|
659
|
+
requiresConfirmation: value.confirmation === "required",
|
|
660
|
+
state: "CLAIMED",
|
|
661
|
+
integrityTag: "",
|
|
662
|
+
};
|
|
663
|
+
seal(state.records[a.index]);
|
|
664
|
+
return Object.freeze({ index: a.index, ownerId });
|
|
665
|
+
}, controls);
|
|
666
|
+
claims.add(result);
|
|
667
|
+
return { ok: true, outcome: "claimed", claim: result };
|
|
668
|
+
}
|
|
669
|
+
catch (e) {
|
|
670
|
+
// A winner may have completed during the final atomic recheck.
|
|
671
|
+
if (e instanceof Rejection && e.code === "CAP_IDEMPOTENCY_IN_PROGRESS")
|
|
672
|
+
return (await inspect(value, controls));
|
|
673
|
+
return failure(e);
|
|
674
|
+
}
|
|
675
|
+
},
|
|
676
|
+
async enter(claim, confirmation, controls) {
|
|
677
|
+
try {
|
|
678
|
+
await atomic((state) => {
|
|
679
|
+
const r = owner(state, claim);
|
|
680
|
+
if (r.state !== "CLAIMED")
|
|
681
|
+
throw new Error("entry");
|
|
682
|
+
if (r.requiresConfirmation &&
|
|
683
|
+
(!confirmation || !confirmationProvider))
|
|
684
|
+
throw new Error("confirmation");
|
|
685
|
+
if (confirmation)
|
|
686
|
+
r.confirmation = linkage(confirmation);
|
|
687
|
+
r.state = "HANDLER_ENTRY_AUTHORIZED";
|
|
688
|
+
seal(r);
|
|
689
|
+
}, controls);
|
|
690
|
+
enteredClaims.add(claim);
|
|
691
|
+
return { ok: true };
|
|
692
|
+
}
|
|
693
|
+
catch (e) {
|
|
694
|
+
return failure(e);
|
|
695
|
+
}
|
|
696
|
+
},
|
|
697
|
+
async release(claim) {
|
|
698
|
+
try {
|
|
699
|
+
await atomic((state) => {
|
|
700
|
+
const r = owner(state, claim);
|
|
701
|
+
if (r.state !== "CLAIMED")
|
|
702
|
+
throw new Error("entry");
|
|
703
|
+
delete state.records[r.index];
|
|
704
|
+
}, undefined, true);
|
|
705
|
+
claims.delete(claim);
|
|
706
|
+
return { ok: true };
|
|
707
|
+
}
|
|
708
|
+
catch (e) {
|
|
709
|
+
return failure(e);
|
|
710
|
+
}
|
|
711
|
+
},
|
|
712
|
+
async complete(claim, value) {
|
|
713
|
+
try {
|
|
714
|
+
if (!claims.has(claim) || !enteredClaims.has(claim))
|
|
715
|
+
throw new Error("claim");
|
|
716
|
+
const normalized = terminal(value);
|
|
717
|
+
const existing = pending.get(claim.ownerId);
|
|
718
|
+
if (existing &&
|
|
719
|
+
hash("terminal", copyJson(existing.terminal)) !==
|
|
720
|
+
hash("terminal", copyJson(normalized)))
|
|
721
|
+
throw new Error("completion_mismatch");
|
|
722
|
+
if (!existing)
|
|
723
|
+
pending.set(claim.ownerId, { claim, terminal: normalized });
|
|
724
|
+
await persist(claim, pending.get(claim.ownerId).terminal);
|
|
725
|
+
return { ok: true };
|
|
726
|
+
}
|
|
727
|
+
catch (e) {
|
|
728
|
+
if (e instanceof StaleCompletion || e instanceof CompletionMismatch)
|
|
729
|
+
pending.delete(claim.ownerId);
|
|
730
|
+
return failure(e, true);
|
|
731
|
+
}
|
|
732
|
+
},
|
|
733
|
+
async reconcile(recovery = {}) {
|
|
734
|
+
try {
|
|
735
|
+
const controls = ownData(recovery, ["lostOwnerIds"]);
|
|
736
|
+
let lostOwnerIds = [];
|
|
737
|
+
if (controls.lostOwnerIds !== undefined) {
|
|
738
|
+
if (types.isProxy(controls.lostOwnerIds) ||
|
|
739
|
+
!Array.isArray(controls.lostOwnerIds) ||
|
|
740
|
+
Object.getPrototypeOf(controls.lostOwnerIds) !== Array.prototype)
|
|
741
|
+
throw new Error("lost_owners");
|
|
742
|
+
const length = Object.getOwnPropertyDescriptor(controls.lostOwnerIds, "length")?.value;
|
|
743
|
+
if (!Number.isSafeInteger(length) ||
|
|
744
|
+
length > policy.maxOwnerReservations)
|
|
745
|
+
throw new Error("lost_owners");
|
|
746
|
+
const values = copyJson(controls.lostOwnerIds);
|
|
747
|
+
for (const id of values)
|
|
748
|
+
if (typeof id !== "string" || !digestPattern.test(id))
|
|
749
|
+
throw new Error("lost_owners");
|
|
750
|
+
lostOwnerIds = values;
|
|
751
|
+
}
|
|
752
|
+
if (lostOwnerIds.length && !fenceLostOwner)
|
|
753
|
+
throw new Error("fencing");
|
|
754
|
+
for (const entry of pending.values()) {
|
|
755
|
+
try {
|
|
756
|
+
await persist(entry.claim, entry.terminal);
|
|
757
|
+
}
|
|
758
|
+
catch (error) {
|
|
759
|
+
if (error instanceof StaleCompletion ||
|
|
760
|
+
error instanceof CompletionMismatch)
|
|
761
|
+
pending.delete(entry.claim.ownerId);
|
|
762
|
+
else
|
|
763
|
+
throw error;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
if (lostOwnerIds.length) {
|
|
767
|
+
if (!fenceLostOwner)
|
|
768
|
+
throw new Error("fencing");
|
|
769
|
+
for (const id of lostOwnerIds) {
|
|
770
|
+
if (typeof id !== "string" ||
|
|
771
|
+
!digestPattern.test(id) ||
|
|
772
|
+
(await native(fenceLostOwner(id))) !== true)
|
|
773
|
+
throw new Error("fencing");
|
|
774
|
+
await atomic((state) => {
|
|
775
|
+
for (const index of Object.keys(state.records)) {
|
|
776
|
+
const r = record(state, index);
|
|
777
|
+
if (r.ownerId === id &&
|
|
778
|
+
["CLAIMED", "HANDLER_ENTRY_AUTHORIZED"].includes(r.state)) {
|
|
779
|
+
r.state = "AMBIGUOUS";
|
|
780
|
+
seal(r);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}, undefined, true);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
const indexes = await atomic((state) => Object.keys(state.records), undefined, true);
|
|
787
|
+
for (const index of indexes)
|
|
788
|
+
await promote(index);
|
|
789
|
+
return { ok: true };
|
|
790
|
+
}
|
|
791
|
+
catch (e) {
|
|
792
|
+
return failure(e, true);
|
|
793
|
+
}
|
|
794
|
+
},
|
|
795
|
+
};
|
|
796
|
+
return Object.freeze(provider);
|
|
797
|
+
}
|
|
798
|
+
//# sourceMappingURL=idempotency.js.map
|