@adcp/sdk 14.0.0-beta.17 → 14.0.0-beta.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
  2. package/dist/lib/server/decisioning/context.d.mts +4 -0
  3. package/dist/lib/server/decisioning/context.d.ts +4 -0
  4. package/dist/lib/server/decisioning/index.d.mts +1 -0
  5. package/dist/lib/server/decisioning/index.d.ts +1 -0
  6. package/dist/lib/server/decisioning/index.js +11 -0
  7. package/dist/lib/server/decisioning/index.mjs +12 -0
  8. package/dist/lib/server/decisioning/runtime/postgres-task-registry.js +5 -3
  9. package/dist/lib/server/decisioning/runtime/postgres-task-registry.mjs +5 -3
  10. package/dist/lib/server/decisioning/runtime/postgres-task-settlement-intents.d.mts +136 -0
  11. package/dist/lib/server/decisioning/runtime/postgres-task-settlement-intents.d.ts +136 -0
  12. package/dist/lib/server/decisioning/runtime/postgres-task-settlement-intents.js +745 -0
  13. package/dist/lib/server/decisioning/runtime/postgres-task-settlement-intents.mjs +717 -0
  14. package/dist/lib/server/decisioning/runtime/postgres-task-settlement.js +108 -36
  15. package/dist/lib/server/decisioning/runtime/postgres-task-settlement.mjs +108 -36
  16. package/dist/lib/server/decisioning/runtime/to-context.js +38 -9
  17. package/dist/lib/server/decisioning/runtime/to-context.mjs +38 -9
  18. package/dist/lib/testing/storyboard/validations.d.mts +1 -1
  19. package/dist/lib/testing/storyboard/validations.d.ts +1 -1
  20. package/dist/lib/types/schemas.generated.js +0 -63
  21. package/dist/lib/types/schemas.generated.mjs +0 -63
  22. package/dist/lib/utils/well-formed-unicode.d.mts +2 -0
  23. package/dist/lib/utils/well-formed-unicode.d.ts +2 -0
  24. package/dist/lib/utils/well-formed-unicode.js +51 -0
  25. package/dist/lib/utils/well-formed-unicode.mjs +27 -0
  26. package/dist/lib/version.d.mts +3 -3
  27. package/dist/lib/version.d.ts +3 -3
  28. package/dist/lib/version.js +3 -3
  29. package/dist/lib/version.mjs +3 -3
  30. package/docs/guides/BUILD-AN-AGENT.md +7 -0
  31. package/docs/guides/DURABLE-TASK-SETTLEMENT.md +468 -0
  32. package/docs/llms.txt +5 -2
  33. package/docs/migration-13-to-14.md +1 -1
  34. package/docs/migration-task-registry-scoping.md +45 -14
  35. package/package.json +2 -1
@@ -0,0 +1,717 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { canonicalJsonSha256 } from "../../../utils/jcs.mjs";
3
+ import { assertWellFormedUnicode } from "../../../utils/well-formed-unicode.mjs";
4
+ import { sanitizeStructuredAdcpError } from "../../errors.mjs";
5
+ import { sanitizeTaskResultForWire } from "./task-registry.mjs";
6
+ const DEFAULT_TABLE = "adcp_task_settlement_intents";
7
+ const VALID_IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
8
+ const VALID_NAMESPACE = /^[A-Za-z0-9_.:-]{1,255}$/;
9
+ const MAX_PAYLOAD_BYTES = 4 * 1024 * 1024;
10
+ const TASK_SETTLEMENT_INTENT_IDEMPOTENCY_HORIZON_MS = 7 * 24 * 60 * 60 * 1e3;
11
+ const DEFAULT_RECOVERY = {
12
+ batchSize: 25,
13
+ leaseMs: 45e3,
14
+ retryAfterMs: 3e4,
15
+ maxRetryAfterMs: 15 * 6e4,
16
+ maxAttempts: 12
17
+ };
18
+ class TaskSettlementIntentConflictError extends Error {
19
+ name = "TaskSettlementIntentConflictError";
20
+ }
21
+ function getTaskSettlementIntentMigration(options = {}) {
22
+ const table = options.tableName ?? DEFAULT_TABLE;
23
+ assertValidTableName(table);
24
+ return `
25
+ CREATE TABLE IF NOT EXISTS ${table} (
26
+ queue_namespace TEXT NOT NULL,
27
+ registry_id TEXT NOT NULL,
28
+ account_id TEXT NOT NULL,
29
+ owner_scope TEXT NOT NULL,
30
+ task_id TEXT NOT NULL,
31
+ scope_fingerprint TEXT NOT NULL,
32
+ action TEXT NOT NULL,
33
+ payload JSONB NOT NULL,
34
+ intent_fingerprint TEXT NOT NULL,
35
+ state TEXT NOT NULL DEFAULT 'pending',
36
+ attempt_count INTEGER NOT NULL DEFAULT 0,
37
+ next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
38
+ lease_owner TEXT,
39
+ lease_claim_id TEXT,
40
+ lease_version BIGINT NOT NULL DEFAULT 0,
41
+ lease_expires_at TIMESTAMPTZ,
42
+ last_error TEXT,
43
+ retain_until TIMESTAMPTZ,
44
+ created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
45
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
46
+ PRIMARY KEY (queue_namespace, scope_fingerprint),
47
+ CONSTRAINT ${table}_valid_scope_fingerprint
48
+ CHECK (scope_fingerprint ~ '^[a-f0-9]{64}$'),
49
+ CONSTRAINT ${table}_valid_action CHECK (action IN ('complete', 'fail')),
50
+ CONSTRAINT ${table}_valid_state CHECK (state IN ('pending', 'dead_letter', 'acknowledged')),
51
+ CONSTRAINT ${table}_valid_retention CHECK (
52
+ (state = 'acknowledged' AND retain_until IS NOT NULL) OR
53
+ (state <> 'acknowledged' AND retain_until IS NULL)
54
+ ),
55
+ CONSTRAINT ${table}_valid_payload CHECK (jsonb_typeof(payload) = 'object')
56
+ );
57
+
58
+ -- Upgrade queues provisioned by an earlier SDK beta before creating indexes
59
+ -- or writing the acknowledged state. These operations are deliberately
60
+ -- idempotent so operators can rerun the generated migration safely.
61
+ ALTER TABLE ${table}
62
+ ADD COLUMN IF NOT EXISTS retain_until TIMESTAMPTZ;
63
+
64
+ ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS ${table}_valid_state;
65
+ ALTER TABLE ${table}
66
+ ADD CONSTRAINT ${table}_valid_state CHECK (state IN ('pending', 'dead_letter', 'acknowledged'));
67
+
68
+ ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS ${table}_valid_retention;
69
+ ALTER TABLE ${table}
70
+ ADD CONSTRAINT ${table}_valid_retention CHECK (
71
+ (state = 'acknowledged' AND retain_until IS NOT NULL) OR
72
+ (state <> 'acknowledged' AND retain_until IS NULL)
73
+ );
74
+
75
+ CREATE INDEX IF NOT EXISTS idx_${table}_due
76
+ ON ${table}(queue_namespace, next_attempt_at, lease_expires_at)
77
+ WHERE state = 'pending';
78
+
79
+ CREATE INDEX IF NOT EXISTS idx_${table}_acknowledged_retention
80
+ ON ${table}(queue_namespace, retain_until)
81
+ WHERE state = 'acknowledged';
82
+ `.trim();
83
+ }
84
+ function createPostgresTaskSettlementIntentQueue(options) {
85
+ if (!options?.db || typeof options.db.query !== "function") {
86
+ throw new TypeError("createPostgresTaskSettlementIntentQueue requires a PostgreSQL queryable");
87
+ }
88
+ const db = options.db;
89
+ const namespace = options.namespace;
90
+ assertValidNamespace(namespace);
91
+ const table = options.tableName ?? DEFAULT_TABLE;
92
+ assertValidTableName(table);
93
+ const idempotencyHorizonMs = options.idempotencyHorizonMs ?? TASK_SETTLEMENT_INTENT_IDEMPOTENCY_HORIZON_MS;
94
+ if (!Number.isSafeInteger(idempotencyHorizonMs) || idempotencyHorizonMs <= 0) {
95
+ throw new TypeError("idempotencyHorizonMs must be a positive safe integer");
96
+ }
97
+ const queue = {
98
+ durability: "durable",
99
+ async enqueue(intent, writeOptions = {}) {
100
+ const normalized = canonicalizeTaskSettlementIntent(intent);
101
+ const fingerprint = canonicalJsonSha256(normalized);
102
+ const payload = payloadForIntent(normalized);
103
+ assertPayloadSize(payload);
104
+ const ref = normalized.taskRef;
105
+ const scopeFingerprint = taskRefFingerprint(ref);
106
+ const writeDb = writeOptions.db ?? db;
107
+ const result = await queryDb(
108
+ writeDb,
109
+ "enqueue",
110
+ `INSERT INTO ${table} (
111
+ queue_namespace, registry_id, account_id, owner_scope, task_id,
112
+ action, payload, intent_fingerprint, scope_fingerprint
113
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9)
114
+ ON CONFLICT (queue_namespace, scope_fingerprint)
115
+ DO UPDATE SET
116
+ registry_id = EXCLUDED.registry_id,
117
+ account_id = EXCLUDED.account_id,
118
+ owner_scope = EXCLUDED.owner_scope,
119
+ task_id = EXCLUDED.task_id,
120
+ action = EXCLUDED.action,
121
+ payload = CASE
122
+ WHEN ${table}.state = 'acknowledged' AND ${table}.retain_until <= statement_timestamp()
123
+ THEN EXCLUDED.payload
124
+ ELSE ${table}.payload
125
+ END,
126
+ intent_fingerprint = EXCLUDED.intent_fingerprint,
127
+ state = CASE
128
+ WHEN ${table}.state = 'acknowledged' AND ${table}.retain_until <= statement_timestamp()
129
+ THEN 'pending'
130
+ ELSE ${table}.state
131
+ END,
132
+ attempt_count = CASE
133
+ WHEN ${table}.state = 'acknowledged' AND ${table}.retain_until <= statement_timestamp()
134
+ THEN 0
135
+ ELSE ${table}.attempt_count
136
+ END,
137
+ next_attempt_at = CASE
138
+ WHEN ${table}.state = 'acknowledged' AND ${table}.retain_until <= statement_timestamp()
139
+ THEN statement_timestamp()
140
+ ELSE ${table}.next_attempt_at
141
+ END,
142
+ lease_owner = CASE
143
+ WHEN ${table}.state = 'acknowledged' AND ${table}.retain_until <= statement_timestamp()
144
+ THEN NULL
145
+ ELSE ${table}.lease_owner
146
+ END,
147
+ lease_claim_id = CASE
148
+ WHEN ${table}.state = 'acknowledged' AND ${table}.retain_until <= statement_timestamp()
149
+ THEN NULL
150
+ ELSE ${table}.lease_claim_id
151
+ END,
152
+ lease_expires_at = CASE
153
+ WHEN ${table}.state = 'acknowledged' AND ${table}.retain_until <= statement_timestamp()
154
+ THEN NULL
155
+ ELSE ${table}.lease_expires_at
156
+ END,
157
+ last_error = CASE
158
+ WHEN ${table}.state = 'acknowledged' AND ${table}.retain_until <= statement_timestamp()
159
+ THEN NULL
160
+ ELSE ${table}.last_error
161
+ END,
162
+ retain_until = CASE
163
+ WHEN ${table}.state = 'acknowledged' AND ${table}.retain_until <= statement_timestamp()
164
+ THEN NULL
165
+ ELSE ${table}.retain_until
166
+ END,
167
+ created_at = CASE
168
+ WHEN ${table}.state = 'acknowledged' AND ${table}.retain_until <= statement_timestamp()
169
+ THEN statement_timestamp()
170
+ ELSE ${table}.created_at
171
+ END,
172
+ updated_at = statement_timestamp()
173
+ WHERE (
174
+ ${table}.registry_id = EXCLUDED.registry_id
175
+ AND ${table}.account_id = EXCLUDED.account_id
176
+ AND ${table}.owner_scope = EXCLUDED.owner_scope
177
+ AND ${table}.task_id = EXCLUDED.task_id
178
+ AND ${table}.action = EXCLUDED.action
179
+ AND ${table}.intent_fingerprint = EXCLUDED.intent_fingerprint
180
+ ) OR (
181
+ ${table}.state = 'acknowledged'
182
+ AND ${table}.retain_until <= statement_timestamp()
183
+ )
184
+ RETURNING task_id`,
185
+ [
186
+ namespace,
187
+ ref.registryId,
188
+ ref.accountId,
189
+ ref.ownerScope,
190
+ ref.taskId,
191
+ normalized.action,
192
+ JSON.stringify(payload),
193
+ fingerprint,
194
+ scopeFingerprint
195
+ ]
196
+ );
197
+ if (result.rowCount !== 1) {
198
+ throw new TaskSettlementIntentConflictError("Task is already bound to a different settlement intent");
199
+ }
200
+ return checkpointFor(namespace, ref, fingerprint);
201
+ },
202
+ async acknowledge(checkpoint, writeOptions = {}) {
203
+ validateCheckpoint(checkpoint, namespace);
204
+ const writeDb = writeOptions.db ?? db;
205
+ const result = await queryDb(
206
+ writeDb,
207
+ "acknowledge",
208
+ `UPDATE ${table}
209
+ SET state = 'acknowledged',
210
+ payload = '{}'::jsonb,
211
+ retain_until = clock_timestamp() + ($8::bigint * INTERVAL '1 millisecond'),
212
+ lease_owner = NULL, lease_claim_id = NULL, lease_expires_at = NULL,
213
+ last_error = NULL, updated_at = clock_timestamp()
214
+ WHERE queue_namespace = $1 AND registry_id = $2 AND account_id = $3
215
+ AND owner_scope = $4 AND task_id = $5 AND intent_fingerprint = $6
216
+ AND scope_fingerprint = $7 AND state IN ('pending', 'dead_letter')`,
217
+ [...checkpointValues(checkpoint), idempotencyHorizonMs]
218
+ );
219
+ return result.rowCount === 1;
220
+ },
221
+ async pruneAcknowledged(pruneOptions = {}) {
222
+ const limit = normalizePruneLimit(pruneOptions.limit);
223
+ return pruneExpiredAcknowledgements(pruneOptions.db ?? db, table, namespace, limit);
224
+ },
225
+ async recover(recoveryOptions) {
226
+ const config = normalizeRecoveryOptions(recoveryOptions);
227
+ await pruneExpiredAcknowledgements(db, table, namespace, config.batchSize);
228
+ const metrics = {
229
+ claimed: 0,
230
+ settled: 0,
231
+ retried: 0,
232
+ deadLettered: 0,
233
+ leaseLost: 0
234
+ };
235
+ const seenFingerprints = [];
236
+ while (metrics.claimed < config.batchSize) {
237
+ const selected = await claimOneDue(db, table, namespace, config, seenFingerprints);
238
+ if (!selected) break;
239
+ metrics.claimed += 1;
240
+ seenFingerprints.push(selected.intentFingerprint);
241
+ if (selected.kind === "dead_letter") {
242
+ metrics.deadLettered += 1;
243
+ await reportRecoveryError(
244
+ recoveryOptions.onError,
245
+ new Error("Task settlement intent exhausted maxAttempts after its lease expired"),
246
+ selected,
247
+ "dead_letter"
248
+ );
249
+ continue;
250
+ }
251
+ const claim = selected;
252
+ let intent;
253
+ try {
254
+ const payload = await loadClaimPayload(db, table, namespace, claim);
255
+ intent = intentFromClaim(claim, payload);
256
+ const outcome = await recoveryOptions.settle(intent, {
257
+ attemptCount: claim.attemptCount,
258
+ extendLease: () => extendClaimLease(db, table, namespace, claim, config.leaseMs)
259
+ });
260
+ if (outcome !== "settled") {
261
+ throw new TypeError("Task settlement callback must resolve with the literal `settled`");
262
+ }
263
+ if (await acknowledgeClaim(db, table, namespace, claim, idempotencyHorizonMs)) {
264
+ metrics.settled += 1;
265
+ } else {
266
+ metrics.leaseLost += 1;
267
+ await reportRecoveryError(
268
+ recoveryOptions.onError,
269
+ new Error("Task settlement intent lease was lost after settlement"),
270
+ claim,
271
+ "lease_lost"
272
+ );
273
+ }
274
+ } catch (error) {
275
+ const disposition = await releaseClaim(db, table, namespace, claim, config, error);
276
+ if (disposition === "retry") metrics.retried += 1;
277
+ else if (disposition === "dead_letter") metrics.deadLettered += 1;
278
+ else metrics.leaseLost += 1;
279
+ await reportRecoveryError(recoveryOptions.onError, error, claim, disposition);
280
+ }
281
+ }
282
+ return metrics;
283
+ },
284
+ async probe() {
285
+ await queryDb(
286
+ db,
287
+ "probe",
288
+ `SELECT queue_namespace, registry_id, account_id, owner_scope, task_id, scope_fingerprint,
289
+ action, payload, intent_fingerprint, state, attempt_count,
290
+ next_attempt_at, lease_owner, lease_claim_id, lease_version,
291
+ lease_expires_at, last_error, retain_until, created_at, updated_at
292
+ FROM ${table}
293
+ WHERE queue_namespace = $1
294
+ LIMIT 0`,
295
+ [namespace]
296
+ );
297
+ }
298
+ };
299
+ return queue;
300
+ }
301
+ function canonicalizeTaskSettlementIntent(intent) {
302
+ if (!intent || typeof intent !== "object") throw new TypeError("Task settlement intent is required");
303
+ const taskRef = requireDurableTaskRef(intent.taskRef);
304
+ if (intent.action === "complete") {
305
+ if (intent.result === void 0) throw new TypeError("A complete task settlement intent requires a result");
306
+ const clonedResult = structuredClone(intent.result);
307
+ assertWellFormedUnicode(clonedResult, "Task settlement intent");
308
+ const result = sanitizeTaskResultForWire(clonedResult, taskRef);
309
+ assertWellFormedUnicode(result, "Task settlement intent");
310
+ canonicalJsonSha256(result);
311
+ return { taskRef, action: "complete", result };
312
+ }
313
+ if (intent.action === "fail") {
314
+ const clonedError = structuredClone(intent.error);
315
+ assertWellFormedUnicode(clonedError, "Task settlement intent");
316
+ requireStructuredError(clonedError);
317
+ const error = sanitizeStructuredAdcpError(clonedError);
318
+ requireStructuredError(error);
319
+ const clonedResult = intent.result === void 0 ? void 0 : structuredClone(intent.result);
320
+ assertWellFormedUnicode(clonedResult, "Task settlement intent");
321
+ const result = clonedResult === void 0 ? void 0 : sanitizeTaskResultForWire(clonedResult, taskRef);
322
+ assertWellFormedUnicode({ error, ...result !== void 0 && { result } }, "Task settlement intent");
323
+ canonicalJsonSha256({ error, result });
324
+ return { taskRef, action: "fail", error, ...result !== void 0 && { result } };
325
+ }
326
+ throw new TypeError("Task settlement intent action must be `complete` or `fail`");
327
+ }
328
+ function payloadForIntent(intent) {
329
+ return intent.action === "complete" ? { result: intent.result } : { error: intent.error, ...intent.result !== void 0 && { result: intent.result } };
330
+ }
331
+ function requireDurableTaskRef(ref) {
332
+ if (!ref || typeof ref !== "object") throw new TypeError("Task settlement intent requires a ScopedTaskRef");
333
+ return {
334
+ taskId: requireTaskRefPart(ref.taskId, "taskId"),
335
+ accountId: requireTaskRefPart(ref.accountId, "accountId"),
336
+ ownerScope: requireTaskRefPart(ref.ownerScope, "ownerScope"),
337
+ registryId: requireTaskRefPart(ref.registryId, "registryId")
338
+ };
339
+ }
340
+ function requireTaskRefPart(value, field) {
341
+ if (typeof value !== "string" || value.length === 0) {
342
+ throw new TypeError(`Task settlement intent ${field} must be a non-empty string`);
343
+ }
344
+ if (Buffer.from(value, "utf8").toString("utf8") !== value) {
345
+ throw new TypeError(`Task settlement intent ${field} must contain well-formed Unicode`);
346
+ }
347
+ return value;
348
+ }
349
+ function requireStructuredError(value) {
350
+ const error = plainObject(value, "error");
351
+ for (const field of ["code", "message"]) {
352
+ if (typeof error[field] !== "string" || error[field].length === 0) {
353
+ throw new TypeError(`Task settlement intent error.${field} must be a non-empty string`);
354
+ }
355
+ }
356
+ if (error.recovery !== "transient" && error.recovery !== "correctable" && error.recovery !== "terminal") {
357
+ throw new TypeError("Task settlement intent error.recovery must be `transient`, `correctable`, or `terminal`");
358
+ }
359
+ return value;
360
+ }
361
+ function checkpointFor(queueNamespace, ref, intentFingerprint) {
362
+ return { ...ref, queueNamespace, intentFingerprint };
363
+ }
364
+ function validateCheckpoint(checkpoint, namespace) {
365
+ requireDurableTaskRef(checkpoint);
366
+ if (checkpoint.queueNamespace !== namespace) {
367
+ throw new TypeError("Task settlement checkpoint belongs to a different queue namespace");
368
+ }
369
+ if (!/^[a-f0-9]{64}$/.test(checkpoint.intentFingerprint)) {
370
+ throw new TypeError("Task settlement checkpoint has an invalid intent fingerprint");
371
+ }
372
+ }
373
+ function checkpointValues(checkpoint) {
374
+ return [
375
+ checkpoint.queueNamespace,
376
+ checkpoint.registryId,
377
+ checkpoint.accountId,
378
+ checkpoint.ownerScope,
379
+ checkpoint.taskId,
380
+ checkpoint.intentFingerprint,
381
+ taskRefFingerprint(checkpoint)
382
+ ];
383
+ }
384
+ function normalizeRecoveryOptions(options) {
385
+ if (!options || typeof options.settle !== "function") {
386
+ throw new TypeError("recover requires an idempotent settle callback");
387
+ }
388
+ const config = {
389
+ batchSize: options.batchSize ?? DEFAULT_RECOVERY.batchSize,
390
+ leaseMs: options.leaseMs ?? DEFAULT_RECOVERY.leaseMs,
391
+ retryAfterMs: options.retryAfterMs ?? DEFAULT_RECOVERY.retryAfterMs,
392
+ maxRetryAfterMs: options.maxRetryAfterMs ?? DEFAULT_RECOVERY.maxRetryAfterMs,
393
+ maxAttempts: options.maxAttempts ?? DEFAULT_RECOVERY.maxAttempts,
394
+ workerId: options.workerId ?? `task-settlement:${process.pid}:${randomUUID()}`
395
+ };
396
+ for (const field of ["batchSize", "leaseMs", "maxRetryAfterMs", "maxAttempts"]) {
397
+ if (!Number.isInteger(config[field]) || config[field] <= 0) {
398
+ throw new TypeError(`${field} must be a positive integer`);
399
+ }
400
+ }
401
+ if (!Number.isInteger(config.retryAfterMs) || config.retryAfterMs < 0) {
402
+ throw new TypeError("retryAfterMs must be a non-negative integer");
403
+ }
404
+ if (config.batchSize > 1e3) throw new TypeError("batchSize must not exceed 1000");
405
+ for (const field of ["leaseMs", "retryAfterMs", "maxRetryAfterMs", "maxAttempts"]) {
406
+ if (config[field] > 2147483647) throw new TypeError(`${field} must not exceed 2147483647`);
407
+ }
408
+ if (typeof config.workerId !== "string" || config.workerId.length === 0 || Buffer.byteLength(config.workerId, "utf8") > 1024) {
409
+ throw new TypeError("workerId must be a non-empty string of at most 1024 bytes");
410
+ }
411
+ return config;
412
+ }
413
+ async function claimOneDue(db, table, namespace, config, seenFingerprints) {
414
+ const claimId = randomUUID();
415
+ const result = await queryDb(
416
+ db,
417
+ "claim",
418
+ `WITH due AS (
419
+ SELECT candidate.queue_namespace, candidate.registry_id, candidate.account_id,
420
+ candidate.owner_scope, candidate.task_id, candidate.scope_fingerprint
421
+ FROM ${table} AS candidate
422
+ WHERE candidate.queue_namespace = $1 AND candidate.state = 'pending'
423
+ AND next_attempt_at <= clock_timestamp()
424
+ AND (lease_expires_at IS NULL OR lease_expires_at <= clock_timestamp())
425
+ AND candidate.intent_fingerprint <> ALL($5::text[])
426
+ ORDER BY next_attempt_at, created_at
427
+ LIMIT 1
428
+ FOR UPDATE SKIP LOCKED
429
+ )
430
+ UPDATE ${table} AS intents
431
+ SET state = CASE WHEN intents.attempt_count >= $6 THEN 'dead_letter' ELSE intents.state END,
432
+ lease_owner = CASE WHEN intents.attempt_count >= $6 THEN NULL ELSE $2 END,
433
+ lease_claim_id = CASE WHEN intents.attempt_count >= $6 THEN NULL ELSE $3 END,
434
+ lease_version = CASE WHEN intents.attempt_count >= $6
435
+ THEN intents.lease_version ELSE intents.lease_version + 1 END,
436
+ lease_expires_at = CASE WHEN intents.attempt_count >= $6 THEN NULL
437
+ ELSE clock_timestamp() + ($4::integer * INTERVAL '1 millisecond') END,
438
+ attempt_count = CASE WHEN intents.attempt_count >= $6
439
+ THEN intents.attempt_count ELSE intents.attempt_count + 1 END,
440
+ last_error = CASE WHEN intents.attempt_count >= $6
441
+ THEN COALESCE(intents.last_error, 'Error') ELSE intents.last_error END,
442
+ updated_at = clock_timestamp()
443
+ FROM due
444
+ WHERE intents.queue_namespace = due.queue_namespace
445
+ AND intents.scope_fingerprint = due.scope_fingerprint
446
+ AND intents.registry_id = due.registry_id
447
+ AND intents.account_id = due.account_id
448
+ AND intents.owner_scope = due.owner_scope
449
+ AND intents.task_id = due.task_id
450
+ RETURNING intents.registry_id, intents.account_id, intents.owner_scope,
451
+ intents.task_id, intents.scope_fingerprint, intents.action,
452
+ intents.intent_fingerprint, intents.attempt_count, intents.state,
453
+ intents.lease_claim_id, intents.lease_version::text`,
454
+ [namespace, config.workerId, claimId, config.leaseMs, seenFingerprints, config.maxAttempts]
455
+ );
456
+ const row = result.rows[0];
457
+ if (!row) return void 0;
458
+ const taskRef = {
459
+ registryId: requireRowString(row.registry_id, "registry_id"),
460
+ accountId: requireRowString(row.account_id, "account_id"),
461
+ ownerScope: requireRowString(row.owner_scope, "owner_scope"),
462
+ taskId: requireRowString(row.task_id, "task_id")
463
+ };
464
+ const attempt = {
465
+ taskRef,
466
+ action: row.action,
467
+ intentFingerprint: requireRowString(row.intent_fingerprint, "intent_fingerprint"),
468
+ attemptCount: requireRowInteger(row.attempt_count, "attempt_count")
469
+ };
470
+ if (row.state === "dead_letter") return { ...attempt, kind: "dead_letter" };
471
+ return {
472
+ ...attempt,
473
+ kind: "claimed",
474
+ scopeFingerprint: requireFingerprint(row.scope_fingerprint, "scope_fingerprint"),
475
+ leaseClaimId: requireRowString(row.lease_claim_id, "lease_claim_id"),
476
+ leaseVersion: requireRowString(row.lease_version, "lease_version")
477
+ };
478
+ }
479
+ async function loadClaimPayload(db, table, namespace, claim) {
480
+ const result = await queryDb(
481
+ db,
482
+ "loadClaimPayload",
483
+ `SELECT payload FROM ${table}
484
+ WHERE queue_namespace = $1 AND registry_id = $2 AND account_id = $3
485
+ AND owner_scope = $4 AND task_id = $5 AND intent_fingerprint = $6
486
+ AND state = 'pending' AND lease_claim_id = $7 AND lease_version = $8::bigint
487
+ AND scope_fingerprint = $9 AND lease_expires_at > clock_timestamp()`,
488
+ claimValues(namespace, claim)
489
+ );
490
+ if (result.rowCount !== 1) throw new Error("Task settlement intent lease was lost before payload read");
491
+ return result.rows[0]?.payload;
492
+ }
493
+ function intentFromClaim(claim, storedPayload) {
494
+ if (taskRefFingerprint(claim.taskRef) !== claim.scopeFingerprint) {
495
+ throw new TypeError("Stored task settlement scope does not match its immutable fingerprint");
496
+ }
497
+ const payload = plainObject(storedPayload, "payload");
498
+ let storedIntent;
499
+ if (claim.action === "complete") {
500
+ if (!Object.hasOwn(payload, "result")) throw new TypeError("Stored complete intent has no result");
501
+ storedIntent = { taskRef: { ...claim.taskRef }, action: "complete", result: payload.result };
502
+ } else if (claim.action === "fail") {
503
+ const error2 = payload.error;
504
+ storedIntent = {
505
+ taskRef: { ...claim.taskRef },
506
+ action: "fail",
507
+ error: error2,
508
+ ...Object.hasOwn(payload, "result") && { result: payload.result }
509
+ };
510
+ } else {
511
+ throw new TypeError("Stored task settlement intent has an invalid action");
512
+ }
513
+ if (canonicalJsonSha256(storedIntent) !== claim.intentFingerprint) {
514
+ throw new TypeError("Stored task settlement intent does not match its immutable fingerprint");
515
+ }
516
+ assertWellFormedUnicode(storedIntent, "Task settlement intent");
517
+ if (storedIntent.action === "complete") {
518
+ return {
519
+ ...storedIntent,
520
+ result: sanitizeTaskResultForWire(structuredClone(storedIntent.result), storedIntent.taskRef)
521
+ };
522
+ }
523
+ const clonedError = structuredClone(storedIntent.error);
524
+ requireStructuredError(clonedError);
525
+ const error = sanitizeStructuredAdcpError(clonedError);
526
+ requireStructuredError(error);
527
+ const result = storedIntent.result === void 0 ? void 0 : sanitizeTaskResultForWire(structuredClone(storedIntent.result), storedIntent.taskRef);
528
+ return { ...storedIntent, error, ...result !== void 0 ? { result } : {} };
529
+ }
530
+ async function acknowledgeClaim(db, table, namespace, claim, idempotencyHorizonMs) {
531
+ const result = await queryDb(
532
+ db,
533
+ "acknowledgeClaim",
534
+ `UPDATE ${table}
535
+ SET state = 'acknowledged',
536
+ payload = '{}'::jsonb,
537
+ retain_until = clock_timestamp() + ($10::bigint * INTERVAL '1 millisecond'),
538
+ lease_owner = NULL, lease_claim_id = NULL, lease_expires_at = NULL,
539
+ last_error = NULL, updated_at = clock_timestamp()
540
+ WHERE queue_namespace = $1 AND registry_id = $2 AND account_id = $3
541
+ AND owner_scope = $4 AND task_id = $5 AND intent_fingerprint = $6
542
+ AND state = 'pending' AND lease_claim_id = $7 AND lease_version = $8::bigint
543
+ AND scope_fingerprint = $9 AND lease_expires_at > clock_timestamp()`,
544
+ [...claimValues(namespace, claim), idempotencyHorizonMs]
545
+ );
546
+ return result.rowCount === 1;
547
+ }
548
+ async function pruneExpiredAcknowledgements(db, table, namespace, limit) {
549
+ const result = await queryDb(
550
+ db,
551
+ "pruneExpiredAcknowledgements",
552
+ `WITH expired AS (
553
+ SELECT candidate.queue_namespace, candidate.scope_fingerprint
554
+ FROM ${table} AS candidate
555
+ WHERE candidate.queue_namespace = $1
556
+ AND candidate.state = 'acknowledged'
557
+ AND candidate.retain_until <= clock_timestamp()
558
+ ORDER BY candidate.retain_until
559
+ LIMIT $2
560
+ FOR UPDATE SKIP LOCKED
561
+ )
562
+ DELETE FROM ${table} AS intents
563
+ USING expired
564
+ WHERE intents.queue_namespace = expired.queue_namespace
565
+ AND intents.scope_fingerprint = expired.scope_fingerprint`,
566
+ [namespace, limit]
567
+ );
568
+ return result.rowCount ?? 0;
569
+ }
570
+ function normalizePruneLimit(value) {
571
+ const limit = value ?? 1e3;
572
+ if (!Number.isInteger(limit) || limit <= 0 || limit > 1e4) {
573
+ throw new TypeError("pruneAcknowledged limit must be a positive integer no greater than 10000");
574
+ }
575
+ return limit;
576
+ }
577
+ async function extendClaimLease(db, table, namespace, claim, leaseMs) {
578
+ const result = await queryDb(
579
+ db,
580
+ "extendLease",
581
+ `UPDATE ${table}
582
+ SET lease_expires_at = clock_timestamp() + ($10::integer * INTERVAL '1 millisecond'),
583
+ updated_at = clock_timestamp()
584
+ WHERE queue_namespace = $1 AND registry_id = $2 AND account_id = $3
585
+ AND owner_scope = $4 AND task_id = $5 AND intent_fingerprint = $6
586
+ AND state = 'pending' AND lease_claim_id = $7 AND lease_version = $8::bigint
587
+ AND scope_fingerprint = $9 AND lease_expires_at > clock_timestamp()`,
588
+ [...claimValues(namespace, claim), leaseMs]
589
+ );
590
+ return result.rowCount === 1;
591
+ }
592
+ async function releaseClaim(db, table, namespace, claim, config, error) {
593
+ const deadLetter = claim.attemptCount >= config.maxAttempts;
594
+ const multiplier = 2 ** Math.min(Math.max(claim.attemptCount - 1, 0), 8);
595
+ const retryAfterMs = Math.min(config.retryAfterMs * multiplier, config.maxRetryAfterMs);
596
+ const errorName = safeErrorName(error);
597
+ const result = await queryDb(
598
+ db,
599
+ "releaseClaim",
600
+ `UPDATE ${table}
601
+ SET state = $10,
602
+ next_attempt_at = CASE WHEN $10 = 'pending'
603
+ THEN clock_timestamp() + ($11::integer * INTERVAL '1 millisecond')
604
+ ELSE next_attempt_at END,
605
+ lease_owner = NULL, lease_claim_id = NULL, lease_expires_at = NULL,
606
+ last_error = $12, updated_at = clock_timestamp()
607
+ WHERE queue_namespace = $1 AND registry_id = $2 AND account_id = $3
608
+ AND owner_scope = $4 AND task_id = $5 AND intent_fingerprint = $6
609
+ AND state = 'pending' AND lease_claim_id = $7 AND lease_version = $8::bigint
610
+ AND scope_fingerprint = $9 AND lease_expires_at > clock_timestamp()`,
611
+ [...claimValues(namespace, claim), deadLetter ? "dead_letter" : "pending", retryAfterMs, errorName]
612
+ );
613
+ if (result.rowCount !== 1) return "lease_lost";
614
+ return deadLetter ? "dead_letter" : "retry";
615
+ }
616
+ function safeErrorName(error) {
617
+ try {
618
+ if (error instanceof EvalError) return "EvalError";
619
+ if (error instanceof RangeError) return "RangeError";
620
+ if (error instanceof ReferenceError) return "ReferenceError";
621
+ if (error instanceof SyntaxError) return "SyntaxError";
622
+ if (error instanceof TypeError) return "TypeError";
623
+ if (error instanceof URIError) return "URIError";
624
+ if (error instanceof AggregateError) return "AggregateError";
625
+ } catch {
626
+ }
627
+ return "Error";
628
+ }
629
+ function claimValues(namespace, claim) {
630
+ return [
631
+ namespace,
632
+ claim.taskRef.registryId,
633
+ claim.taskRef.accountId,
634
+ claim.taskRef.ownerScope,
635
+ claim.taskRef.taskId,
636
+ claim.intentFingerprint,
637
+ claim.leaseClaimId,
638
+ claim.leaseVersion,
639
+ claim.scopeFingerprint
640
+ ];
641
+ }
642
+ function taskRefFingerprint(ref) {
643
+ return canonicalJsonSha256({
644
+ registryId: ref.registryId,
645
+ accountId: ref.accountId,
646
+ ownerScope: ref.ownerScope,
647
+ taskId: ref.taskId
648
+ });
649
+ }
650
+ async function queryDb(db, operation, sql, values) {
651
+ try {
652
+ return await db.query(sql, values);
653
+ } catch (cause) {
654
+ throw new Error(`PostgresTaskSettlementIntentQueue.${operation}: database operation failed`, { cause });
655
+ }
656
+ }
657
+ async function reportRecoveryError(hook, error, claim, disposition) {
658
+ if (!hook) return;
659
+ try {
660
+ await hook(error, {
661
+ taskRef: claim.taskRef,
662
+ action: claim.action === "complete" ? "complete" : "fail",
663
+ attemptCount: claim.attemptCount,
664
+ disposition
665
+ });
666
+ } catch {
667
+ }
668
+ }
669
+ function assertPayloadSize(payload) {
670
+ const json = JSON.stringify(payload);
671
+ if (Buffer.byteLength(json, "utf8") > MAX_PAYLOAD_BYTES) {
672
+ throw new TypeError(`Task settlement intent payload exceeds ${MAX_PAYLOAD_BYTES} bytes`);
673
+ }
674
+ }
675
+ function assertValidTableName(table) {
676
+ if (!VALID_IDENTIFIER.test(table) || Buffer.byteLength(table, "utf8") > 40) {
677
+ throw new TypeError("Task settlement intent tableName must be a lowercase SQL identifier of at most 40 bytes");
678
+ }
679
+ }
680
+ function assertValidNamespace(namespace) {
681
+ if (typeof namespace !== "string" || !VALID_NAMESPACE.test(namespace)) {
682
+ throw new TypeError(
683
+ "Task settlement intent namespace must be 1-255 ASCII letters, digits, dots, underscores, colons, or hyphens"
684
+ );
685
+ }
686
+ }
687
+ function plainObject(value, field) {
688
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
689
+ throw new TypeError(`Stored task settlement intent ${field} must be an object`);
690
+ }
691
+ return value;
692
+ }
693
+ function requireRowString(value, field) {
694
+ if (typeof value !== "string" || value.length === 0) {
695
+ throw new TypeError(`Stored task settlement intent ${field} must be a non-empty string`);
696
+ }
697
+ return value;
698
+ }
699
+ function requireFingerprint(value, field) {
700
+ if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) {
701
+ throw new TypeError(`Stored task settlement intent ${field} must be a SHA-256 fingerprint`);
702
+ }
703
+ return value;
704
+ }
705
+ function requireRowInteger(value, field) {
706
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
707
+ throw new TypeError(`Stored task settlement intent ${field} must be a positive integer`);
708
+ }
709
+ return value;
710
+ }
711
+ export {
712
+ TASK_SETTLEMENT_INTENT_IDEMPOTENCY_HORIZON_MS,
713
+ TaskSettlementIntentConflictError,
714
+ canonicalizeTaskSettlementIntent,
715
+ createPostgresTaskSettlementIntentQueue,
716
+ getTaskSettlementIntentMigration
717
+ };