@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.
- package/dist/lib/core/SingleAgentClient.d.mts +4 -1
- package/dist/lib/core/SingleAgentClient.d.ts +4 -1
- package/dist/lib/core/SingleAgentClient.js +25 -4
- package/dist/lib/core/SingleAgentClient.mjs +25 -4
- package/dist/lib/core/TaskExecutor.d.mts +3 -1
- package/dist/lib/core/TaskExecutor.d.ts +3 -1
- package/dist/lib/core/TaskExecutor.js +15 -11
- package/dist/lib/core/TaskExecutor.mjs +15 -11
- package/dist/lib/core/buyer-account-registry.d.mts +30 -2
- package/dist/lib/core/buyer-account-registry.d.ts +30 -2
- package/dist/lib/core/buyer-account-registry.js +126 -42
- package/dist/lib/core/buyer-account-registry.mjs +127 -43
- package/dist/lib/index.d.mts +3 -2
- package/dist/lib/index.d.ts +3 -2
- package/dist/lib/index.js +9 -0
- package/dist/lib/index.mjs +12 -1
- package/dist/lib/net/agent-transport-fetch.d.mts +4 -0
- package/dist/lib/net/agent-transport-fetch.d.ts +4 -0
- package/dist/lib/net/agent-transport-fetch.js +18 -5
- package/dist/lib/net/agent-transport-fetch.mjs +17 -5
- package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
- package/dist/lib/signing/agent-resolver/errors.d.mts +2 -0
- package/dist/lib/signing/agent-resolver/errors.d.ts +2 -0
- package/dist/lib/signing/agent-resolver/errors.js +6 -0
- package/dist/lib/signing/agent-resolver/errors.mjs +6 -0
- package/dist/lib/signing/agent-resolver/fetch-helpers.d.mts +9 -0
- package/dist/lib/signing/agent-resolver/fetch-helpers.d.ts +9 -0
- package/dist/lib/signing/agent-resolver/fetch-helpers.js +35 -1
- package/dist/lib/signing/agent-resolver/fetch-helpers.mjs +38 -2
- package/dist/lib/signing/agent-resolver/legacy-brand.js +1 -1
- package/dist/lib/signing/agent-resolver/legacy-brand.mjs +2 -2
- package/dist/lib/signing/agent-resolver/resolve-agent.d.mts +1 -1
- package/dist/lib/signing/agent-resolver/resolve-agent.d.ts +1 -1
- package/dist/lib/signing/agent-resolver/resolve-agent.js +47 -5
- package/dist/lib/signing/agent-resolver/resolve-agent.mjs +55 -6
- package/dist/lib/signing/brand-jwks.d.mts +2 -0
- package/dist/lib/signing/brand-jwks.d.ts +2 -0
- package/dist/lib/signing/brand-jwks.js +10 -2
- package/dist/lib/signing/brand-jwks.mjs +10 -2
- package/dist/lib/signing/errors.d.mts +4 -2
- package/dist/lib/signing/errors.d.ts +4 -2
- package/dist/lib/signing/errors.js +3 -1
- package/dist/lib/signing/errors.mjs +3 -1
- package/dist/lib/signing/verifier.js +1 -1
- package/dist/lib/signing/verifier.mjs +1 -1
- package/dist/lib/signing/webhook-verifier.js +2 -2
- package/dist/lib/signing/webhook-verifier.mjs +3 -3
- package/dist/lib/testing/storyboard/validations.d.mts +1 -1
- package/dist/lib/testing/storyboard/validations.d.ts +1 -1
- package/dist/lib/types/accept-proposal.d.ts +19 -1
- package/dist/lib/types/buy-products.d.ts +19 -1
- package/dist/lib/types/check-governance.d.ts +19 -1
- package/dist/lib/types/comply-test-controller.d.ts +19 -1
- package/dist/lib/types/control-media-buy.d.ts +19 -1
- package/dist/lib/types/core.generated.d.mts +14 -1
- package/dist/lib/types/core.generated.d.ts +14 -1
- package/dist/lib/types/create-media-buy.d.ts +14 -1
- package/dist/lib/types/get-media-buys.d.ts +14 -1
- package/dist/lib/types/get-products.d.ts +19 -1
- package/dist/lib/types/list-products.d.ts +19 -1
- package/dist/lib/types/refine-proposals.d.ts +19 -1
- package/dist/lib/types/request-proposals.d.ts +19 -1
- package/dist/lib/types/schemas.generated.d.ts +12 -3
- package/dist/lib/types/schemas.generated.js +4 -1
- package/dist/lib/types/schemas.generated.mjs +4 -1
- package/dist/lib/types/tools.generated.d.mts +14 -1
- package/dist/lib/types/tools.generated.d.ts +14 -1
- package/dist/lib/types/update-media-buy.d.ts +14 -1
- 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/dist/lib/webhooks/index.d.mts +24 -0
- package/dist/lib/webhooks/index.d.ts +24 -0
- package/dist/lib/webhooks/index.js +50 -24
- package/dist/lib/webhooks/index.mjs +49 -24
- package/dist/lib/wholesale-feed-sync/index.d.mts +2 -0
- package/dist/lib/wholesale-feed-sync/index.d.ts +2 -0
- package/dist/lib/wholesale-feed-sync/index.js +7 -0
- package/dist/lib/wholesale-feed-sync/index.mjs +4 -0
- package/dist/lib/wholesale-feed-sync/mirror.d.mts +97 -0
- package/dist/lib/wholesale-feed-sync/mirror.d.ts +97 -0
- package/dist/lib/wholesale-feed-sync/mirror.js +350 -0
- package/dist/lib/wholesale-feed-sync/mirror.mjs +322 -0
- package/dist/lib/wholesale-feed-sync/sync.d.mts +12 -29
- package/dist/lib/wholesale-feed-sync/sync.d.ts +12 -29
- package/dist/lib/wholesale-feed-sync/sync.js +153 -297
- package/dist/lib/wholesale-feed-sync/sync.mjs +158 -297
- package/docs/README.md +6 -0
- package/docs/TYPE-SUMMARY.md +2 -2
- package/docs/guides/BUYER-STORAGE.md +3 -0
- package/docs/guides/FIRST-CALL-TO-A-SELLER.md +3 -1
- package/docs/guides/account-resolution.md +46 -10
- package/docs/llms.txt +2 -2
- package/docs/migration-14.0-to-14.1.md +85 -0
- package/docs/migration-14.x-rc-worksheet.md +4 -4
- package/docs/migration-agent-resolution-3.3.md +5 -3
- package/docs/recipes/verifying-inbound-webhooks.md +4 -0
- package/package.json +2 -1
- package/skills/adcp-brand.previous/SKILL.md +0 -200
- package/skills/adcp-creative.previous/SKILL.md +0 -305
- package/skills/adcp-governance.previous/SKILL.md +0 -566
- package/skills/adcp-measurement.previous/SKILL.md +0 -136
- package/skills/adcp-media-buy.previous/SKILL.md +0 -556
- package/skills/adcp-si.previous/SKILL.md +0 -206
- 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,
|
|
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.
|
|
49
|
-
this.
|
|
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(
|
|
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
|
|
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.
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
await this.
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
status: "failed_provisioning",
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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)
|
|
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,
|
|
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.
|
|
26
|
-
this.
|
|
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(
|
|
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
|
|
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.
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
await this.
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
status: "failed_provisioning",
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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)
|
|
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) {
|