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