@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.
- package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
- package/dist/lib/server/decisioning/context.d.mts +4 -0
- package/dist/lib/server/decisioning/context.d.ts +4 -0
- package/dist/lib/server/decisioning/index.d.mts +1 -0
- package/dist/lib/server/decisioning/index.d.ts +1 -0
- package/dist/lib/server/decisioning/index.js +11 -0
- package/dist/lib/server/decisioning/index.mjs +12 -0
- package/dist/lib/server/decisioning/runtime/postgres-task-registry.js +5 -3
- package/dist/lib/server/decisioning/runtime/postgres-task-registry.mjs +5 -3
- package/dist/lib/server/decisioning/runtime/postgres-task-settlement-intents.d.mts +136 -0
- package/dist/lib/server/decisioning/runtime/postgres-task-settlement-intents.d.ts +136 -0
- package/dist/lib/server/decisioning/runtime/postgres-task-settlement-intents.js +745 -0
- package/dist/lib/server/decisioning/runtime/postgres-task-settlement-intents.mjs +717 -0
- package/dist/lib/server/decisioning/runtime/postgres-task-settlement.js +108 -36
- package/dist/lib/server/decisioning/runtime/postgres-task-settlement.mjs +108 -36
- package/dist/lib/server/decisioning/runtime/to-context.js +38 -9
- package/dist/lib/server/decisioning/runtime/to-context.mjs +38 -9
- package/dist/lib/testing/storyboard/validations.d.mts +1 -1
- package/dist/lib/testing/storyboard/validations.d.ts +1 -1
- package/dist/lib/types/schemas.generated.js +0 -63
- package/dist/lib/types/schemas.generated.mjs +0 -63
- package/dist/lib/utils/well-formed-unicode.d.mts +2 -0
- package/dist/lib/utils/well-formed-unicode.d.ts +2 -0
- package/dist/lib/utils/well-formed-unicode.js +51 -0
- package/dist/lib/utils/well-formed-unicode.mjs +27 -0
- package/dist/lib/version.d.mts +3 -3
- package/dist/lib/version.d.ts +3 -3
- package/dist/lib/version.js +3 -3
- package/dist/lib/version.mjs +3 -3
- package/docs/guides/BUILD-AN-AGENT.md +7 -0
- package/docs/guides/DURABLE-TASK-SETTLEMENT.md +468 -0
- package/docs/llms.txt +5 -2
- package/docs/migration-13-to-14.md +1 -1
- package/docs/migration-task-registry-scoping.md +45 -14
- 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
|
+
});
|