@adcp/sdk 14.1.0 → 14.2.0

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 (106) hide show
  1. package/dist/lib/core/SingleAgentClient.d.mts +4 -1
  2. package/dist/lib/core/SingleAgentClient.d.ts +4 -1
  3. package/dist/lib/core/SingleAgentClient.js +25 -4
  4. package/dist/lib/core/SingleAgentClient.mjs +25 -4
  5. package/dist/lib/core/TaskExecutor.d.mts +3 -1
  6. package/dist/lib/core/TaskExecutor.d.ts +3 -1
  7. package/dist/lib/core/TaskExecutor.js +15 -11
  8. package/dist/lib/core/TaskExecutor.mjs +15 -11
  9. package/dist/lib/core/buyer-account-registry.d.mts +30 -2
  10. package/dist/lib/core/buyer-account-registry.d.ts +30 -2
  11. package/dist/lib/core/buyer-account-registry.js +126 -42
  12. package/dist/lib/core/buyer-account-registry.mjs +127 -43
  13. package/dist/lib/index.d.mts +3 -2
  14. package/dist/lib/index.d.ts +3 -2
  15. package/dist/lib/index.js +9 -0
  16. package/dist/lib/index.mjs +12 -1
  17. package/dist/lib/net/agent-transport-fetch.d.mts +4 -0
  18. package/dist/lib/net/agent-transport-fetch.d.ts +4 -0
  19. package/dist/lib/net/agent-transport-fetch.js +18 -5
  20. package/dist/lib/net/agent-transport-fetch.mjs +17 -5
  21. package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
  22. package/dist/lib/signing/agent-resolver/errors.d.mts +2 -0
  23. package/dist/lib/signing/agent-resolver/errors.d.ts +2 -0
  24. package/dist/lib/signing/agent-resolver/errors.js +6 -0
  25. package/dist/lib/signing/agent-resolver/errors.mjs +6 -0
  26. package/dist/lib/signing/agent-resolver/fetch-helpers.d.mts +9 -0
  27. package/dist/lib/signing/agent-resolver/fetch-helpers.d.ts +9 -0
  28. package/dist/lib/signing/agent-resolver/fetch-helpers.js +35 -1
  29. package/dist/lib/signing/agent-resolver/fetch-helpers.mjs +38 -2
  30. package/dist/lib/signing/agent-resolver/legacy-brand.js +1 -1
  31. package/dist/lib/signing/agent-resolver/legacy-brand.mjs +2 -2
  32. package/dist/lib/signing/agent-resolver/resolve-agent.d.mts +1 -1
  33. package/dist/lib/signing/agent-resolver/resolve-agent.d.ts +1 -1
  34. package/dist/lib/signing/agent-resolver/resolve-agent.js +47 -5
  35. package/dist/lib/signing/agent-resolver/resolve-agent.mjs +55 -6
  36. package/dist/lib/signing/brand-jwks.d.mts +2 -0
  37. package/dist/lib/signing/brand-jwks.d.ts +2 -0
  38. package/dist/lib/signing/brand-jwks.js +10 -2
  39. package/dist/lib/signing/brand-jwks.mjs +10 -2
  40. package/dist/lib/signing/errors.d.mts +4 -2
  41. package/dist/lib/signing/errors.d.ts +4 -2
  42. package/dist/lib/signing/errors.js +3 -1
  43. package/dist/lib/signing/errors.mjs +3 -1
  44. package/dist/lib/signing/verifier.js +1 -1
  45. package/dist/lib/signing/verifier.mjs +1 -1
  46. package/dist/lib/signing/webhook-verifier.js +2 -2
  47. package/dist/lib/signing/webhook-verifier.mjs +3 -3
  48. package/dist/lib/testing/storyboard/validations.d.mts +1 -1
  49. package/dist/lib/testing/storyboard/validations.d.ts +1 -1
  50. package/dist/lib/types/accept-proposal.d.ts +19 -1
  51. package/dist/lib/types/buy-products.d.ts +19 -1
  52. package/dist/lib/types/check-governance.d.ts +19 -1
  53. package/dist/lib/types/comply-test-controller.d.ts +19 -1
  54. package/dist/lib/types/control-media-buy.d.ts +19 -1
  55. package/dist/lib/types/core.generated.d.mts +14 -1
  56. package/dist/lib/types/core.generated.d.ts +14 -1
  57. package/dist/lib/types/create-media-buy.d.ts +14 -1
  58. package/dist/lib/types/get-media-buys.d.ts +14 -1
  59. package/dist/lib/types/get-products.d.ts +19 -1
  60. package/dist/lib/types/list-products.d.ts +19 -1
  61. package/dist/lib/types/refine-proposals.d.ts +19 -1
  62. package/dist/lib/types/request-proposals.d.ts +19 -1
  63. package/dist/lib/types/schemas.generated.d.ts +12 -3
  64. package/dist/lib/types/schemas.generated.js +4 -1
  65. package/dist/lib/types/schemas.generated.mjs +4 -1
  66. package/dist/lib/types/tools.generated.d.mts +14 -1
  67. package/dist/lib/types/tools.generated.d.ts +14 -1
  68. package/dist/lib/types/update-media-buy.d.ts +14 -1
  69. package/dist/lib/version.d.mts +3 -3
  70. package/dist/lib/version.d.ts +3 -3
  71. package/dist/lib/version.js +3 -3
  72. package/dist/lib/version.mjs +3 -3
  73. package/dist/lib/webhooks/index.d.mts +24 -0
  74. package/dist/lib/webhooks/index.d.ts +24 -0
  75. package/dist/lib/webhooks/index.js +50 -24
  76. package/dist/lib/webhooks/index.mjs +49 -24
  77. package/dist/lib/wholesale-feed-sync/index.d.mts +2 -0
  78. package/dist/lib/wholesale-feed-sync/index.d.ts +2 -0
  79. package/dist/lib/wholesale-feed-sync/index.js +7 -0
  80. package/dist/lib/wholesale-feed-sync/index.mjs +4 -0
  81. package/dist/lib/wholesale-feed-sync/mirror.d.mts +97 -0
  82. package/dist/lib/wholesale-feed-sync/mirror.d.ts +97 -0
  83. package/dist/lib/wholesale-feed-sync/mirror.js +350 -0
  84. package/dist/lib/wholesale-feed-sync/mirror.mjs +322 -0
  85. package/dist/lib/wholesale-feed-sync/sync.d.mts +12 -29
  86. package/dist/lib/wholesale-feed-sync/sync.d.ts +12 -29
  87. package/dist/lib/wholesale-feed-sync/sync.js +153 -297
  88. package/dist/lib/wholesale-feed-sync/sync.mjs +158 -297
  89. package/docs/README.md +6 -0
  90. package/docs/TYPE-SUMMARY.md +2 -2
  91. package/docs/guides/BUYER-STORAGE.md +3 -0
  92. package/docs/guides/FIRST-CALL-TO-A-SELLER.md +3 -1
  93. package/docs/guides/account-resolution.md +46 -10
  94. package/docs/llms.txt +2 -2
  95. package/docs/migration-14.0-to-14.1.md +85 -0
  96. package/docs/migration-14.x-rc-worksheet.md +4 -4
  97. package/docs/migration-agent-resolution-3.3.md +5 -3
  98. package/docs/recipes/verifying-inbound-webhooks.md +4 -0
  99. package/package.json +2 -1
  100. package/skills/adcp-brand.previous/SKILL.md +0 -200
  101. package/skills/adcp-creative.previous/SKILL.md +0 -305
  102. package/skills/adcp-governance.previous/SKILL.md +0 -566
  103. package/skills/adcp-measurement.previous/SKILL.md +0 -136
  104. package/skills/adcp-media-buy.previous/SKILL.md +0 -556
  105. package/skills/adcp-si.previous/SKILL.md +0 -206
  106. package/skills/adcp-signals.previous/SKILL.md +0 -204
@@ -28,25 +28,32 @@ var import_account_resolution = require('./account-resolution.js');
28
28
  var import_errors = require('../errors/index.js');
29
29
  var import_abort = require('../protocols/abort.js');
30
30
  var import_task_deadline = require('./task-deadline.js');
31
+ var import_idempotency = require('../utils/idempotency.js');
31
32
  function setupTerms(options) {
32
33
  const stable = (value) => Array.isArray(value) ? value.map(stable) : value && typeof value === "object" ? Object.fromEntries(
33
34
  Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, stable(item)])
34
35
  ) : value;
35
36
  return Object.fromEntries(
36
- Object.entries(options).map(([key, value]) => [
37
+ Object.entries(options).filter(([key]) => key !== "idempotencyKey").map(([key, value]) => [
37
38
  key,
38
39
  (0, import_node_crypto.createHash)("sha256").update(JSON.stringify(stable(value))).digest("hex")
39
40
  ])
40
41
  );
41
42
  }
43
+ function isDefinitiveRejection(result) {
44
+ return Boolean(
45
+ result.adcpError && !result.adcpError.synthetic && !result.adcpError.code.startsWith("IDEMPOTENCY_") && ["terminal", "correctable"].includes(result.adcpError.recovery ?? "")
46
+ );
47
+ }
42
48
  class BuyerAccountRegistry {
43
- constructor(scope, provision, storage, repair, options = {}) {
49
+ constructor(scope, provision, storage, repair, registryOptions = {}) {
44
50
  this.scope = scope;
45
51
  this.provision = provision;
46
52
  this.storage = storage;
47
53
  this.repair = repair;
48
- this.maxEntries = options.maxEntries ?? 1e4;
49
- this.normalizeOptions = options.normalizeOptions;
54
+ this.registryOptions = registryOptions;
55
+ this.maxEntries = registryOptions.maxEntries ?? 1e4;
56
+ this.normalizeOptions = registryOptions.normalizeOptions;
50
57
  if (!Number.isSafeInteger(this.maxEntries) || this.maxEntries < 2)
51
58
  throw new Error("Account registry maxEntries must be an integer >= 2.");
52
59
  }
@@ -54,6 +61,7 @@ class BuyerAccountRegistry {
54
61
  provision;
55
62
  storage;
56
63
  repair;
64
+ registryOptions;
57
65
  entries = /* @__PURE__ */ new Map();
58
66
  pending = /* @__PURE__ */ new Map();
59
67
  writes = Promise.resolve();
@@ -87,13 +95,15 @@ class BuyerAccountRegistry {
87
95
  }
88
96
  async get(account) {
89
97
  const key = this.key(account);
90
- let entry = this.entries.get(key);
91
- if (!entry) {
98
+ let entry = this.storage?.compareAndSet ? await this.storage.get(key) : this.entries.get(key);
99
+ if (!entry && !this.storage?.compareAndSet) {
92
100
  const persisted = await this.storage?.get(key);
93
101
  entry = this.entries.get(key) ?? persisted;
94
102
  }
95
103
  if (key !== this.key(account) || entry && (0, import_account_key.accountReferenceKey)(entry.account) !== (0, import_account_key.accountReferenceKey)(account))
96
104
  return void 0;
105
+ if (entry && this.storage?.compareAndSet && (!Number.isSafeInteger(entry.revision) || entry.revision < 1))
106
+ throw new Error("CAS account storage must return a positive integer revision.");
97
107
  if (entry) this.remember(key, entry);
98
108
  if (entry?.account_id && this.invalidatedHandles.has(this.key({ account_id: entry.account_id })))
99
109
  return { ...structuredClone(entry), status: "unknown" };
@@ -106,6 +116,8 @@ class BuyerAccountRegistry {
106
116
  account = structuredClone(account);
107
117
  if (this.normalizeOptions) options = await this.normalizeOptions(structuredClone(options), taskOptions);
108
118
  options = structuredClone(Object.fromEntries(Object.entries(options).filter(([, value]) => value !== void 0)));
119
+ if (options.idempotencyKey !== void 0 && !(0, import_idempotency.isValidIdempotencyKey)(options.idempotencyKey))
120
+ throw new TypeError("idempotencyKey must match [A-Za-z0-9_.:-]{16,255}.");
109
121
  const scope = this.scope();
110
122
  const key = this.key(account, scope);
111
123
  const current = await this.get(account);
@@ -118,7 +130,9 @@ class BuyerAccountRegistry {
118
130
  );
119
131
  if (shared) {
120
132
  if (!(0, import_node_util.isDeepStrictEqual)(shared.options, options))
121
- throw new import_errors.AccountSetupRequiredError("Provisioning is already in progress with different billing terms.");
133
+ throw new import_errors.AccountSetupRequiredError(
134
+ "Provisioning is already in progress with different billing terms or idempotency keys."
135
+ );
122
136
  const entry2 = await wait(shared.promise);
123
137
  if (shared.result) onTaskResult?.(shared.result);
124
138
  return entry2;
@@ -157,6 +171,10 @@ class BuyerAccountRegistry {
157
171
  throw new import_errors.AccountNotFoundError(
158
172
  "Reconcile this account with listAccounts or explicitly reestablish it with syncAccounts."
159
173
  );
174
+ if (current.status === "provisioning" && current.dispatch)
175
+ throw new import_errors.AccountSetupRequiredError(
176
+ `Provisioning is claimed or in doubt in durable storage.${current.pendingTaskId ? ` Recover task ${current.pendingTaskId}.` : ""} Reconcile the recorded idempotency key and observeSync before retrying.`
177
+ );
160
178
  if (current.status === "provisioning" && current.pendingTaskId)
161
179
  throw new import_errors.AccountSetupRequiredError(
162
180
  `Reconcile pending sync_accounts task ${current.pendingTaskId} and observe its completed account rows before provisioning again.`
@@ -186,42 +204,97 @@ class BuyerAccountRegistry {
186
204
  };
187
205
  const operationOptions = { ...taskOptions, signal: void 0, timeout: void 0 };
188
206
  const observationEpoch = this.captureObservation();
189
- const request = this.provision(structuredClone(account), pending.options, operationOptions).then(async (initial) => {
207
+ const idempotencyKey = options.idempotencyKey ?? (0, import_node_crypto.randomUUID)();
208
+ const dispatch = { storageKey: key, account: structuredClone(account), idempotencyKey };
209
+ let claimRevision;
210
+ let dispatched = false;
211
+ const releaseClaim = async () => {
212
+ if (!this.storage?.compareAndSet || claimRevision === void 0) return;
213
+ const latest = await this.storage.get(key);
214
+ if (latest?.revision !== claimRevision || latest.dispatch?.idempotencyKey !== idempotencyKey) return;
215
+ await this.write({ ...latest, status: "failed_provisioning", dispatch: void 0 }, scope);
216
+ };
217
+ const beforeDispatch = async () => {
218
+ if (scope !== this.scope()) throw new import_errors.AccountNotFoundError("Caller credentials changed before dispatch.");
219
+ if (this.storage?.compareAndSet) {
220
+ await this.write(
221
+ {
222
+ account,
223
+ status: "provisioning",
224
+ setupTerms: terms,
225
+ revision: current?.revision,
226
+ dispatch: { idempotencyKey, startedAt: (/* @__PURE__ */ new Date()).toISOString() }
227
+ },
228
+ scope
229
+ );
230
+ claimRevision = (current?.revision ?? 0) + 1;
231
+ }
232
+ await this.registryOptions.onDispatchStart?.(structuredClone(dispatch));
233
+ if (scope !== this.scope()) throw new import_errors.AccountNotFoundError("Caller credentials changed before dispatch.");
234
+ dispatched = true;
235
+ };
236
+ const request = (async () => {
237
+ try {
238
+ if (!this.registryOptions.provisionOwnsDispatchBoundary) await beforeDispatch();
239
+ return await this.provision(structuredClone(account), pending.options, operationOptions, {
240
+ idempotencyKey,
241
+ beforeDispatch,
242
+ requireIdempotencyKey: Boolean(
243
+ options.idempotencyKey || this.storage?.compareAndSet || this.registryOptions.onDispatchStart || this.registryOptions.onResult
244
+ )
245
+ });
246
+ } catch (error) {
247
+ if (!dispatched) await releaseClaim();
248
+ throw error;
249
+ }
250
+ })().then(async (initial) => {
190
251
  let result = initial;
191
252
  pending.result = result;
253
+ if (dispatched) await this.registryOptions.onResult?.({ ...structuredClone(dispatch), result });
192
254
  notify(result);
193
255
  if (this.key(account) !== key) throw new import_errors.AccountNotFoundError("Caller credentials changed during provisioning.");
194
- if (!result.success)
256
+ if (!result.success) {
257
+ if (!dispatched || isDefinitiveRejection(result)) await releaseClaim();
195
258
  throw (0, import_errors.adcpErrorToTypedError)(result.adcpError ?? { code: "ACCOUNT_NOT_FOUND", message: result.error }) ?? new Error(result.error);
259
+ }
196
260
  if (result.status !== "completed") {
197
261
  const provisional = {
198
262
  account: structuredClone(account),
199
263
  status: "provisioning",
200
264
  setupTerms: current?.setupTerms ?? terms,
201
- ...result.metadata.taskId && { pendingTaskId: result.metadata.taskId }
265
+ ...(result.metadata.serverTaskId ?? result.metadata.taskId) && {
266
+ pendingTaskId: result.metadata.serverTaskId ?? result.metadata.taskId
267
+ }
202
268
  };
203
269
  await this.enqueue(async () => {
204
270
  const latest = await this.get(account);
205
- if (!latest || latest.status === "failed_provisioning" || latest.status === current?.status && latest.account_id === current?.account_id)
206
- await this.write(provisional, scope);
271
+ if (this.storage?.compareAndSet ? latest?.revision === claimRevision && latest?.dispatch?.idempotencyKey === idempotencyKey : !latest || latest.status === "failed_provisioning" || latest.status === current?.status && latest.account_id === current?.account_id) {
272
+ await this.write({ ...provisional, revision: latest?.revision, dispatch: latest?.dispatch }, scope);
273
+ if (claimRevision !== void 0) claimRevision = (latest?.revision ?? 0) + 1;
274
+ }
207
275
  });
208
276
  if (!result.submitted) return provisional;
209
277
  result = await result.submitted.waitForCompletion();
210
278
  pending.result = result;
279
+ await this.registryOptions.onResult?.({ ...structuredClone(dispatch), result });
211
280
  notify(result);
212
281
  if (!result.success) {
213
- await this.enqueue(async () => {
214
- const latest = await this.get(account);
215
- if (!latest || latest.status === "provisioning")
216
- await this.write(
217
- {
218
- account: structuredClone(account),
219
- status: "failed_provisioning",
220
- setupTerms: current?.setupTerms ?? terms
221
- },
222
- scope
223
- );
224
- });
282
+ if (this.storage?.compareAndSet) {
283
+ if (isDefinitiveRejection(result)) await releaseClaim();
284
+ } else {
285
+ await this.enqueue(async () => {
286
+ const latest = await this.get(account);
287
+ if (!latest || latest.status === "provisioning")
288
+ await this.write(
289
+ {
290
+ account: structuredClone(account),
291
+ status: "failed_provisioning",
292
+ setupTerms: current?.setupTerms ?? terms
293
+ },
294
+ scope
295
+ );
296
+ });
297
+ }
225
298
  throw (0, import_errors.adcpErrorToTypedError)(result.adcpError ?? { code: "ACCOUNT_SETUP_REQUIRED", message: result.error }) ?? new Error(result.error);
226
299
  }
227
300
  }
@@ -230,35 +303,36 @@ class BuyerAccountRegistry {
230
303
  throw new import_errors.AccountSetupRequiredError(
231
304
  "sync_accounts has not completed; observe its completion before provisioning again."
232
305
  );
233
- await this.observeSync([account], result.data.accounts, false, observationEpoch);
306
+ await this.observeSync([account], result.data.accounts, false, observationEpoch, claimRevision);
234
307
  const entry2 = await this.get(account);
235
308
  if (scope !== this.scope()) throw new import_errors.AccountNotFoundError("Caller credentials changed during provisioning.");
236
309
  if (!entry2 || entry2.status === "provisioning" || entry2.status === "failed_provisioning") {
237
- await this.enqueue(async () => {
238
- const latest = await this.get(account);
239
- if (!latest || ["provisioning", "failed_provisioning"].includes(latest.status))
240
- await this.write(
241
- {
242
- account,
243
- status: "failed_provisioning",
244
- setupTerms: current?.setupTerms ?? terms
245
- },
246
- scope
247
- );
248
- });
310
+ if (this.storage?.compareAndSet) await releaseClaim();
311
+ else
312
+ await this.enqueue(async () => {
313
+ const latest = await this.get(account);
314
+ if (!latest || ["provisioning", "failed_provisioning"].includes(latest.status))
315
+ await this.write(
316
+ { account, status: "failed_provisioning", setupTerms: current?.setupTerms ?? terms },
317
+ scope
318
+ );
319
+ });
249
320
  const failed = result.data.accounts.find((row) => row.action === "failed");
250
321
  const error = failed?.errors?.[0];
251
322
  throw (error && (0, import_errors.adcpErrorToTypedError)(error)) ?? new import_errors.AccountNotFoundError("sync_accounts did not establish the requested account.");
252
323
  }
324
+ if (claimRevision !== void 0 && entry2.revision !== claimRevision + 1) return entry2;
253
325
  let settled = entry2;
254
326
  await this.enqueue(async () => {
255
327
  const latest = await this.get(account);
256
328
  if (scope !== this.scope()) throw new import_errors.AccountNotFoundError("Caller credentials changed during provisioning.");
257
329
  if (!latest) throw new import_errors.AccountNotFoundError("The provisioned account is no longer registered.");
258
330
  settled = latest;
259
- if (latest.account_id !== entry2.account_id) return;
331
+ if (latest.account_id !== entry2.account_id || this.storage?.compareAndSet && latest.revision !== entry2.revision)
332
+ return;
260
333
  settled = { ...latest, setupTerms: setupTerms(options) };
261
334
  await this.write(settled, scope);
335
+ settled = await this.get(account) ?? settled;
262
336
  if (settled.account_id && !("account_id" in account)) {
263
337
  const idEntry = await this.get({ account_id: settled.account_id });
264
338
  if (scope !== this.scope()) throw new import_errors.AccountNotFoundError("Caller credentials changed during provisioning.");
@@ -280,16 +354,24 @@ class BuyerAccountRegistry {
280
354
  async write(entry, scope, isCurrent = () => true) {
281
355
  if (!isCurrent()) return;
282
356
  const key = this.key(entry.account, scope);
357
+ if (this.storage?.compareAndSet) {
358
+ const revision = entry.revision;
359
+ entry = { ...entry, revision: (revision ?? 0) + 1 };
360
+ if (!await this.storage.compareAndSet(key, revision, structuredClone(entry)))
361
+ throw new import_errors.AccountSetupRequiredError("Account storage revision changed. Reload and reconcile before retrying.");
362
+ } else {
363
+ await this.storage?.set(key, structuredClone(entry));
364
+ }
283
365
  if (scope === this.scope()) this.remember(key, entry);
284
- await this.storage?.set(key, structuredClone(entry));
285
366
  }
286
- async record(ref, row, scope, preserveTerms = false, isCurrent = () => true) {
367
+ async record(ref, row, scope, preserveTerms = false, isCurrent = () => true, expectedRevision) {
287
368
  const entry = {
288
369
  account: structuredClone(ref),
289
370
  status: row.status,
290
371
  ...row.account_id && { account_id: row.account_id }
291
372
  };
292
373
  const prior = await this.get(ref);
374
+ if (expectedRevision !== void 0 && prior?.revision !== expectedRevision) return;
293
375
  if (scope !== this.scope() || !isCurrent()) return;
294
376
  const idRef = row.account_id && !("account_id" in ref) ? { account_id: row.account_id } : void 0;
295
377
  const priorId = idRef ? await this.get(idRef) : void 0;
@@ -311,6 +393,7 @@ class BuyerAccountRegistry {
311
393
  );
312
394
  }
313
395
  }
396
+ entry.revision = prior?.revision;
314
397
  if (preserveTerms && prior?.setupTerms) entry.setupTerms = prior.setupTerms;
315
398
  if ("account_id" in ref) {
316
399
  if (prior?.aliases) entry.aliases = prior.aliases;
@@ -320,6 +403,7 @@ class BuyerAccountRegistry {
320
403
  await this.write(
321
404
  {
322
405
  account: idRef,
406
+ revision: priorId?.revision,
323
407
  account_id: row.account_id,
324
408
  status: row.status,
325
409
  aliases: [...aliases.values()],
@@ -335,7 +419,7 @@ class BuyerAccountRegistry {
335
419
  });
336
420
  return next;
337
421
  }
338
- async observeSync(refs, rows, dryRun = false, observationEpoch) {
422
+ async observeSync(refs, rows, dryRun = false, observationEpoch, expectedRevision) {
339
423
  if (dryRun) return;
340
424
  const scope = this.scope();
341
425
  return this.enqueue(async () => {
@@ -367,7 +451,7 @@ class BuyerAccountRegistry {
367
451
  repairedBinding = true;
368
452
  }
369
453
  const isCurrent = () => scope === this.scope() && this.observationIsCurrent(row.account_id, rowEpoch, scope);
370
- await this.record(ref, row, scope, false, isCurrent);
454
+ await this.record(ref, row, scope, false, isCurrent, expectedRevision);
371
455
  if (!isCurrent() || repairedBinding) continue;
372
456
  for (const id of [row.account_id])
373
457
  if (id) {
@@ -1,29 +1,36 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
- import { createHash } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import { accountReferenceKey } from "./account-key.mjs";
4
4
  import { sameCountrySet } from "./account-resolution.mjs";
5
5
  import { adcpErrorToTypedError, AccountNotFoundError, AccountSetupRequiredError } from "../errors/index.mjs";
6
6
  import { isAbortOrTimeoutError, MAX_TIMER_DELAY_MS, throwIfAborted, withAbortSignal } from "../protocols/abort.mjs";
7
7
  import { withTaskDeadline } from "./task-deadline.mjs";
8
+ import { isValidIdempotencyKey } from "../utils/idempotency.mjs";
8
9
  function setupTerms(options) {
9
10
  const stable = (value) => Array.isArray(value) ? value.map(stable) : value && typeof value === "object" ? Object.fromEntries(
10
11
  Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, stable(item)])
11
12
  ) : value;
12
13
  return Object.fromEntries(
13
- Object.entries(options).map(([key, value]) => [
14
+ Object.entries(options).filter(([key]) => key !== "idempotencyKey").map(([key, value]) => [
14
15
  key,
15
16
  createHash("sha256").update(JSON.stringify(stable(value))).digest("hex")
16
17
  ])
17
18
  );
18
19
  }
20
+ function isDefinitiveRejection(result) {
21
+ return Boolean(
22
+ result.adcpError && !result.adcpError.synthetic && !result.adcpError.code.startsWith("IDEMPOTENCY_") && ["terminal", "correctable"].includes(result.adcpError.recovery ?? "")
23
+ );
24
+ }
19
25
  class BuyerAccountRegistry {
20
- constructor(scope, provision, storage, repair, options = {}) {
26
+ constructor(scope, provision, storage, repair, registryOptions = {}) {
21
27
  this.scope = scope;
22
28
  this.provision = provision;
23
29
  this.storage = storage;
24
30
  this.repair = repair;
25
- this.maxEntries = options.maxEntries ?? 1e4;
26
- this.normalizeOptions = options.normalizeOptions;
31
+ this.registryOptions = registryOptions;
32
+ this.maxEntries = registryOptions.maxEntries ?? 1e4;
33
+ this.normalizeOptions = registryOptions.normalizeOptions;
27
34
  if (!Number.isSafeInteger(this.maxEntries) || this.maxEntries < 2)
28
35
  throw new Error("Account registry maxEntries must be an integer >= 2.");
29
36
  }
@@ -31,6 +38,7 @@ class BuyerAccountRegistry {
31
38
  provision;
32
39
  storage;
33
40
  repair;
41
+ registryOptions;
34
42
  entries = /* @__PURE__ */ new Map();
35
43
  pending = /* @__PURE__ */ new Map();
36
44
  writes = Promise.resolve();
@@ -64,13 +72,15 @@ class BuyerAccountRegistry {
64
72
  }
65
73
  async get(account) {
66
74
  const key = this.key(account);
67
- let entry = this.entries.get(key);
68
- if (!entry) {
75
+ let entry = this.storage?.compareAndSet ? await this.storage.get(key) : this.entries.get(key);
76
+ if (!entry && !this.storage?.compareAndSet) {
69
77
  const persisted = await this.storage?.get(key);
70
78
  entry = this.entries.get(key) ?? persisted;
71
79
  }
72
80
  if (key !== this.key(account) || entry && accountReferenceKey(entry.account) !== accountReferenceKey(account))
73
81
  return void 0;
82
+ if (entry && this.storage?.compareAndSet && (!Number.isSafeInteger(entry.revision) || entry.revision < 1))
83
+ throw new Error("CAS account storage must return a positive integer revision.");
74
84
  if (entry) this.remember(key, entry);
75
85
  if (entry?.account_id && this.invalidatedHandles.has(this.key({ account_id: entry.account_id })))
76
86
  return { ...structuredClone(entry), status: "unknown" };
@@ -83,6 +93,8 @@ class BuyerAccountRegistry {
83
93
  account = structuredClone(account);
84
94
  if (this.normalizeOptions) options = await this.normalizeOptions(structuredClone(options), taskOptions);
85
95
  options = structuredClone(Object.fromEntries(Object.entries(options).filter(([, value]) => value !== void 0)));
96
+ if (options.idempotencyKey !== void 0 && !isValidIdempotencyKey(options.idempotencyKey))
97
+ throw new TypeError("idempotencyKey must match [A-Za-z0-9_.:-]{16,255}.");
86
98
  const scope = this.scope();
87
99
  const key = this.key(account, scope);
88
100
  const current = await this.get(account);
@@ -95,7 +107,9 @@ class BuyerAccountRegistry {
95
107
  );
96
108
  if (shared) {
97
109
  if (!isDeepStrictEqual(shared.options, options))
98
- throw new AccountSetupRequiredError("Provisioning is already in progress with different billing terms.");
110
+ throw new AccountSetupRequiredError(
111
+ "Provisioning is already in progress with different billing terms or idempotency keys."
112
+ );
99
113
  const entry2 = await wait(shared.promise);
100
114
  if (shared.result) onTaskResult?.(shared.result);
101
115
  return entry2;
@@ -134,6 +148,10 @@ class BuyerAccountRegistry {
134
148
  throw new AccountNotFoundError(
135
149
  "Reconcile this account with listAccounts or explicitly reestablish it with syncAccounts."
136
150
  );
151
+ if (current.status === "provisioning" && current.dispatch)
152
+ throw new AccountSetupRequiredError(
153
+ `Provisioning is claimed or in doubt in durable storage.${current.pendingTaskId ? ` Recover task ${current.pendingTaskId}.` : ""} Reconcile the recorded idempotency key and observeSync before retrying.`
154
+ );
137
155
  if (current.status === "provisioning" && current.pendingTaskId)
138
156
  throw new AccountSetupRequiredError(
139
157
  `Reconcile pending sync_accounts task ${current.pendingTaskId} and observe its completed account rows before provisioning again.`
@@ -163,42 +181,97 @@ class BuyerAccountRegistry {
163
181
  };
164
182
  const operationOptions = { ...taskOptions, signal: void 0, timeout: void 0 };
165
183
  const observationEpoch = this.captureObservation();
166
- const request = this.provision(structuredClone(account), pending.options, operationOptions).then(async (initial) => {
184
+ const idempotencyKey = options.idempotencyKey ?? randomUUID();
185
+ const dispatch = { storageKey: key, account: structuredClone(account), idempotencyKey };
186
+ let claimRevision;
187
+ let dispatched = false;
188
+ const releaseClaim = async () => {
189
+ if (!this.storage?.compareAndSet || claimRevision === void 0) return;
190
+ const latest = await this.storage.get(key);
191
+ if (latest?.revision !== claimRevision || latest.dispatch?.idempotencyKey !== idempotencyKey) return;
192
+ await this.write({ ...latest, status: "failed_provisioning", dispatch: void 0 }, scope);
193
+ };
194
+ const beforeDispatch = async () => {
195
+ if (scope !== this.scope()) throw new AccountNotFoundError("Caller credentials changed before dispatch.");
196
+ if (this.storage?.compareAndSet) {
197
+ await this.write(
198
+ {
199
+ account,
200
+ status: "provisioning",
201
+ setupTerms: terms,
202
+ revision: current?.revision,
203
+ dispatch: { idempotencyKey, startedAt: (/* @__PURE__ */ new Date()).toISOString() }
204
+ },
205
+ scope
206
+ );
207
+ claimRevision = (current?.revision ?? 0) + 1;
208
+ }
209
+ await this.registryOptions.onDispatchStart?.(structuredClone(dispatch));
210
+ if (scope !== this.scope()) throw new AccountNotFoundError("Caller credentials changed before dispatch.");
211
+ dispatched = true;
212
+ };
213
+ const request = (async () => {
214
+ try {
215
+ if (!this.registryOptions.provisionOwnsDispatchBoundary) await beforeDispatch();
216
+ return await this.provision(structuredClone(account), pending.options, operationOptions, {
217
+ idempotencyKey,
218
+ beforeDispatch,
219
+ requireIdempotencyKey: Boolean(
220
+ options.idempotencyKey || this.storage?.compareAndSet || this.registryOptions.onDispatchStart || this.registryOptions.onResult
221
+ )
222
+ });
223
+ } catch (error) {
224
+ if (!dispatched) await releaseClaim();
225
+ throw error;
226
+ }
227
+ })().then(async (initial) => {
167
228
  let result = initial;
168
229
  pending.result = result;
230
+ if (dispatched) await this.registryOptions.onResult?.({ ...structuredClone(dispatch), result });
169
231
  notify(result);
170
232
  if (this.key(account) !== key) throw new AccountNotFoundError("Caller credentials changed during provisioning.");
171
- if (!result.success)
233
+ if (!result.success) {
234
+ if (!dispatched || isDefinitiveRejection(result)) await releaseClaim();
172
235
  throw adcpErrorToTypedError(result.adcpError ?? { code: "ACCOUNT_NOT_FOUND", message: result.error }) ?? new Error(result.error);
236
+ }
173
237
  if (result.status !== "completed") {
174
238
  const provisional = {
175
239
  account: structuredClone(account),
176
240
  status: "provisioning",
177
241
  setupTerms: current?.setupTerms ?? terms,
178
- ...result.metadata.taskId && { pendingTaskId: result.metadata.taskId }
242
+ ...(result.metadata.serverTaskId ?? result.metadata.taskId) && {
243
+ pendingTaskId: result.metadata.serverTaskId ?? result.metadata.taskId
244
+ }
179
245
  };
180
246
  await this.enqueue(async () => {
181
247
  const latest = await this.get(account);
182
- if (!latest || latest.status === "failed_provisioning" || latest.status === current?.status && latest.account_id === current?.account_id)
183
- await this.write(provisional, scope);
248
+ if (this.storage?.compareAndSet ? latest?.revision === claimRevision && latest?.dispatch?.idempotencyKey === idempotencyKey : !latest || latest.status === "failed_provisioning" || latest.status === current?.status && latest.account_id === current?.account_id) {
249
+ await this.write({ ...provisional, revision: latest?.revision, dispatch: latest?.dispatch }, scope);
250
+ if (claimRevision !== void 0) claimRevision = (latest?.revision ?? 0) + 1;
251
+ }
184
252
  });
185
253
  if (!result.submitted) return provisional;
186
254
  result = await result.submitted.waitForCompletion();
187
255
  pending.result = result;
256
+ await this.registryOptions.onResult?.({ ...structuredClone(dispatch), result });
188
257
  notify(result);
189
258
  if (!result.success) {
190
- await this.enqueue(async () => {
191
- const latest = await this.get(account);
192
- if (!latest || latest.status === "provisioning")
193
- await this.write(
194
- {
195
- account: structuredClone(account),
196
- status: "failed_provisioning",
197
- setupTerms: current?.setupTerms ?? terms
198
- },
199
- scope
200
- );
201
- });
259
+ if (this.storage?.compareAndSet) {
260
+ if (isDefinitiveRejection(result)) await releaseClaim();
261
+ } else {
262
+ await this.enqueue(async () => {
263
+ const latest = await this.get(account);
264
+ if (!latest || latest.status === "provisioning")
265
+ await this.write(
266
+ {
267
+ account: structuredClone(account),
268
+ status: "failed_provisioning",
269
+ setupTerms: current?.setupTerms ?? terms
270
+ },
271
+ scope
272
+ );
273
+ });
274
+ }
202
275
  throw adcpErrorToTypedError(result.adcpError ?? { code: "ACCOUNT_SETUP_REQUIRED", message: result.error }) ?? new Error(result.error);
203
276
  }
204
277
  }
@@ -207,35 +280,36 @@ class BuyerAccountRegistry {
207
280
  throw new AccountSetupRequiredError(
208
281
  "sync_accounts has not completed; observe its completion before provisioning again."
209
282
  );
210
- await this.observeSync([account], result.data.accounts, false, observationEpoch);
283
+ await this.observeSync([account], result.data.accounts, false, observationEpoch, claimRevision);
211
284
  const entry2 = await this.get(account);
212
285
  if (scope !== this.scope()) throw new AccountNotFoundError("Caller credentials changed during provisioning.");
213
286
  if (!entry2 || entry2.status === "provisioning" || entry2.status === "failed_provisioning") {
214
- await this.enqueue(async () => {
215
- const latest = await this.get(account);
216
- if (!latest || ["provisioning", "failed_provisioning"].includes(latest.status))
217
- await this.write(
218
- {
219
- account,
220
- status: "failed_provisioning",
221
- setupTerms: current?.setupTerms ?? terms
222
- },
223
- scope
224
- );
225
- });
287
+ if (this.storage?.compareAndSet) await releaseClaim();
288
+ else
289
+ await this.enqueue(async () => {
290
+ const latest = await this.get(account);
291
+ if (!latest || ["provisioning", "failed_provisioning"].includes(latest.status))
292
+ await this.write(
293
+ { account, status: "failed_provisioning", setupTerms: current?.setupTerms ?? terms },
294
+ scope
295
+ );
296
+ });
226
297
  const failed = result.data.accounts.find((row) => row.action === "failed");
227
298
  const error = failed?.errors?.[0];
228
299
  throw (error && adcpErrorToTypedError(error)) ?? new AccountNotFoundError("sync_accounts did not establish the requested account.");
229
300
  }
301
+ if (claimRevision !== void 0 && entry2.revision !== claimRevision + 1) return entry2;
230
302
  let settled = entry2;
231
303
  await this.enqueue(async () => {
232
304
  const latest = await this.get(account);
233
305
  if (scope !== this.scope()) throw new AccountNotFoundError("Caller credentials changed during provisioning.");
234
306
  if (!latest) throw new AccountNotFoundError("The provisioned account is no longer registered.");
235
307
  settled = latest;
236
- if (latest.account_id !== entry2.account_id) return;
308
+ if (latest.account_id !== entry2.account_id || this.storage?.compareAndSet && latest.revision !== entry2.revision)
309
+ return;
237
310
  settled = { ...latest, setupTerms: setupTerms(options) };
238
311
  await this.write(settled, scope);
312
+ settled = await this.get(account) ?? settled;
239
313
  if (settled.account_id && !("account_id" in account)) {
240
314
  const idEntry = await this.get({ account_id: settled.account_id });
241
315
  if (scope !== this.scope()) throw new AccountNotFoundError("Caller credentials changed during provisioning.");
@@ -257,16 +331,24 @@ class BuyerAccountRegistry {
257
331
  async write(entry, scope, isCurrent = () => true) {
258
332
  if (!isCurrent()) return;
259
333
  const key = this.key(entry.account, scope);
334
+ if (this.storage?.compareAndSet) {
335
+ const revision = entry.revision;
336
+ entry = { ...entry, revision: (revision ?? 0) + 1 };
337
+ if (!await this.storage.compareAndSet(key, revision, structuredClone(entry)))
338
+ throw new AccountSetupRequiredError("Account storage revision changed. Reload and reconcile before retrying.");
339
+ } else {
340
+ await this.storage?.set(key, structuredClone(entry));
341
+ }
260
342
  if (scope === this.scope()) this.remember(key, entry);
261
- await this.storage?.set(key, structuredClone(entry));
262
343
  }
263
- async record(ref, row, scope, preserveTerms = false, isCurrent = () => true) {
344
+ async record(ref, row, scope, preserveTerms = false, isCurrent = () => true, expectedRevision) {
264
345
  const entry = {
265
346
  account: structuredClone(ref),
266
347
  status: row.status,
267
348
  ...row.account_id && { account_id: row.account_id }
268
349
  };
269
350
  const prior = await this.get(ref);
351
+ if (expectedRevision !== void 0 && prior?.revision !== expectedRevision) return;
270
352
  if (scope !== this.scope() || !isCurrent()) return;
271
353
  const idRef = row.account_id && !("account_id" in ref) ? { account_id: row.account_id } : void 0;
272
354
  const priorId = idRef ? await this.get(idRef) : void 0;
@@ -288,6 +370,7 @@ class BuyerAccountRegistry {
288
370
  );
289
371
  }
290
372
  }
373
+ entry.revision = prior?.revision;
291
374
  if (preserveTerms && prior?.setupTerms) entry.setupTerms = prior.setupTerms;
292
375
  if ("account_id" in ref) {
293
376
  if (prior?.aliases) entry.aliases = prior.aliases;
@@ -297,6 +380,7 @@ class BuyerAccountRegistry {
297
380
  await this.write(
298
381
  {
299
382
  account: idRef,
383
+ revision: priorId?.revision,
300
384
  account_id: row.account_id,
301
385
  status: row.status,
302
386
  aliases: [...aliases.values()],
@@ -312,7 +396,7 @@ class BuyerAccountRegistry {
312
396
  });
313
397
  return next;
314
398
  }
315
- async observeSync(refs, rows, dryRun = false, observationEpoch) {
399
+ async observeSync(refs, rows, dryRun = false, observationEpoch, expectedRevision) {
316
400
  if (dryRun) return;
317
401
  const scope = this.scope();
318
402
  return this.enqueue(async () => {
@@ -344,7 +428,7 @@ class BuyerAccountRegistry {
344
428
  repairedBinding = true;
345
429
  }
346
430
  const isCurrent = () => scope === this.scope() && this.observationIsCurrent(row.account_id, rowEpoch, scope);
347
- await this.record(ref, row, scope, false, isCurrent);
431
+ await this.record(ref, row, scope, false, isCurrent, expectedRevision);
348
432
  if (!isCurrent() || repairedBinding) continue;
349
433
  for (const id of [row.account_id])
350
434
  if (id) {