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

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