@salesforce/lds-worker-api 1.465.0 → 1.467.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.
@@ -4282,7 +4282,7 @@
4282
4282
  }
4283
4283
  callbacks.push(callback);
4284
4284
  }
4285
- // version: 1.465.0-91ef58ca05
4285
+ // version: 1.467.0-4f065d2a2a
4286
4286
 
4287
4287
  // TODO [TD-0081508]: once that TD is fulfilled we can probably change this file
4288
4288
  function instrumentAdapter$1(createFunction, _metadata) {
@@ -5326,7 +5326,7 @@
5326
5326
  const { apiFamily, name } = metadata;
5327
5327
  return createGraphQLWireAdapterConstructor$1(adapter, `${apiFamily}.${name}`, luvio, astResolver);
5328
5328
  }
5329
- // version: 1.465.0-91ef58ca05
5329
+ // version: 1.467.0-4f065d2a2a
5330
5330
 
5331
5331
  function isSupportedEntity(_objectApiName) {
5332
5332
  return true;
@@ -14379,7 +14379,7 @@
14379
14379
  return undefined;
14380
14380
  }
14381
14381
 
14382
- const VERSION$10$2 = "3f49d751896cf66e6e29788d8880e2cc";
14382
+ const VERSION$10$2 = "c42f4294edf6efc71f5ff9b9adf922e8";
14383
14383
  const RepresentationType$G = 'PlatformActionRepresentation';
14384
14384
  function keyBuilder$1P$1(luvio, config) {
14385
14385
  return keyPrefix$3 + '::' + RepresentationType$G + ':' + config.externalId + ':' + (config.relatedSourceObject === null ? '' : config.relatedSourceObject) + ':' + (config.relatedListRecordId === null ? '' : config.relatedListRecordId);
@@ -14439,6 +14439,11 @@
14439
14439
  name: 'lwcComponent',
14440
14440
  kind: 'Scalar'
14441
14441
  },
14442
+ {
14443
+ name: 'modalSize',
14444
+ kind: 'Scalar',
14445
+ required: false
14446
+ },
14442
14447
  {
14443
14448
  name: 'primaryColor',
14444
14449
  kind: 'Scalar'
@@ -14539,6 +14544,19 @@
14539
14544
  if (!(existing_lwcComponent === incoming_lwcComponent)) {
14540
14545
  return false;
14541
14546
  }
14547
+ const existing_modalSize = existing.modalSize;
14548
+ const incoming_modalSize = incoming.modalSize;
14549
+ // if at least one of these optionals is defined
14550
+ if (existing_modalSize !== undefined || incoming_modalSize !== undefined) {
14551
+ // if one of these is not defined we know the other is defined and therefore
14552
+ // not equal
14553
+ if (existing_modalSize === undefined || incoming_modalSize === undefined) {
14554
+ return false;
14555
+ }
14556
+ if (!(existing_modalSize === incoming_modalSize)) {
14557
+ return false;
14558
+ }
14559
+ }
14542
14560
  const existing_primaryColor = existing.primaryColor;
14543
14561
  const incoming_primaryColor = incoming.primaryColor;
14544
14562
  if (!(existing_primaryColor === incoming_primaryColor)) {
@@ -32553,7 +32571,7 @@
32553
32571
  throttle(60, 60000, setupNotifyAllListRecordUpdateAvailable(luvio));
32554
32572
  throttle(60, 60000, setupNotifyAllListInfoSummaryUpdateAvailable(luvio));
32555
32573
  });
32556
- // version: 1.465.0-4371689bb7
32574
+ // version: 1.467.0-049a8b6027
32557
32575
 
32558
32576
  var draftQueueMaxRetryAttemptsGate = {
32559
32577
  isOpen: function (e) {
@@ -44085,6 +44103,15 @@
44085
44103
  return luvioDocumentNode;
44086
44104
  }
44087
44105
 
44106
+ var parentRelationshipPrefetchKillSwitch = {
44107
+ isOpen: function (e) {
44108
+ return e.fallback;
44109
+ },
44110
+ hasError: function () {
44111
+ return !0;
44112
+ },
44113
+ };
44114
+
44088
44115
  var graphqlRelationshipFieldsPerf = {
44089
44116
  isOpen: function (e) {
44090
44117
  return e.fallback;
@@ -48038,6 +48065,14 @@
48038
48065
  * Triggered after the Draft Queue state changes
48039
48066
  */
48040
48067
  DraftQueueEventType["QueueStateChanged"] = "state";
48068
+ /**
48069
+ * Triggered when batch-journal crash recovery (B2, Performance Upload path)
48070
+ * could not complete: a maybe-committed all-or-none batch's members could not
48071
+ * be durably quarantined this session, a startup reconcile failed, or a
48072
+ * resolved journal row could not be cleared. Carries only a bounded reason
48073
+ * code (no ids/messages) so it is safe to surface to o11y.
48074
+ */
48075
+ DraftQueueEventType["BatchRecoveryFailed"] = "batchRecoveryFailed";
48041
48076
  })(DraftQueueEventType || (DraftQueueEventType = {}));
48042
48077
  var QueueOperationType;
48043
48078
  (function (QueueOperationType) {
@@ -48247,6 +48282,8 @@
48247
48282
  mergeActions: () => {
48248
48283
  throw Error('mergeActions not supported for custom actions');
48249
48284
  },
48285
+ getDraftIdsFromAction: (action) => [action.targetId],
48286
+ getRedirectMappings: () => undefined,
48250
48287
  };
48251
48288
  }
48252
48289
 
@@ -48330,6 +48367,13 @@
48330
48367
  const handler = customActionHandler(executor, id, this);
48331
48368
  return this.addHandler(handler);
48332
48369
  }
48370
+ /**
48371
+ * True when a handler for `id` is registered on this queue — i.e. this queue owns
48372
+ * (and drains) the actions carrying that handlerId.
48373
+ */
48374
+ isHandlerRegistered(id) {
48375
+ return this.handlers[id] !== undefined;
48376
+ }
48333
48377
  getQueueState() {
48334
48378
  return this.state;
48335
48379
  }
@@ -48354,6 +48398,11 @@
48354
48398
  }
48355
48399
  this.retryIntervalMilliseconds = 0;
48356
48400
  this.state = DraftQueueState.Started;
48401
+ // Stuck-queue detection: a live upload keeps the queue Started (filtered by the early
48402
+ // return above), so a stale uploadingActionId here strands processNextAction() on it.
48403
+ if (this.uploadingActionId !== undefined && this.logger) {
48404
+ this.logger.logWarning(`Draft queue restarting with action still marked uploading (stale uploadingActionId=${this.uploadingActionId}); possible abandoned-upload stuck queue`);
48405
+ }
48357
48406
  await this.notifyChangedListeners({
48358
48407
  type: DraftQueueEventType.QueueStateChanged,
48359
48408
  state: this.state,
@@ -48884,239 +48933,1814 @@
48884
48933
  }
48885
48934
  }
48886
48935
 
48887
- function clone$1(obj) {
48888
- return parse$6(stringify$6(obj));
48936
+ /**
48937
+ * Narrows a peer DraftQueue to one that also participates in cross-queue
48938
+ * coordination. A peer may be a NimbusDraftQueue (gate-ON on-device path),
48939
+ * which implements DraftQueue but NOT CrossQueueCoordination, so this check
48940
+ * is load-bearing, not defensive.
48941
+ */
48942
+ function isCrossQueueCoordination(queue) {
48943
+ const candidate = queue;
48944
+ return (typeof candidate.applyDraftIdRedirects === 'function' &&
48945
+ typeof candidate.resumeDeferredActions === 'function' &&
48946
+ typeof candidate.getInFlightRewritableDraftIds === 'function' &&
48947
+ typeof candidate.getOwnedQueueActions === 'function');
48889
48948
  }
48890
-
48891
- const DRAFT_ACTION_KEY_JUNCTION = '__DraftAction__';
48892
- function buildDraftDurableStoreKey(recordKey, draftActionId) {
48893
- return `${recordKey}${DRAFT_ACTION_KEY_JUNCTION}${draftActionId}`;
48949
+ /**
48950
+ * Maximum number of draft actions to combine into a single UI-API Batch
48951
+ * request when batching is enabled. Tunable; chosen as a conservative starting
48952
+ * point that bounds payload size and retry cost while still amortizing TTFB
48953
+ * across many actions.
48954
+ */
48955
+ const MAX_BATCH_SIZE$1 = 25;
48956
+ /**
48957
+ * How many times clearBatchJournalIfSupported retries a failed journal clear
48958
+ * before giving up and escalating the log line. Bounded so a permanently-failing
48959
+ * store can't spin. A leftover row is mainly a scale concern (unbounded restart
48960
+ * reconcile); it can also cause a rare, fail-safe over-quarantine — a definitively
48961
+ * rolled-back batch's still-Pending siblings surface as commit-ambiguous on the
48962
+ * next restart (never a replay, since the envelope records no rolled-back outcome).
48963
+ * A small bound suffices for the spin guard; a durable rolled-back/resolved
48964
+ * tombstone is the follow-up to close the over-quarantine before queueBatching is
48965
+ * enabled (4077120435).
48966
+ */
48967
+ const CLEAR_JOURNAL_MAX_ATTEMPTS = 3;
48968
+ /**
48969
+ * Diagnostic log line for the local-experimental batching path. Routes through
48970
+ * JSLoggerPlugin.logInfo so messages surface in the iOS device log, each line
48971
+ * prefixed with `Batch `. Silently no-ops outside the LWR JSC environment.
48972
+ */
48973
+ function batchLog$1(line) {
48974
+ try {
48975
+ globalThis.__nimbus?.plugins?.JSLoggerPlugin?.logInfo(`Batch ${line}`);
48976
+ }
48977
+ catch {
48978
+ // debug-only; never fail real work because of logging
48979
+ }
48894
48980
  }
48895
48981
  /**
48896
- * Implements a write-through InMemoryStore for Drafts, storing all drafts in a
48897
- * in-memory store with a write through to the DurableStore.
48898
- *
48899
- * Before any reads or writes come in from the draft queue, we need to revive the draft
48900
- * queue into memory. During this initial revive, any writes are queued up and operated on the
48901
- * queue once it's in memory. Similarly any reads are delayed until the queue is in memory.
48902
- *
48982
+ * Bounded, scrubbed rendering of a caught value for the device log. Emits only
48983
+ * the error constructor NAME (e.g. "TypeError"), never the free-text message,
48984
+ * which can embed record ids (URL path segments) or server field-validation
48985
+ * strings. Mirrors the device-log PII policy in metrics.ts
48986
+ * (reportDraftActionUploadError): bounded metadata only; full detail belongs on
48987
+ * the structured, scrubbed o11y path (4065667273).
48903
48988
  */
48904
- class DurableDraftStore {
48905
- constructor(durableStore, instrumentation, useSnapshot = true) {
48906
- this.draftStore = {};
48907
- // Snapshot: holds deep clones of each draft, refreshed at write time.
48908
- // getAllDrafts serves from this snapshot with zero deep clones per read.
48909
- this.cleanSnapshot = {};
48910
- // queue of writes that were made during the initial sync
48911
- this.writeQueue = [];
48912
- this.durableStore = durableStore;
48913
- this.instrumentation = instrumentation;
48914
- this.useSnapshot = useSnapshot;
48915
- this.resyncDraftStore();
48989
+ function errName(e) {
48990
+ try {
48991
+ const name = e?.name;
48992
+ if (typeof name !== 'string' || name.length === 0) {
48993
+ return 'Error';
48994
+ }
48995
+ // Strip everything outside printable ASCII, then bound the length. The
48996
+ // id/free-text PII vector is already closed by reading `.name` (a
48997
+ // constructor name) and never `.message` (see above) — but a thrown object
48998
+ // can carry an arbitrary `name` (and reading it can trip a getter), so a
48999
+ // newline or control character could still break device-log-line integrity
49000
+ // and a pathological length could blow up a log entry. This drops control
49001
+ // sequences and bounds length; it does NOT attempt to strip a printable
49002
+ // identifier, since nothing in this path populates `.name` with one
49003
+ // (4077120502).
49004
+ const cleaned = name.replace(/[^\x20-\x7E]/g, '');
49005
+ if (cleaned.length === 0) {
49006
+ return 'Error';
49007
+ }
49008
+ return cleaned.length > 64 ? cleaned.slice(0, 64) : cleaned;
48916
49009
  }
48917
- // When no instrumentation is injected the promise is returned as-is (zero overhead).
48918
- timeDurableOp(op, segment, run) {
48919
- const onDurableOperation = this.instrumentation?.onDurableOperation;
48920
- if (onDurableOperation === undefined) {
48921
- return run();
48922
- }
48923
- const start = Date.now();
48924
- return run().finally(() => {
48925
- onDurableOperation(op, segment, Date.now() - start);
48926
- });
49010
+ catch {
49011
+ // Reading `.name` threw (a hostile getter). Scrubbing a value for the
49012
+ // log must never itself throw and take down the caller's error path.
49013
+ return 'Error';
48927
49014
  }
48928
- writeAction(action) {
48929
- const addAction = () => {
48930
- const { id, tag } = action;
48931
- this.draftStore[id] = action;
48932
- this.cleanSnapshot[id] = clone$1(action);
48933
- const durableEntryKey = buildDraftDurableStoreKey(tag, id);
48934
- const entry = {
48935
- data: action,
48936
- };
48937
- const entries = { [durableEntryKey]: entry };
48938
- return this.timeDurableOp('setEntries', DRAFT_SEGMENT, () => this.durableStore.setEntries(entries, DRAFT_SEGMENT));
49015
+ }
49016
+ /**
49017
+ * Builds the queue operations needed to rewrite every queued action whose
49018
+ * basePath/body text contains a draft id from `redirects`. If a queued
49019
+ * action's tag matches a redirect's draftKey, the action is deleted and
49020
+ * re-added at the canonical key. Pure function — no draft-store side
49021
+ * effects. Used by PerformanceDraftQueue.applyDraftIdRedirects to propagate
49022
+ * a peer queue's POST completion across queues.
49023
+ */
49024
+ function rewriteQueueActionsForRedirects(queue, redirects) {
49025
+ const queueOperations = [];
49026
+ if (redirects.length === 0) {
49027
+ return queueOperations;
49028
+ }
49029
+ for (let i = 0, len = queue.length; i < len; i++) {
49030
+ const queueAction = queue[i];
49031
+ const data = queueAction.data;
49032
+ if (data === undefined ||
49033
+ data === null ||
49034
+ typeof data.method !== 'string' ||
49035
+ data.body === undefined) {
49036
+ continue;
49037
+ }
49038
+ let basePath = data.basePath || '';
49039
+ let stringifiedBody = JSON.stringify(data.body || '');
49040
+ let mutated = false;
49041
+ let updatedTag;
49042
+ let updatedTargetId;
49043
+ for (const r of redirects) {
49044
+ if (basePath.indexOf(r.draftId) >= 0 || stringifiedBody.indexOf(r.draftId) >= 0) {
49045
+ const re = new RegExp(r.draftId, 'g');
49046
+ basePath = basePath.replace(re, r.canonicalId);
49047
+ stringifiedBody = stringifiedBody.replace(re, r.canonicalId);
49048
+ mutated = true;
49049
+ }
49050
+ if (queueAction.tag === r.draftKey) {
49051
+ updatedTag = r.canonicalKey;
49052
+ updatedTargetId = r.canonicalId;
49053
+ }
49054
+ }
49055
+ if (!mutated) {
49056
+ continue;
49057
+ }
49058
+ const baseUpdated = {
49059
+ ...queueAction,
49060
+ data: { ...data, basePath, body: JSON.parse(stringifiedBody) },
48939
49061
  };
48940
- return this.enqueueAction(addAction);
49062
+ if (updatedTag !== undefined && updatedTargetId !== undefined) {
49063
+ queueOperations.push({ type: QueueOperationType.Delete, id: queueAction.id });
49064
+ queueOperations.push({
49065
+ type: QueueOperationType.Add,
49066
+ action: { ...baseUpdated, tag: updatedTag, targetId: updatedTargetId },
49067
+ });
49068
+ }
49069
+ else {
49070
+ queueOperations.push({
49071
+ type: QueueOperationType.Update,
49072
+ id: queueAction.id,
49073
+ action: baseUpdated,
49074
+ });
49075
+ }
48941
49076
  }
48942
- getAllDrafts() {
48943
- const waitForOngoingSync = this.syncPromise || Promise.resolve();
48944
- return waitForOngoingSync.then(() => {
48945
- const actionArray = [];
48946
- if (this.useSnapshot) {
48947
- const { cleanSnapshot } = this;
48948
- const keys$1 = keys$4(cleanSnapshot);
48949
- for (let i = 0, len = keys$1.length; i < len; i++) {
48950
- const key = keys$1[i];
48951
- // Return shallow spread of snapshot entries so top-level caller mutations
48952
- // (e.g., .status =) don't persist into the snapshot. The snapshot itself
48953
- // is a deep clone of draftStore, so no nested mutation reaches draftStore.
48954
- actionArray.push({ ...cleanSnapshot[key] });
48955
- }
49077
+ return queueOperations;
49078
+ }
49079
+ /**
49080
+ * The Performance Upload draft queue. Extends {@link DurableDraftQueue} — the
49081
+ * base class is byte-identical to today's shipped serial queue — and adds the
49082
+ * multi-candidate concurrent scheduler, Set-based in-flight tracking,
49083
+ * cross-queue redirect propagation, and UI-API batching. Only constructed on
49084
+ * the gate-ON code path; gate-off customers never run any of this.
49085
+ */
49086
+ class PerformanceDraftQueue extends DurableDraftQueue {
49087
+ constructor(draftStore, { getPeerQueues = () => [], concurrency = 1, strictOrdering = true, queueBatching = false, } = {}) {
49088
+ super(draftStore);
49089
+ this.uploadingActionIds = new Set();
49090
+ // Reentrancy guard for processNextAction, which dispatches into free concurrency
49091
+ // slots with eligible actions. It is invoked un-awaited from several sites
49092
+ // (actionCompleted, applyDraftIdRedirects, resumeDeferredActions, enqueue)
49093
+ // and awaits mid-pass to read peer state, so without this two passes could
49094
+ // clear the concurrency guard, select the same action, and dispatch it
49095
+ // twice. `isDispatching` is set synchronously before any await; a concurrent
49096
+ // entrant records `redispatchRequested` and bails, and the active pass re-runs
49097
+ // to honor the missed wakeup.
49098
+ this.isDispatching = false;
49099
+ this.redispatchRequested = false;
49100
+ // Monotonic counter used to mint a unique batchId per dispatched batch
49101
+ // (combined with the head action id). Deterministic and dependency-free —
49102
+ // no Date/random — and unique within a session; across restarts a journal
49103
+ // is either cleared on completion or consumed by reconcile, so ids never
49104
+ // collide meaningfully.
49105
+ this.batchSequence = 0;
49106
+ // Guards the one-time batch-completion journal reconcile so it runs on the
49107
+ // first startQueue of the process (crash recovery) and not on every
49108
+ // stop/start cycle. Holds the in-flight reconcile promise rather than a
49109
+ // set-first boolean: startQueue can be called concurrently at launch (the
49110
+ // first call's reconcile awaits before super.startQueue() marks the queue
49111
+ // Started, so a second call still passes the base state guard). A boolean
49112
+ // set before the await would let that second call skip reconcile and begin
49113
+ // dispatching — re-batching a journaled, committed, not-yet-resolved member
49114
+ // and dropping its idempotency key. Every caller awaits the same promise,
49115
+ // so no dispatch begins until the members are surfaced as errors.
49116
+ this.reconcileBatchJournalsPromise = undefined;
49117
+ // Actions currently in retry backoff. A failed-but-retryable action stays
49118
+ // Pending in the store, so without this a re-entrant dispatch pass (a peer
49119
+ // kick or a sibling completion) could re-select and re-dispatch it before
49120
+ // its backoff timer fires. Populated in actionFailed's retry branch and
49121
+ // cleared per-action when that action's own backoff timer elapses, plus on
49122
+ // permanent failure / removal.
49123
+ this.retryingActionIds = new Set();
49124
+ // Members of a maybe-committed batch whose commit status could NOT be moved
49125
+ // to a durable Error this session (both the atomic completion and the
49126
+ // commit-ambiguous quarantine write failed — the store was rejecting writes
49127
+ // right after the server committed). They are still Pending in the store, so
49128
+ // without this guard a later startQueue / retryAllErrors / reconnect would
49129
+ // re-select them and re-batch keyless, replaying a maybe-committed write
49130
+ // (Option A forbids that). Excluded from dispatch (isDispatchable and the
49131
+ // single-action candidate loop) until they are durably resolved. In-memory
49132
+ // only: it bridges the current session; a restart clears it and the retained
49133
+ // batch journal is surfaced by reconcile before dispatch instead.
49134
+ this.commitAmbiguousUnresolvedIds = new Set();
49135
+ // Per-action retry backoff state. The serial base tracks a single
49136
+ // `timeoutHandler` + single `retryIntervalMilliseconds`, which conflate
49137
+ // concurrent retries: two actions failing at different times would overwrite
49138
+ // each other's timer (leaking the earlier one) and share one exponential
49139
+ // interval, so the first timer to fire would unpark every retrying action on
49140
+ // whichever backoff happened to be shortest. A concurrent queue instead
49141
+ // parks each action under its OWN timer and its OWN growing interval, keyed
49142
+ // by action id, so each retries on its own schedule independent of siblings.
49143
+ this.retryTimers = new Map();
49144
+ this.retryIntervals = new Map();
49145
+ // Draft ids of in-flight/just-completed POSTs whose redirects have not yet
49146
+ // been applied to peer queues. actionCompleted resolves a completing POST
49147
+ // out of the store (completeAction) BEFORE propagateRedirectsToPeers
49148
+ // fire-and-forgets the rewrite to peers, so between those two points
49149
+ // getInFlightRewritableDraftIds would no longer see the id in the store and
49150
+ // a peer could dispatch a queued action still carrying the stale draft id.
49151
+ // Ids are retained here before completeAction and released only once the
49152
+ // peer applyDraftIdRedirects calls settle; getInFlightRewritableDraftIds
49153
+ // unions this set so peers keep deferring across the gap.
49154
+ this.redirectingDraftIds = new Set();
49155
+ this.getPeerQueues = getPeerQueues;
49156
+ this.concurrency = concurrency;
49157
+ this.strictOrdering = strictOrdering;
49158
+ this.queueBatching = queueBatching;
49159
+ }
49160
+ /**
49161
+ * One-time batch-journal crash recovery (B2). A journal entry that survives
49162
+ * a restart means a batch was dispatched but its members were never
49163
+ * confirmed removed — the app may have died after the server committed the
49164
+ * all-or-none request. Any journaled member still Pending might therefore be
49165
+ * committed already, and the batch envelope carried no server-visible
49166
+ * idempotency key, so it must not be re-batched OR re-dispatched (either
49167
+ * would risk replaying a committed write). Surface every still-Pending member
49168
+ * as a commit-ambiguous error for manual resolution (Option A). The journal
49169
+ * entry is cleared only once its members are resolved (surfaced or already
49170
+ * gone), so a failure to record the error leaves the entry for the next
49171
+ * startup.
49172
+ */
49173
+ reconcileBatchJournalsOnce() {
49174
+ // Memoize the in-flight promise so concurrent startQueue calls all await
49175
+ // the same reconcile (see reconcileBatchJournalsPromise). Assigning
49176
+ // before the first await is the point: a later caller reuses this
49177
+ // promise instead of racing past a boolean that was flipped before the
49178
+ // pinning work ran.
49179
+ //
49180
+ // Reconcile must fail CLOSED (4065665821): if it rejects, startQueue does
49181
+ // NOT dispatch. But the failure must be RETRYABLE — clear the memo on
49182
+ // rejection so the next startQueue re-runs the reconcile rather than
49183
+ // reusing a cached rejection and never recovering. Success stays memoized
49184
+ // (runs at most once per process).
49185
+ if (this.reconcileBatchJournalsPromise === undefined) {
49186
+ this.reconcileBatchJournalsPromise = this.runBatchJournalReconcile().catch((e) => {
49187
+ this.reconcileBatchJournalsPromise = undefined;
49188
+ // Single choke point for every reconcile failure (journal read
49189
+ // error, resolve failure, or missing journal support): surface one
49190
+ // bounded o11y signal before re-throwing so startQueue still fails
49191
+ // closed. Fire-and-forget — the rejection must propagate unchanged.
49192
+ this.signalBatchRecoveryFailed('reconcile-failed');
49193
+ throw e;
49194
+ });
49195
+ }
49196
+ return this.reconcileBatchJournalsPromise;
49197
+ }
49198
+ async runBatchJournalReconcile() {
49199
+ // Nothing journals when batching is off, so there is nothing to reconcile.
49200
+ if (!this.queueBatching) {
49201
+ return;
49202
+ }
49203
+ const store = this.draftStore;
49204
+ if (typeof store.readRecoverableBatchJournals !== 'function' ||
49205
+ typeof store.clearBatchJournal !== 'function' ||
49206
+ typeof store.writeBatchJournal !== 'function') {
49207
+ // Batching is ON but the store cannot journal. Dispatching would batch
49208
+ // keyless writes with no crash backstop, so refuse to start rather than
49209
+ // silently run unprotected (4065665821). Throwing keeps startQueue
49210
+ // stopped; a store misconfigured this way will keep failing, which is
49211
+ // the intended fail-closed outcome.
49212
+ // eslint-disable-next-line @salesforce/lds/no-error-in-production
49213
+ throw new Error('PerformanceDraftQueue: batching enabled but the DraftStore lacks batch-journal support');
49214
+ }
49215
+ // A read failure THROWS here (not swallowed): it propagates through
49216
+ // reconcileBatchJournalsOnce so startQueue stays stopped and retries,
49217
+ // rather than proceeding to dispatch a member of a batch that may have
49218
+ // already committed (4065665821). Only RECOVERABLE journals are read —
49219
+ // crash residue from a dead process. A journal stamped with the live
49220
+ // session's token is a batch still in flight (possibly on a sibling queue
49221
+ // sharing this store); reconciling it would quarantine live members out
49222
+ // from under their owner, so the store filters those out.
49223
+ const journals = await store.readRecoverableBatchJournals();
49224
+ if (journals.length === 0) {
49225
+ return;
49226
+ }
49227
+ for (const journal of journals) {
49228
+ // A journaled member that never completed was in-flight when the
49229
+ // process died: the batch MAY have committed, and the envelope carried
49230
+ // no server-visible idempotency key, so we do NOT re-batch or
49231
+ // re-dispatch it. Surface every still-Pending member as commit-ambiguous
49232
+ // for manual resolution (Option A), then clear the journal once resolved.
49233
+ // A resolve failure THROWS (via reconcileBatchJournalsOnce) so startup
49234
+ // stays stopped-but-retryable and the journal is retained.
49235
+ //
49236
+ // errorAmbiguousActions reads its own fresh draft snapshot per call
49237
+ // rather than sharing one across this loop: each iteration awaits
49238
+ // completeAction and listener callbacks, which can delete a member
49239
+ // (listener re-entrancy, or a concurrent removeDraftAction/deleteByTag),
49240
+ // and a snapshot held across the loop would let a later journal
49241
+ // resurrect that just-deleted member as an Error draft via the blind
49242
+ // Update in completeAction (4077120311). A fresh read per journal costs
49243
+ // O(journals × drafts), acceptable on this once-per-process
49244
+ // crash-recovery path.
49245
+ const resolved = await this.errorAmbiguousActions(journal.memberIds);
49246
+ if (resolved) {
49247
+ await this.clearBatchJournalIfSupported(journal.batchId);
48956
49248
  }
48957
49249
  else {
48958
- // Killswitch (W-23515682): original pre-fix behavior — deep-clone every draft on
48959
- // every read so the internal draft store is never exposed. O(N) clones per read.
48960
- const { draftStore } = this;
48961
- const keys$1 = keys$4(draftStore);
48962
- for (let i = 0, len = keys$1.length; i < len; i++) {
48963
- actionArray.push(clone$1(draftStore[keys$1[i]]));
48964
- }
49250
+ // eslint-disable-next-line @salesforce/lds/no-error-in-production
49251
+ throw new Error(`PerformanceDraftQueue: batch journal ${journal.batchId} could not be reconciled`);
48965
49252
  }
48966
- return actionArray;
48967
- });
49253
+ }
48968
49254
  }
48969
- deleteDraft(id) {
48970
- const deleteAction = () => {
48971
- const draft = this.draftStore[id];
48972
- if (draft !== undefined) {
48973
- delete this.draftStore[id];
48974
- delete this.cleanSnapshot[id];
48975
- const durableKey = buildDraftDurableStoreKey(draft.tag, draft.id);
48976
- return this.timeDurableOp('evictEntries', DRAFT_SEGMENT, () => this.durableStore.evictEntries([durableKey], DRAFT_SEGMENT));
49255
+ // Overrides DurableDraftQueue.getQueueActions rather than reusing it: the
49256
+ // base marks the single in-flight action from its private `uploadingActionId`
49257
+ // (serial path), while the concurrent path tracks many in-flight ids in
49258
+ // `uploadingActionIds`. The base field is private, so the uploading check
49259
+ // cannot be shared without a base refactor; the rest of the body (sort) is
49260
+ // deliberately kept identical. If the base sort changes, mirror it here.
49261
+ async getQueueActions() {
49262
+ const drafts = (await this.draftStore.getAllDrafts());
49263
+ const queue = [];
49264
+ drafts.forEach((draft) => {
49265
+ if (this.uploadingActionIds.has(draft.id)) {
49266
+ draft.status = DraftActionStatus.Uploading;
49267
+ }
49268
+ queue.push(draft);
49269
+ });
49270
+ return queue.sort((a, b) => {
49271
+ const aTime = parseInt(a.id, 10);
49272
+ const bTime = parseInt(b.id, 10);
49273
+ // safety check
49274
+ if (isNaN(aTime)) {
49275
+ return 1;
48977
49276
  }
49277
+ if (isNaN(bTime)) {
49278
+ return -1;
49279
+ }
49280
+ return aTime - bTime;
49281
+ });
49282
+ }
49283
+ async getOwnedQueueActions() {
49284
+ const all = await this.getQueueActions();
49285
+ return all.filter((a) => this.handlers[a.handler] !== undefined);
49286
+ }
49287
+ /** Draft ids `action` creates — the same set the dispatch-time dependency check uses. */
49288
+ getCreatedDraftIds(action) {
49289
+ const ids = new Set();
49290
+ this.collectDraftIds(action, ids);
49291
+ return Array.from(ids);
49292
+ }
49293
+ /** Owned actions whose basePath or body references any of `draftIds`. */
49294
+ async getOwnedActionsReferencing(draftIds) {
49295
+ const ids = new Set(draftIds);
49296
+ const owned = await this.getOwnedQueueActions();
49297
+ return owned.filter((action) => this.actionReferencesAny(action, ids));
49298
+ }
49299
+ async actionCompleted(action) {
49300
+ return this.workerPool.push({
49301
+ workFn: async () => {
49302
+ const handler = this.getHandler(action.handler);
49303
+ // Compute the redirects this completion produces BEFORE
49304
+ // completeAction resolves the POST out of the store, and retain
49305
+ // the draft ids they resolve. completeAction below removes the
49306
+ // completing POST from the store, so getInFlightRewritableDraftIds
49307
+ // would stop reporting its draft id — but the fire-and-forget
49308
+ // propagation to peers hasn't run yet, so a peer could dispatch a
49309
+ // queued action still carrying that stale draft id. Retaining the
49310
+ // ids (released only once the peer rewrites settle, below) keeps
49311
+ // peers deferring across that gap. Only bother when there are
49312
+ // peers to propagate to — with none, there is nothing to retain
49313
+ // for and no handler consultation to pay for.
49314
+ const peers = this.getPeerQueues().filter(isCrossQueueCoordination);
49315
+ const redirects = peers.length > 0 && handler.getRedirectMappings !== undefined
49316
+ ? handler.getRedirectMappings(action)
49317
+ : undefined;
49318
+ const retainedDraftIds = this.retainRedirectingDraftIds(redirects);
49319
+ try {
49320
+ let queue = await this.getQueueActions();
49321
+ const queueOperations = handler.getQueueOperationsForCompletingDrafts(queue, action);
49322
+ // write the queue operations to the store prior to ingesting the result
49323
+ await this.draftStore.completeAction(queueOperations);
49324
+ await this.finalizeCompletedAction(action, handler, queueOperations);
49325
+ if (this.state === DraftQueueState.Started) {
49326
+ this.processNextAction();
49327
+ }
49328
+ // Propagate redirects to peer queues; fire-and-forget. Awaiting
49329
+ // peer.applyDraftIdRedirects from inside this worker callback
49330
+ // produces a cross-queue worker-pool deadlock when the peer is
49331
+ // also propagating back to this queue. Release the retained ids
49332
+ // only once every peer's rewrite settles.
49333
+ this.propagateRedirectsToPeers(redirects, () => this.releaseRedirectingDraftIds(retainedDraftIds));
49334
+ }
49335
+ catch (e) {
49336
+ // Any step above can throw before propagateRedirectsToPeers
49337
+ // schedules the release. Release the retained ids here so they
49338
+ // don't leak into redirectingDraftIds permanently (which would
49339
+ // make getInFlightRewritableDraftIds report them forever and
49340
+ // strand peers deferring on them). Must be a catch, not a
49341
+ // finally — on the success path release happens only after
49342
+ // peers apply the rewrites, so releasing here too early would
49343
+ // reopen the cross-queue stale-id race.
49344
+ this.releaseRedirectingDraftIds(retainedDraftIds);
49345
+ throw e;
49346
+ }
49347
+ },
49348
+ });
49349
+ }
49350
+ /**
49351
+ * Shared in-memory finalization for a completed action, run after its durable
49352
+ * removal: ingest the response and apply side effects (handleActionCompleted),
49353
+ * drop any retry backoff state, release the in-flight claim, and notify
49354
+ * listeners. Used by both the single-action completion path (actionCompleted)
49355
+ * and the atomic batch completion path (completeBatchAtomically), which each
49356
+ * wrap it in their own error handling.
49357
+ */
49358
+ async finalizeCompletedAction(action, handler, queueOperations) {
49359
+ await handler.handleActionCompleted(action, queueOperations, values$2(this.handlers));
49360
+ // A completed action is no longer backing off: drop its timer, interval,
49361
+ // and parked flag.
49362
+ this.clearActionRetry(action.id);
49363
+ this.uploadingActionIds.delete(action.id);
49364
+ await this.notifyChangedListeners({
49365
+ type: DraftQueueEventType.ActionCompleted,
49366
+ action,
49367
+ draftCount: this.draftStore.getCount(),
49368
+ });
49369
+ }
49370
+ async actionFailed(action, retry, retryDelayInMs, actionDataChanged) {
49371
+ if (actionDataChanged === true) {
49372
+ await this.draftStore.writeAction({
49373
+ ...action,
49374
+ status: DraftActionStatus.Pending,
49375
+ });
49376
+ }
49377
+ this.uploadingActionIds.delete(action.id);
49378
+ if (retry && this.state !== DraftQueueState.Stopped) {
49379
+ // Park the action so a re-entrant dispatch pass (peer kick / sibling
49380
+ // completion) can't re-select it while it's Pending in the store and
49381
+ // waiting on its backoff timer. Cleared when this action's own timer
49382
+ // fires.
49383
+ this.retryingActionIds.add(action.id);
49384
+ await this.scheduleActionRetry(action.id, retryDelayInMs);
49385
+ return;
49386
+ }
49387
+ // Permanent failure. Awaiting handleServerError (rather than returning
49388
+ // it) guarantees the errored action is persisted before this method
49389
+ // resolves, so any dispatch pass on the next tick observes the error
49390
+ // state instead of racing the write.
49391
+ if (isDraftError(action)) {
49392
+ this.clearActionRetry(action.id);
49393
+ await this.handleServerError(action, action.error);
49394
+ // A permanently-failed POST never resolves the draft ids it would
49395
+ // have created, so peers that deferred actions on those ids would
49396
+ // otherwise stall until an unrelated success on a later tick wakes
49397
+ // them. Wake them explicitly so they can re-evaluate (and surface
49398
+ // their own error, rather than hang).
49399
+ this.getPeerQueues()
49400
+ .filter(isCrossQueueCoordination)
49401
+ .forEach((peer) => peer.resumeDeferredActions());
49402
+ }
49403
+ }
49404
+ async handleBatch(actions) {
49405
+ const handler = this.getHandler(actions[0].handler);
49406
+ if (handler === undefined) {
49407
+ return Promise.reject(`No handler for ${actions[0].handler}.`);
49408
+ }
49409
+ if (handler.handleBatchActions === undefined) {
49410
+ return Promise.reject(`Handler ${actions[0].handler} does not support batching.`);
49411
+ }
49412
+ const batchIds = actions.map((a) => a.id);
49413
+ // B2: buffer the members the handler reports completed instead of
49414
+ // removing each from the store as its callback fires. On success they
49415
+ // are removed in ONE atomic durable write (completeBatchAtomically), so
49416
+ // there is no partial-removal window that a crash — or a completion
49417
+ // callback throwing mid-loop — could strand. The stranded-member bug it
49418
+ // prevents: a batch commits all-or-none on the server exactly once, but
49419
+ // members removed one-at-a-time can leave committed members Pending,
49420
+ // which restart (or the handler's broad retry) would re-batch WITHOUT
49421
+ // their per-action idempotency keys (the batch envelope drops them),
49422
+ // replaying a committed PATCH's automation or failing a committed DELETE.
49423
+ const completed = [];
49424
+ const batchActionCompleted = (action) => {
49425
+ completed.push(action);
48978
49426
  return Promise.resolve();
48979
49427
  };
48980
- return this.enqueueAction(deleteAction);
48981
- }
48982
- deleteByTag(tag) {
48983
- const deleteAction = () => {
48984
- const { draftStore } = this;
48985
- const keys$1 = keys$4(draftStore);
48986
- const durableKeys = [];
48987
- for (let i = 0, len = keys$1.length; i < len; i++) {
48988
- const key = keys$1[i];
48989
- const action = draftStore[key];
48990
- if (action.tag === tag) {
48991
- delete draftStore[action.id];
48992
- delete this.cleanSnapshot[action.id];
48993
- durableKeys.push(buildDraftDurableStoreKey(action.tag, action.id));
49428
+ // Mint a unique id for this batch and journal its members BEFORE
49429
+ // dispatch (B2). If the process dies — or the atomic removal below fails
49430
+ // — between the server committing the request and the members being
49431
+ // removed, the next startQueue reconcile finds this journal and surfaces
49432
+ // any still-Pending member as commit-ambiguous (Option A: not auto-replayed,
49433
+ // because the batch envelope carries no server-visible idempotency key).
49434
+ // Journal support is optional; a store without it (never the gate-ON path)
49435
+ // skips this.
49436
+ const batchId = `${actions[0].id}:${(this.batchSequence += 1)}`;
49437
+ try {
49438
+ await this.writeBatchJournalIfSupported(batchId, batchIds);
49439
+ }
49440
+ catch (e) {
49441
+ // Fail CLOSED (4048061181): without a durable journal a mid-flight crash
49442
+ // would revive these members Pending with no record that they were
49443
+ // in-flight, so they would be re-batched — replaying a maybe-committed
49444
+ // write. Do NOT dispatch the batch. Release every member's in-flight
49445
+ // claim and dispatch the head alone via the single-action path, which
49446
+ // sends its idempotency key on the first attempt and so is replay-safe;
49447
+ // the remaining members re-arm and drain one-at-a-time single-action
49448
+ // while journaling is unavailable.
49449
+ batchLog$1(`journal write failed, single-action fallback batchId=${batchId} err=${errName(e)}`);
49450
+ for (const id of batchIds) {
49451
+ this.uploadingActionIds.delete(id);
49452
+ }
49453
+ this.uploadingActionIds.add(actions[0].id);
49454
+ return this.handle(actions[0]);
49455
+ }
49456
+ // Shared post-dispatch bookkeeping: release every batch member's uploading
49457
+ // claim, then handle the batch journal + still-Pending members per the
49458
+ // outcome, and issue the single trailing processNextAction. Two shapes:
49459
+ //
49460
+ // - Commit-AMBIGUOUS (result === NETWORK_ERROR, or a promise rejection):
49461
+ // the batch was dispatched but its outcome could not be confirmed
49462
+ // (network throw, empty-body 2xx, misaligned response, a retryable
49463
+ // 5xx/408/dropped-connection or generic-400 envelope that may have
49464
+ // committed with the 2xx lost — see executeBatch / classifyBatchFailure).
49465
+ // A rejection likewise implies a dispatch attempt whose cleanup then
49466
+ // threw, so it is treated as ambiguous too (it must NOT be read as
49467
+ // "nothing was sent"). The all-or-none request MAY have committed, and
49468
+ // the batch envelope carries no server-visible idempotency key, so a
49469
+ // re-send (batch OR serial) could replay a committed write. Do NOT
49470
+ // auto-replay (Option A): surface every still-Pending member as an error
49471
+ // for manual resolution, then clear the now-resolved journal (its job —
49472
+ // catching a mid-flight crash before we could record the ambiguity — is
49473
+ // done once the members carry the durable error).
49474
+ //
49475
+ // - Definitive non-success (any other non-success result): a terminal or
49476
+ // rolled-back envelope (server-generated 4xx, 429) or a partial
49477
+ // all-or-none failure — the server rolled the whole batch back or
49478
+ // rejected it before commit, so nothing committed and re-batching on
49479
+ // retry is replay-safe. The journal is stale: clear it. The handler
49480
+ // routed the offender (terminal, or parked in retryingActionIds) and
49481
+ // deliberately left innocent rolled-back siblings Pending/unparked (B5);
49482
+ // we only re-notify still-Pending members to clear the "uploading" UI
49483
+ // state.
49484
+ const finalize = async (result, rejected) => {
49485
+ for (const id of batchIds) {
49486
+ this.uploadingActionIds.delete(id);
49487
+ }
49488
+ if (rejected || result === ProcessActionResult.NETWORK_ERROR) {
49489
+ // Commit-ambiguous: surface members for manual resolution, then
49490
+ // clear the journal ONLY once the ambiguity is durably recorded on
49491
+ // every still-Pending member. If a member could not be moved to
49492
+ // Error, retain the journal so the next startQueue reconcile can
49493
+ // surface it — clearing now would strand it Pending with no backstop
49494
+ // and let a restart re-batch it (the replay Option A prevents).
49495
+ const resolved = await this.errorAmbiguousActions(batchIds);
49496
+ if (resolved) {
49497
+ await this.clearBatchJournalIfSupported(batchId);
49498
+ }
49499
+ else {
49500
+ // errorAmbiguousActions could not durably move the members to
49501
+ // Error (its bulk write failed); it stopped the queue and
49502
+ // quarantined them in-memory, and the journal is retained for
49503
+ // the next reconcile. Surface the failure as a bounded signal.
49504
+ await this.signalBatchRecoveryFailed('quarantine-failed');
48994
49505
  }
48995
49506
  }
48996
- return this.timeDurableOp('evictEntries', DRAFT_SEGMENT, () => this.durableStore.evictEntries(durableKeys, DRAFT_SEGMENT));
49507
+ else if (result !== ProcessActionResult.ACTION_SUCCEEDED) {
49508
+ // Definitively rolled back: journal is stale, clear it.
49509
+ await this.clearBatchJournalIfSupported(batchId);
49510
+ const queue = await this.getQueueActions();
49511
+ for (const id of batchIds) {
49512
+ const action = queue.find((a) => a.id === id);
49513
+ if (action && action.status === DraftActionStatus.Pending) {
49514
+ await this.notifyChangedListeners({
49515
+ type: DraftQueueEventType.ActionUpdated,
49516
+ action,
49517
+ draftCount: this.draftStore.getCount(),
49518
+ });
49519
+ }
49520
+ }
49521
+ }
49522
+ if (this.state === DraftQueueState.Started) {
49523
+ this.processNextAction();
49524
+ }
49525
+ return result;
48997
49526
  };
48998
- return this.enqueueAction(deleteAction);
49527
+ return handler
49528
+ .handleBatchActions(actions, batchActionCompleted, this.actionFailed.bind(this))
49529
+ .then(async (result) => {
49530
+ batchLog$1(`dispatch done result=${result} size=${actions.length}`);
49531
+ if (result === ProcessActionResult.ACTION_SUCCEEDED) {
49532
+ // Atomically remove every committed member and clear the
49533
+ // journal. Never rejects — it protects members itself if
49534
+ // the durable write fails (see below).
49535
+ await this.completeBatchAtomically(batchId, completed);
49536
+ }
49537
+ return finalize(result, false);
49538
+ }, (e) => {
49539
+ batchLog$1(`dispatch rejected err=${errName(e)} size=${actions.length}`);
49540
+ return finalize(ProcessActionResult.ACTION_ERRORED, true);
49541
+ });
48999
49542
  }
49000
- completeAction(queueOperations) {
49001
- const action = () => {
49002
- const durableStoreOperations = [];
49003
- const { draftStore } = this;
49004
- for (let i = 0, len = queueOperations.length; i < len; i++) {
49005
- const operation = queueOperations[i];
49006
- if (operation.type === QueueOperationType.Delete) {
49007
- const action = draftStore[operation.id];
49008
- if (action !== undefined) {
49009
- delete draftStore[operation.id];
49010
- delete this.cleanSnapshot[operation.id];
49011
- const key = buildDraftDurableStoreKey(action.tag, action.id);
49012
- durableStoreOperations.push({
49013
- ids: [key],
49014
- type: 'evictEntries',
49015
- segment: DRAFT_SEGMENT,
49543
+ /**
49544
+ * Atomically completes every successfully-uploaded member of a batch (B2).
49545
+ *
49546
+ * The server commits an all-or-none batch exactly once; the old path removed
49547
+ * members one-at-a-time, so a crash — or a completion callback throwing
49548
+ * mid-loop — could strand already-committed members Pending, which restart
49549
+ * (or the handler's broad retry) would re-batch WITHOUT their idempotency
49550
+ * keys and replay. This removes ALL members in ONE durable batchOperations
49551
+ * (the base completeAction applies the whole QueueOperation[] in a single
49552
+ * transaction), so there is no partial-removal state, then clears the
49553
+ * pre-dispatch journal. If any step throws after the server committed, this
49554
+ * fails CLOSED (4065665227): it quarantines every still-Pending member as a
49555
+ * commit-ambiguous error and retains the journal unless every member is
49556
+ * durably resolved, so the trailing finalize's claim release cannot let
49557
+ * collectBatch re-batch a keyless, maybe-committed member (Option A — the
49558
+ * batch envelope drops the idempotency key, so a re-send could replay a
49559
+ * committed write). Whatever the journal does not resolve this session is
49560
+ * surfaced by the next startQueue reconcile. The per-member cache ingest and
49561
+ * listener notification run afterward: they are best-effort cache updates, so
49562
+ * a throw there cannot corrupt the already-correct durable state.
49563
+ *
49564
+ * Batched members are UPDATE/DELETE only (collectBatch excludes POSTs), so
49565
+ * none mint server ids — getRedirectMappings returns undefined and each
49566
+ * member's queue ops reduce to a single self-delete, independent of the
49567
+ * others. That is why one queue snapshot yields correct ops for every member
49568
+ * and why the cross-queue redirect machinery on the single-action completion
49569
+ * path is not needed here. Never rejects.
49570
+ */
49571
+ completeBatchAtomically(batchId, completed) {
49572
+ return this.workerPool.push({
49573
+ workFn: async () => {
49574
+ if (completed.length === 0) {
49575
+ await this.clearBatchJournalIfSupported(batchId);
49576
+ return;
49577
+ }
49578
+ const memberIds = completed.map((a) => a.id);
49579
+ let perMember;
49580
+ try {
49581
+ // The setup (snapshot, handler lookup, op construction) is
49582
+ // inside the try too (4065666785): it runs in the success
49583
+ // callback, so a throw here would otherwise reject this task,
49584
+ // bypass finalize, and strand every member marked Uploading for
49585
+ // the session. Every exit below either removes the members or
49586
+ // quarantines them, so this method still never rejects.
49587
+ const queue = await this.getQueueActions();
49588
+ perMember = [];
49589
+ const allOps = [];
49590
+ for (const action of completed) {
49591
+ const handler = this.getHandler(action.handler);
49592
+ const ops = handler.getQueueOperationsForCompletingDrafts(queue, action);
49593
+ perMember.push({ action, ops, handler });
49594
+ for (let i = 0, len = ops.length; i < len; i++) {
49595
+ allOps.push(ops[i]);
49596
+ }
49597
+ }
49598
+ // ONE transaction removes every committed member — no
49599
+ // partial-removal window (B2).
49600
+ await this.draftStore.completeAction(allOps);
49601
+ await this.clearBatchJournalIfSupported(batchId);
49602
+ }
49603
+ catch (e) {
49604
+ // The server committed the all-or-none request but the local
49605
+ // completion could not be applied (snapshot read, op
49606
+ // construction, or the durable write failed). Do NOT assume the
49607
+ // members were evicted from memory: completeAction is part of the
49608
+ // public DraftStore contract and an implementation may reject
49609
+ // without removing its Pending rows (4065665227). If we simply
49610
+ // returned, the trailing finalize would release the uploading
49611
+ // claims and re-arm dispatch, letting collectBatch re-batch a
49612
+ // keyless, maybe-committed member and replay it. Fail CLOSED:
49613
+ // quarantine every still-Pending member as commit-ambiguous
49614
+ // BEFORE finalize releases the claims. treatMissingAsResolved is
49615
+ // false because the base store evicts a member from memory before
49616
+ // its durable row settles, so an absent member's durable state is
49617
+ // unknown here — leave the journal so the next startQueue reconcile
49618
+ // (which reads freshly-synced durable state) is the authority
49619
+ // (4065666287). Only clear the journal once every member is durably
49620
+ // resolved.
49621
+ batchLog$1(`atomic completion failed batchId=${batchId} err=${errName(e)}`);
49622
+ let resolved = false;
49623
+ try {
49624
+ resolved = await this.errorAmbiguousActions(memberIds, {
49625
+ treatMissingAsResolved: false,
49016
49626
  });
49017
49627
  }
49628
+ catch (qe) {
49629
+ batchLog$1(`atomic completion quarantine failed batchId=${batchId} err=${errName(qe)}`);
49630
+ }
49631
+ if (resolved) {
49632
+ await this.clearBatchJournalIfSupported(batchId);
49633
+ }
49634
+ else {
49635
+ // Not resolved: errorAmbiguousActions either failed its
49636
+ // durable write (it already stopped the queue + quarantined)
49637
+ // or threw before quarantining (e.g. its snapshot read
49638
+ // failed). Handle both uniformly — force the queue
49639
+ // non-dispatchable and quarantine every member in-memory so
49640
+ // the trailing finalize's unconditional claim release cannot
49641
+ // let collectBatch re-batch a keyless, maybe-committed member
49642
+ // and replay it (Option A). The journal is deliberately NOT
49643
+ // cleared; the next process-restart reconcile is the backstop.
49644
+ this.state = DraftQueueState.Error;
49645
+ for (const id of memberIds) {
49646
+ this.commitAmbiguousUnresolvedIds.add(id);
49647
+ }
49648
+ // The server committed but the members could not be durably
49649
+ // quarantined this session — surface a bounded o11y signal.
49650
+ await this.signalBatchRecoveryFailed('quarantine-failed');
49651
+ }
49652
+ return;
49018
49653
  }
49019
- else {
49020
- const { action } = operation;
49021
- const key = buildDraftDurableStoreKey(action.tag, action.id);
49022
- draftStore[action.id] = action;
49023
- this.cleanSnapshot[action.id] = clone$1(action);
49024
- durableStoreOperations.push({
49025
- type: 'setEntries',
49026
- segment: DRAFT_SEGMENT,
49027
- entries: {
49028
- [key]: {
49029
- data: operation.action,
49030
- },
49031
- },
49654
+ // Durable state is now correct. Per-member in-memory
49655
+ // finalization (cache ingest, retry-state clear, listener
49656
+ // notify) is best-effort; isolate each so one failure can't skip
49657
+ // the others or reject this task (which would surface as an
49658
+ // unhandled dispatch rejection).
49659
+ let finalizeFailed = false;
49660
+ for (const { action, ops, handler } of perMember) {
49661
+ try {
49662
+ await this.finalizeCompletedAction(action, handler, ops);
49663
+ }
49664
+ catch (e) {
49665
+ // Still release the in-flight claim so the id isn't stuck
49666
+ // marked uploading.
49667
+ this.uploadingActionIds.delete(action.id);
49668
+ finalizeFailed = true;
49669
+ batchLog$1(`post-completion finalize failed id=${action.id} err=${errName(e)}`);
49670
+ }
49671
+ }
49672
+ if (finalizeFailed) {
49673
+ // The write committed and was durably removed exactly once — no
49674
+ // replay/duplication risk — but some member's local cache/notify
49675
+ // work was lost with no retry path. Emit one bounded structured
49676
+ // signal per batch (o11y counter) so this is monitorable beyond
49677
+ // the per-member device log (4077120522).
49678
+ await this.signalBatchRecoveryFailed('finalize-failed');
49679
+ }
49680
+ },
49681
+ });
49682
+ }
49683
+ /**
49684
+ * Surfaces the given still-Pending actions as commit-ambiguous errors (Option
49685
+ * A). A batch that MAY have committed cannot be safely re-sent because the batch
49686
+ * envelope drops the per-action idempotency keys, so instead of auto-replaying
49687
+ * (batch OR serial — both would present keys the server never saw on the
49688
+ * original batch) we move each member to Error for manual resolution. Used by
49689
+ * finalize on a commit-ambiguous dispatch outcome and by startQueue reconcile
49690
+ * for members a prior batch journaled but never completed (in-flight at a
49691
+ * crash). Reads the raw store status (not the uploading overlay); a member
49692
+ * already Error is left as-is (counts as resolved).
49693
+ *
49694
+ * All still-Pending members are moved to Error in ONE completeAction
49695
+ * transaction rather than one writeAction (and one getQueueActions snapshot)
49696
+ * per member (4065667688): a 25-member batch is a single durable
49697
+ * batchOperations, not 25 writes plus 25 snapshots. The in-memory effects
49698
+ * handleServerError applies per member (drop the uploading claim + retry
49699
+ * state, set queue Error state, notify listeners) are mirrored here once for
49700
+ * the whole set.
49701
+ *
49702
+ * A fresh draft snapshot (id → action) is read per call — never shared across
49703
+ * the reconcile loop — because each call awaits completeAction and listener
49704
+ * callbacks that can delete a member, and a stale shared snapshot would let a
49705
+ * later call resurrect a just-deleted member as an Error draft (4077120311).
49706
+ * `options.treatMissingAsResolved` controls how an id absent from the snapshot
49707
+ * is scored: true (default) when the snapshot authoritatively reflects durable
49708
+ * state (restart reconcile runs against a freshly-synced store, so absent means
49709
+ * genuinely gone); false in the live post-commit failure path, where the base
49710
+ * store evicts a member from memory BEFORE its durable row settles (4065666287),
49711
+ * so an absent member's durable state is unknown and must be treated as
49712
+ * unresolved to keep the journal. An id already in commitAmbiguousUnresolvedIds
49713
+ * is likewise treated as unresolved regardless of its (possibly stale) in-memory
49714
+ * status: its durable Error write failed earlier this session, so its durable
49715
+ * row is still Pending and the journal must be retained for the next
49716
+ * fresh-process reconcile (4077120459). Returns true only when every targeted id
49717
+ * is resolved.
49718
+ */
49719
+ async errorAmbiguousActions(ids, options) {
49720
+ if (ids.length === 0) {
49721
+ return true;
49722
+ }
49723
+ const treatMissingAsResolved = options?.treatMissingAsResolved ?? true;
49724
+ // Read a fresh snapshot and index it by id so each targeted id is an O(1)
49725
+ // lookup instead of a full scan. Deliberately NOT shared across the
49726
+ // reconcile loop: a snapshot held across this call's awaited completeAction
49727
+ // and listener callbacks could go stale and let a later call resurrect a
49728
+ // just-deleted member as an Error draft (4077120311).
49729
+ const draftsById = new Map((await this.draftStore.getAllDrafts()).map((d) => [d.id, d]));
49730
+ const ops = [];
49731
+ const erroredActions = [];
49732
+ const seen = new Set();
49733
+ // An id whose durable Error write already failed this session (still in the
49734
+ // quarantine set) is NOT resolved even if its in-memory status now reads
49735
+ // Error: that in-memory Error is the write-through cache mutation that was
49736
+ // never rolled back after its durable batchOperations rejected, so the
49737
+ // durable row is still Pending. Retain the journal (return false) so the
49738
+ // next fresh-process reconcile — running against a re-synced store — is the
49739
+ // authority, rather than clearing the journal on unpersisted state and
49740
+ // re-batching the still-Pending row keyless after a later restart
49741
+ // (4077120459).
49742
+ let quarantinedUnresolved = false;
49743
+ for (const id of ids) {
49744
+ if (seen.has(id)) {
49745
+ continue;
49746
+ }
49747
+ if (this.commitAmbiguousUnresolvedIds.has(id)) {
49748
+ quarantinedUnresolved = true;
49749
+ continue;
49750
+ }
49751
+ const action = draftsById.get(id);
49752
+ if (action === undefined) {
49753
+ continue;
49754
+ }
49755
+ seen.add(id);
49756
+ if (action.status !== DraftActionStatus.Pending) {
49757
+ continue;
49758
+ }
49759
+ const errored = {
49760
+ ...action,
49761
+ status: DraftActionStatus.Error,
49762
+ error: PerformanceDraftQueue.COMMIT_AMBIGUOUS_ERROR,
49763
+ metadata: action.metadata || {},
49764
+ };
49765
+ ops.push({ type: QueueOperationType.Update, id: action.id, action: errored });
49766
+ erroredActions.push(errored);
49767
+ }
49768
+ if (ops.length > 0) {
49769
+ try {
49770
+ // One durable transaction moves every still-Pending member to Error.
49771
+ await this.draftStore.completeAction(ops);
49772
+ }
49773
+ catch (e) {
49774
+ // Nothing was durably moved: the members are still Pending and MAY
49775
+ // have committed. Fail CLOSED — stop the queue and quarantine them
49776
+ // in-memory so no later dispatch (a re-arm, retryAllErrors, or a
49777
+ // reconnect-driven startQueue) re-batches them keyless and replays a
49778
+ // committed write (Option A). Report unresolved so the caller retains
49779
+ // the journal for the next reconcile.
49780
+ batchLog$1(`commit-ambiguous bulk error failed count=${ops.length} err=${errName(e)}`);
49781
+ this.state = DraftQueueState.Error;
49782
+ for (const errored of erroredActions) {
49783
+ this.commitAmbiguousUnresolvedIds.add(errored.id);
49784
+ }
49785
+ return false;
49786
+ }
49787
+ // Durable Error state is written; mirror handleServerError's in-memory
49788
+ // effects once for the whole set rather than per member. Batched members
49789
+ // are UPDATE/DELETE (never POST — collectBatch excludes POSTs), so no
49790
+ // peer deferred on a draft id these would have created; the peer-wake
49791
+ // that actionFailed does for permanently-failed POSTs is not needed.
49792
+ this.state = DraftQueueState.Error;
49793
+ for (const errored of erroredActions) {
49794
+ this.uploadingActionIds.delete(errored.id);
49795
+ // Durably Error now (already excluded from dispatch by status), so
49796
+ // drop any prior in-memory quarantine — otherwise a later
49797
+ // retryAllErrors that re-pends this action would find it wrongly
49798
+ // blocked from ever dispatching again.
49799
+ this.commitAmbiguousUnresolvedIds.delete(errored.id);
49800
+ this.clearActionRetry(errored.id);
49801
+ try {
49802
+ await this.notifyChangedListeners({
49803
+ type: DraftQueueEventType.ActionFailed,
49804
+ action: errored,
49805
+ draftCount: this.draftStore.getCount(),
49032
49806
  });
49033
49807
  }
49808
+ catch (e) {
49809
+ batchLog$1(`commit-ambiguous notify failed id=${errored.id} err=${errName(e)}`);
49810
+ }
49034
49811
  }
49035
- return this.timeDurableOp('batchOperations', DRAFT_SEGMENT, () => this.durableStore.batchOperations(durableStoreOperations));
49036
- };
49037
- return this.enqueueAction(action);
49038
- }
49039
- getCount() {
49040
- return keys$4(this.draftStore).length;
49812
+ }
49813
+ if (quarantinedUnresolved) {
49814
+ return false;
49815
+ }
49816
+ if (treatMissingAsResolved) {
49817
+ return true;
49818
+ }
49819
+ for (let i = 0, len = ids.length; i < len; i++) {
49820
+ if (!seen.has(ids[i])) {
49821
+ return false;
49822
+ }
49823
+ }
49824
+ return true;
49041
49825
  }
49042
49826
  /**
49043
- * Runs a write operation against the draft store, if the initial
49044
- * revive is still in progress, the action gets enqueued to run once the
49045
- * initial revive is complete
49046
- * @param action
49047
- * @returns a promise that is resolved once the action has run
49827
+ * Feature-detecting wrapper: journals a batch's members if the store supports
49828
+ * it. A store without journal support (never the gate-ON path) resolves as a
49829
+ * no-op. When supported, the write REJECTION propagates so handleBatch can fail
49830
+ * CLOSED (4048061181) — the journal is the only record that these members were
49831
+ * in-flight, so a failed write must abort the batch dispatch rather than send
49832
+ * unrecorded.
49048
49833
  */
49049
- enqueueAction(action) {
49050
- const { syncPromise, writeQueue: pendingMerges } = this;
49051
- // if the initial sync is done and existing operations have been run, no need to queue, just run
49052
- if (syncPromise === undefined && pendingMerges.length === 0) {
49053
- return action();
49834
+ writeBatchJournalIfSupported(batchId, memberIds) {
49835
+ const store = this.draftStore;
49836
+ if (typeof store.writeBatchJournal !== 'function') {
49837
+ return Promise.resolve();
49054
49838
  }
49055
- const deferred = new Promise((resolve, reject) => {
49056
- this.writeQueue.push(() => {
49057
- return action()
49058
- .then((x) => {
49059
- resolve(x);
49060
- })
49061
- .catch((err) => reject(err));
49839
+ return store.writeBatchJournal(batchId, memberIds);
49840
+ }
49841
+ /**
49842
+ * Surfaces a batch-journal crash-recovery failure (B2) to listeners as a
49843
+ * bounded structured signal, so instrumentDraftQueue can map it to o11y —
49844
+ * these paths are otherwise visible only in device logs. Carries ONLY the
49845
+ * reason code and the queue depth (never ids or messages — device-log PII
49846
+ * policy). Best-effort and never rejects: a listener throw must not break the
49847
+ * recovery path, so it is swallowed to the batch log.
49848
+ */
49849
+ async signalBatchRecoveryFailed(reason) {
49850
+ try {
49851
+ await this.notifyChangedListeners({
49852
+ type: DraftQueueEventType.BatchRecoveryFailed,
49853
+ reason,
49854
+ draftCount: this.draftStore.getCount(),
49062
49855
  });
49856
+ }
49857
+ catch (e) {
49858
+ batchLog$1(`batch recovery signal failed reason=${reason} err=${errName(e)}`);
49859
+ }
49860
+ }
49861
+ /**
49862
+ * Feature-detecting wrapper: clears a batch journal entry if the store
49863
+ * supports it. A resolved batch's journal is stale, but a swallowed clear
49864
+ * failure would leave a recovery row behind on EVERY dispatch, so restart
49865
+ * reconcile grows unbounded (4065667688). Retry the clear a bounded number of
49866
+ * times, and if it still fails escalate the log line (leftover=true) so a
49867
+ * persistently-failing clear is greppable rather than silent. Never rejects:
49868
+ * callers (finalize, completeBatchAtomically) treat the clear as best-effort
49869
+ * cleanup of an already-resolved journal — a leftover row is caught by the
49870
+ * next reconcile, it does not risk a replay.
49871
+ */
49872
+ async clearBatchJournalIfSupported(batchId) {
49873
+ const store = this.draftStore;
49874
+ if (typeof store.clearBatchJournal !== 'function') {
49875
+ return;
49876
+ }
49877
+ for (let attempt = 1; attempt <= CLEAR_JOURNAL_MAX_ATTEMPTS; attempt++) {
49878
+ try {
49879
+ await store.clearBatchJournal(batchId);
49880
+ return;
49881
+ }
49882
+ catch (e) {
49883
+ const leftover = attempt === CLEAR_JOURNAL_MAX_ATTEMPTS;
49884
+ batchLog$1(`journal clear failed batchId=${batchId} attempt=${attempt} leftover=${leftover} err=${errName(e)}`);
49885
+ }
49886
+ }
49887
+ // Every attempt failed (the loop returns on success): the resolved journal
49888
+ // row leaks, so the next reconcile keeps re-reading it (unbounded growth).
49889
+ // Surface a bounded o11y signal — the device log alone is not enough.
49890
+ // Still never rejects: signalBatchRecoveryFailed swallows its own errors.
49891
+ await this.signalBatchRecoveryFailed('clear-exhausted');
49892
+ }
49893
+ /**
49894
+ * A queued action is eligible to be selected or batched only when it is
49895
+ * Pending in the store and this queue is neither already uploading it nor
49896
+ * holding it in retry backoff. A retrying action stays Pending in the
49897
+ * store, so without the retryingActionIds guard collectBatch would pull a
49898
+ * parked action into a batch before its backoff timer fires (C5).
49899
+ */
49900
+ isDispatchable(action) {
49901
+ return (action.status === DraftActionStatus.Pending &&
49902
+ !this.uploadingActionIds.has(action.id) &&
49903
+ !this.retryingActionIds.has(action.id) &&
49904
+ // A member left Pending because its commit-ambiguous quarantine could
49905
+ // not be durably written must never be re-batched keyless (Option A).
49906
+ !this.commitAmbiguousUnresolvedIds.has(action.id));
49907
+ }
49908
+ /**
49909
+ * Walks the queue from `headIndex` and returns the longest contiguous prefix
49910
+ * of actions that are eligible to batch together. Eligibility:
49911
+ * - same handler id as the head action
49912
+ * - dispatchable (Pending, not in-flight, not parked in retry backoff)
49913
+ * - not a POST/CREATE (POSTs are never batched — see BatchCutReason.PostBoundary)
49914
+ * - tag does not collide with any tag already in the batch
49915
+ * (UI-API Batch rejects two operations on the same record per request)
49916
+ * - body/basePath does not reference an unresolved draft id from a peer
49917
+ * queue's (or this queue's) pending POST (would dispatch with a draft id)
49918
+ * - targetId is not already being written by an in-flight request
49919
+ * (per-record serialization; the head is pre-checked by the caller,
49920
+ * but later members must be checked here too — see BatchCutReason.TargetInFlight)
49921
+ * - batch size capped at MAX_BATCH_SIZE
49922
+ */
49923
+ collectBatch(queue, headIndex, peerPendingPostDraftIds, inFlightTargetIds) {
49924
+ if (queue.length === 0 || headIndex >= queue.length) {
49925
+ return [];
49926
+ }
49927
+ const head = queue[headIndex];
49928
+ const handler = this.handlers[head.handler];
49929
+ // CREATEs are never batched (C1): a POST head falls back to single-action
49930
+ // dispatch, which preserves its per-action idempotency key.
49931
+ const headData = head.data;
49932
+ if (headData !== undefined && headData.method === 'post') {
49933
+ batchLog$1('head is POST — not batchable, fall back to single-action');
49934
+ return [head];
49935
+ }
49936
+ // The head carries semantics the batch envelope cannot represent (B1):
49937
+ // fall back to single-action dispatch so those semantics (e.g. a
49938
+ // conditional-update header) are preserved.
49939
+ if (handler !== undefined &&
49940
+ typeof handler.isActionBatchable === 'function' &&
49941
+ !handler.isActionBatchable(head)) {
49942
+ batchLog$1('head not batchable — fall back to single-action');
49943
+ return [head];
49944
+ }
49945
+ const batch = [head];
49946
+ const seenTags = new Set();
49947
+ seenTags.add(head.tag);
49948
+ let reason = "END_OF_QUEUE" /* BatchCutReason.EndOfQueue */;
49949
+ let detail = '';
49950
+ for (let i = headIndex + 1; i < queue.length; i++) {
49951
+ if (batch.length >= MAX_BATCH_SIZE$1) {
49952
+ reason = "MAX_BATCH_SIZE" /* BatchCutReason.MaxBatchSize */;
49953
+ break;
49954
+ }
49955
+ const candidate = queue[i];
49956
+ if (candidate.handler !== head.handler) {
49957
+ reason = "HANDLER_CHANGE" /* BatchCutReason.HandlerChange */;
49958
+ detail = `nextHandler=${candidate.handler}`;
49959
+ break;
49960
+ }
49961
+ if (!this.isDispatchable(candidate)) {
49962
+ reason = "NOT_PENDING" /* BatchCutReason.NotPending */;
49963
+ detail = `nextStatus=${candidate.status}`;
49964
+ break;
49965
+ }
49966
+ // Stop at any POST: CREATEs are excluded from batching (C1). Only
49967
+ // naturally-idempotent UPDATE/DELETE actions batch, so there are no
49968
+ // in-batch POST draft ids to track — the blocking set below is the
49969
+ // caller's peer/owned pending-POST set alone.
49970
+ const candData = candidate.data;
49971
+ if (candData !== undefined && candData.method === 'post') {
49972
+ reason = "POST_BOUNDARY" /* BatchCutReason.PostBoundary */;
49973
+ break;
49974
+ }
49975
+ // Candidate carries semantics the batch envelope cannot represent
49976
+ // (B1): end the batch here so it is dispatched via single-action
49977
+ // handleAction on a later pass rather than folded in and stripped.
49978
+ if (handler !== undefined &&
49979
+ typeof handler.isActionBatchable === 'function' &&
49980
+ !handler.isActionBatchable(candidate)) {
49981
+ reason = "NOT_BATCHABLE" /* BatchCutReason.NotBatchable */;
49982
+ break;
49983
+ }
49984
+ // Per-record serialization (see BatchCutReason.TargetInFlight): the
49985
+ // caller pre-checks the head; later members must be checked here too.
49986
+ if (candidate.targetId && inFlightTargetIds.has(candidate.targetId)) {
49987
+ reason = "TARGET_IN_FLIGHT" /* BatchCutReason.TargetInFlight */;
49988
+ // Log the cut position, NOT the targetId: a targetId is a record id
49989
+ // and even a 24-char suffix preserves the full 18-char id, which the
49990
+ // device-log PII policy (metrics.ts) forbids (4065667273). The
49991
+ // BatchCutReason already says why; the offset says where.
49992
+ detail = `atOffset=${i - headIndex}`;
49993
+ break;
49994
+ }
49995
+ if (seenTags.has(candidate.tag)) {
49996
+ reason = "TAG_COLLISION" /* BatchCutReason.TagCollision */;
49997
+ // Log the cut position, NOT the tag: the tag embeds the record id
49998
+ // (same PII concern as targetId above).
49999
+ detail = `atOffset=${i - headIndex}`;
50000
+ break;
50001
+ }
50002
+ // Block a candidate that references a draft id still unresolved on a
50003
+ // peer queue's (or this queue's) in-flight POST, minus the
50004
+ // candidate's own draft ids (an action mustn't self-block on its own
50005
+ // targetId).
50006
+ const candidateOwnIds = new Set();
50007
+ this.collectDraftIds(candidate, candidateOwnIds);
50008
+ const blockingIds = new Set(peerPendingPostDraftIds);
50009
+ for (const id of candidateOwnIds) {
50010
+ blockingIds.delete(id);
50011
+ }
50012
+ if (this.actionReferencesAny(candidate, blockingIds)) {
50013
+ reason = "REF_BLOCKED" /* BatchCutReason.RefBlocked */;
50014
+ break;
50015
+ }
50016
+ batch.push(candidate);
50017
+ seenTags.add(candidate.tag);
50018
+ }
50019
+ const queuedAfterBatch = queue.length - headIndex - batch.length;
50020
+ batchLog$1(`collected size=${batch.length} reason=${reason}` +
50021
+ ` queueRemaining=${queuedAfterBatch}` +
50022
+ (detail.length > 0 ? ` ${detail}` : ''));
50023
+ return batch;
50024
+ }
50025
+ /**
50026
+ * Returns all draft ids "created" by this action (i.e., ids that don't yet
50027
+ * exist on the server). Defaults to `[targetId]`; handlers may override
50028
+ * `getDraftIdsFromAction` to return additional ids (e.g., the CD composite
50029
+ * handler returns CD + CV + CDL).
50030
+ */
50031
+ collectDraftIds(action, into) {
50032
+ const handler = this.handlers[action.handler];
50033
+ if (handler !== undefined && typeof handler.getDraftIdsFromAction === 'function') {
50034
+ for (const id of handler.getDraftIdsFromAction(action)) {
50035
+ if (id !== undefined && id !== null && id !== '') {
50036
+ into.add(id);
50037
+ }
50038
+ }
50039
+ return;
50040
+ }
50041
+ if (action.targetId) {
50042
+ into.add(action.targetId);
50043
+ }
50044
+ }
50045
+ /**
50046
+ * Returns true if any of the given draft ids appears in the action's
50047
+ * basePath or stringified body. Mirrors the substring scan used by
50048
+ * `getQueueOperationsForCompletingDrafts` so blocking and rewriting agree.
50049
+ *
50050
+ * TODO [W-23515682]: this re-scans basePath/body on every dispatch pass.
50051
+ * The referenced-id set could instead be computed once and cached on the
50052
+ * DraftAction, recomputed only when rewriteQueueActionsForRedirects mutates
50053
+ * basePath/body (the only place they change), so the hot path is a set
50054
+ * lookup rather than a stringify+substring scan.
50055
+ */
50056
+ actionReferencesAny(action, draftIds) {
50057
+ if (draftIds.size === 0) {
50058
+ return false;
50059
+ }
50060
+ const data = action.data;
50061
+ if (data === undefined) {
50062
+ return false;
50063
+ }
50064
+ const body = typeof data.body === 'string' ? data.body : JSON.stringify(data.body || '');
50065
+ const haystack = `${data.basePath || ''}${body}`;
50066
+ return [...draftIds].some((id) => haystack.includes(id));
50067
+ }
50068
+ async getInFlightRewritableDraftIds() {
50069
+ const allDrafts = await this.draftStore.getAllDrafts();
50070
+ // Seed with draft ids retained across the completeAction →
50071
+ // propagateRedirectsToPeers gap so a peer keeps deferring even though the
50072
+ // completing POST has already left the store but its redirects have not
50073
+ // yet reached peers.
50074
+ const ids = new Set(this.redirectingDraftIds);
50075
+ for (const action of allDrafts) {
50076
+ if (this.handlers[action.handler] !== undefined &&
50077
+ (action.status === DraftActionStatus.Pending ||
50078
+ action.status === DraftActionStatus.Uploading) &&
50079
+ action.data &&
50080
+ action.data.method === 'post') {
50081
+ this.collectDraftIds(action, ids);
50082
+ }
50083
+ }
50084
+ return Array.from(ids);
50085
+ }
50086
+ resumeDeferredActions() {
50087
+ if (this.state === DraftQueueState.Started) {
50088
+ this.processNextAction();
50089
+ }
50090
+ }
50091
+ // Applies a set of draft → canonical id rewrites to every action queued
50092
+ // on this queue. Called by a peer queue after one of its POSTs completes
50093
+ // and produced redirects. Walks this queue, rewrites any action whose
50094
+ // basePath/body/tag references a draft id, persists via the shared
50095
+ // store, and kicks processNextAction so newly-resolvable actions dispatch.
50096
+ async applyDraftIdRedirects(redirects) {
50097
+ if (!redirects || redirects.length === 0) {
50098
+ return;
50099
+ }
50100
+ return this.workerPool.push({
50101
+ workFn: async () => {
50102
+ const queue = await this.getQueueActions();
50103
+ const queueOperations = rewriteQueueActionsForRedirects(queue, redirects);
50104
+ if (queueOperations.length > 0) {
50105
+ await this.draftStore.completeAction(queueOperations);
50106
+ }
50107
+ if (this.state === DraftQueueState.Started) {
50108
+ this.processNextAction();
50109
+ }
50110
+ },
49063
50111
  });
49064
- return deferred;
49065
50112
  }
49066
50113
  /**
49067
- * Revives the draft store from the durable store. Once the draft store is
49068
- * revived, executes any queued up draft store operations that came in while
49069
- * reviving
50114
+ * Retains the draft ids resolved by `redirects` in `redirectingDraftIds` so
50115
+ * getInFlightRewritableDraftIds keeps reporting them across the
50116
+ * completeAction → propagateRedirectsToPeers fire-and-forget gap. Returns
50117
+ * only the ids this call newly added (so a concurrent completion touching
50118
+ * the same id doesn't release it early). Called before completeAction on the
50119
+ * single-action completion path (actionCompleted).
49070
50120
  */
49071
- resyncDraftStore() {
49072
- const sync = () => {
49073
- this.syncPromise = this.durableStore
49074
- .getAllEntries(DRAFT_SEGMENT)
49075
- .then((durableEntries) => {
49076
- if (durableEntries === undefined) {
49077
- this.draftStore = {};
49078
- this.cleanSnapshot = {};
49079
- return this.runQueuedOperations();
50121
+ retainRedirectingDraftIds(redirects) {
50122
+ const retainedDraftIds = [];
50123
+ if (redirects !== undefined) {
50124
+ for (const redirect of redirects) {
50125
+ if (redirect.draftId && !this.redirectingDraftIds.has(redirect.draftId)) {
50126
+ this.redirectingDraftIds.add(redirect.draftId);
50127
+ retainedDraftIds.push(redirect.draftId);
49080
50128
  }
49081
- const { draftStore } = this;
49082
- const keys$1 = keys$4(durableEntries);
49083
- for (let i = 0, len = keys$1.length; i < len; i++) {
49084
- const entry = durableEntries[keys$1[i]];
49085
- const action = entry.data;
49086
- draftStore[action.id] = action;
49087
- this.cleanSnapshot[action.id] = clone$1(action);
50129
+ }
50130
+ }
50131
+ return retainedDraftIds;
50132
+ }
50133
+ /** Releases the ids retained by a prior retainRedirectingDraftIds call. */
50134
+ releaseRedirectingDraftIds(retainedDraftIds) {
50135
+ for (const id of retainedDraftIds) {
50136
+ this.redirectingDraftIds.delete(id);
50137
+ }
50138
+ }
50139
+ /**
50140
+ * After an action on this queue completes, propagates the draft → canonical
50141
+ * id rewrites it produced to the peer queues so their queued actions that
50142
+ * reference the resolved id are rewritten before dispatch. When there are no
50143
+ * redirects (or none apply), falls back to a bare wake of each peer so their
50144
+ * previously-deferred actions re-evaluate.
50145
+ *
50146
+ * `redirects` is precomputed by the caller (before completeAction resolves
50147
+ * the POST out of the store) so the redirecting draft ids can be retained
50148
+ * across the fire-and-forget gap. `onSettled` runs once every peer's rewrite
50149
+ * has settled, so the caller can release those retained ids.
50150
+ */
50151
+ propagateRedirectsToPeers(redirects, onSettled) {
50152
+ // Peers are typed DraftQueue[]; only those that also implement
50153
+ // CrossQueueCoordination can participate. Narrow once up front so the
50154
+ // calls below need no per-method guards.
50155
+ const peers = this.getPeerQueues().filter(isCrossQueueCoordination);
50156
+ if (peers.length === 0) {
50157
+ onSettled();
50158
+ return;
50159
+ }
50160
+ if (redirects !== undefined && redirects.length > 0) {
50161
+ const settlements = peers.map((peer) => {
50162
+ // Fire-and-forget: peer.applyDraftIdRedirects schedules its own
50163
+ // work on the peer's worker pool. Don't await — that deadlocks
50164
+ // when the peer is propagating redirects back to this queue.
50165
+ const promise = peer.applyDraftIdRedirects(redirects);
50166
+ if (promise && typeof promise.catch === 'function') {
50167
+ return promise.catch((e) => {
50168
+ if (this.logger) {
50169
+ this.logger.logError(`PerformanceDraftQueue: cross-queue redirect propagation to peer failed: ${errName(e)}`);
50170
+ }
50171
+ });
49088
50172
  }
49089
- return this.runQueuedOperations();
49090
- })
49091
- .finally(() => {
49092
- this.syncPromise = undefined;
50173
+ return Promise.resolve();
49093
50174
  });
49094
- return this.syncPromise;
49095
- };
49096
- // if there's an ongoing sync populating the in memory store, wait for it to complete before re-syncing
49097
- const { syncPromise } = this;
49098
- if (syncPromise === undefined) {
49099
- return sync();
50175
+ // Release the retained draft ids only once every peer has applied
50176
+ // (or failed to apply) the rewrites, so getInFlightRewritableDraftIds
50177
+ // keeps reporting them until peers no longer reference the stale id.
50178
+ Promise.all(settlements).then(onSettled, onSettled);
49100
50179
  }
49101
- return syncPromise.then(() => {
49102
- return sync();
50180
+ else {
50181
+ peers.forEach((peer) => peer.resumeDeferredActions());
50182
+ onSettled();
50183
+ }
50184
+ }
50185
+ async processNextAction() {
50186
+ // Serialize dispatch passes. A pass awaits peer state before
50187
+ // claiming a slot, so overlapping passes could double-dispatch. If a
50188
+ // pass is already running, record the wakeup and let it re-run.
50189
+ if (this.isDispatching) {
50190
+ this.redispatchRequested = true;
50191
+ return ProcessActionResult.ACTION_ALREADY_PROCESSING;
50192
+ }
50193
+ this.isDispatching = true;
50194
+ try {
50195
+ let result = ProcessActionResult.NO_ACTION_TO_PROCESS;
50196
+ do {
50197
+ this.redispatchRequested = false;
50198
+ result = await this.dispatchFreeSlots();
50199
+ } while (this.redispatchRequested);
50200
+ return result;
50201
+ }
50202
+ finally {
50203
+ this.isDispatching = false;
50204
+ }
50205
+ }
50206
+ async dispatchFreeSlots() {
50207
+ if (this.uploadingActionIds.size >= this.concurrency) {
50208
+ return ProcessActionResult.ACTION_ALREADY_PROCESSING;
50209
+ }
50210
+ // Gather the set of pending POST draft ids whose creators are still in
50211
+ // flight on any peer queue. A candidate action that references one of
50212
+ // those ids would otherwise dispatch with an unresolved draft id
50213
+ // (ContentDocument 069... etc.) instead of waiting for the upstream
50214
+ // POST to rewrite it to canonical. These peer reads each await, so they
50215
+ // run BEFORE the owned-queue snapshot below — otherwise an await would
50216
+ // sit between snapshotting the queue and claiming from it, letting the
50217
+ // store change underneath the selection (the candidate we picked could
50218
+ // already be gone or completed by the time we claim it).
50219
+ const blockingDraftIds = new Set();
50220
+ const peers = this.getPeerQueues().filter(isCrossQueueCoordination);
50221
+ for (const peer of peers) {
50222
+ try {
50223
+ const ids = await peer.getInFlightRewritableDraftIds();
50224
+ for (const id of ids) {
50225
+ blockingDraftIds.add(id);
50226
+ }
50227
+ }
50228
+ catch (e) {
50229
+ // Peers are already narrowed to CrossQueueCoordination, so this
50230
+ // is a genuine failure to read a peer's in-flight ids, not a
50231
+ // missing capability. Log it: on failure we fall through with an
50232
+ // incomplete blocking set, which can let a dependent action
50233
+ // dispatch against an unresolved draft id.
50234
+ if (this.logger) {
50235
+ this.logger.logError(`PerformanceDraftQueue: failed to read peer in-flight rewritable draft ids: ${errName(e)}`);
50236
+ }
50237
+ }
50238
+ }
50239
+ // Snapshot the owned queue AFTER the peer reads. From here to the claims
50240
+ // below there is no await, so the snapshot and the slot claims see a
50241
+ // consistent view of the store.
50242
+ const queue = await this.getQueueActions();
50243
+ // The shared DurableDraftStore returns actions for every queue, so we
50244
+ // restrict candidate selection to actions whose handler is registered
50245
+ // on this queue. This keeps a queue from claiming or being
50246
+ // error-blocked by its peer's actions.
50247
+ const ownedQueue = queue.filter((a) => this.handlers[a.handler] !== undefined);
50248
+ // Head-of-queue error block: only applies when strictOrdering is enabled.
50249
+ // With strictOrdering=false the candidate loop will skip errored items.
50250
+ if (this.strictOrdering) {
50251
+ const head = ownedQueue[0];
50252
+ if (head !== undefined && head.status === DraftActionStatus.Error) {
50253
+ this.state = DraftQueueState.Error;
50254
+ await this.notifyChangedListeners({
50255
+ type: DraftQueueEventType.ActionFailed,
50256
+ action: head,
50257
+ draftCount: this.draftStore.getCount(),
50258
+ });
50259
+ return ProcessActionResult.BLOCKED_ON_ERROR;
50260
+ }
50261
+ }
50262
+ // Single pass over ownedQueue derives everything the selection loop
50263
+ // needs: candidates (actions neither already claimed in-flight nor
50264
+ // parked in retry backoff), the targetIds of in-flight actions
50265
+ // (per-record serialization), and every owned pending/uploading POST's
50266
+ // draft ids (this queue's contribution to the blocking set).
50267
+ const candidates = [];
50268
+ const inFlightTargetIds = new Set();
50269
+ for (const a of ownedQueue) {
50270
+ if (this.uploadingActionIds.has(a.id)) {
50271
+ if (a.targetId) {
50272
+ inFlightTargetIds.add(a.targetId);
50273
+ }
50274
+ }
50275
+ else if (!this.retryingActionIds.has(a.id) &&
50276
+ // Same guard as isDispatchable: a commit-ambiguous member whose
50277
+ // quarantine could not be durably written stays out of
50278
+ // single-action dispatch too, not just batching.
50279
+ !this.commitAmbiguousUnresolvedIds.has(a.id)) {
50280
+ candidates.push(a);
50281
+ }
50282
+ if ((a.status === DraftActionStatus.Pending ||
50283
+ a.status === DraftActionStatus.Uploading) &&
50284
+ a.data &&
50285
+ a.data.method === 'post') {
50286
+ this.collectDraftIds(a, blockingDraftIds);
50287
+ }
50288
+ }
50289
+ // Fill every free concurrency slot from this single snapshot rather than
50290
+ // claiming one and bouncing back through processNextAction — that would
50291
+ // re-run getQueueActions (and its deep clone) per slot. Claims are
50292
+ // synchronous (no await inside the loop), so inFlightTargetIds stays
50293
+ // authoritative for per-record serialization of later picks in the same
50294
+ // pass. Blocked candidates are skipped (strictOrdering=false) or halt
50295
+ // the scan (strictOrdering=true head-of-line semantics).
50296
+ const toDispatch = [];
50297
+ let blockedCount = 0;
50298
+ for (const candidate of candidates) {
50299
+ if (this.uploadingActionIds.size >= this.concurrency) {
50300
+ break;
50301
+ }
50302
+ if (candidate.status === DraftActionStatus.Error) {
50303
+ continue;
50304
+ }
50305
+ // Per-record serialization: skip if another action on the same record is in-flight.
50306
+ if (candidate.targetId && inFlightTargetIds.has(candidate.targetId)) {
50307
+ blockedCount++;
50308
+ if (this.strictOrdering) {
50309
+ break;
50310
+ }
50311
+ continue;
50312
+ }
50313
+ // Exclude the candidate's own draft IDs so a POST doesn't self-block
50314
+ // on its own targetId.
50315
+ const ownIds = new Set();
50316
+ this.collectDraftIds(candidate, ownIds);
50317
+ const effectiveBlockingIds = new Set(blockingDraftIds);
50318
+ for (const id of ownIds) {
50319
+ effectiveBlockingIds.delete(id);
50320
+ }
50321
+ if (this.actionReferencesAny(candidate, effectiveBlockingIds)) {
50322
+ blockedCount++;
50323
+ if (this.strictOrdering) {
50324
+ break;
50325
+ }
50326
+ continue;
50327
+ }
50328
+ // Batching path: only when enabled, on the first action dispatched
50329
+ // this pass, and only if its handler implements handleBatchActions
50330
+ // and the eligible batch is larger than 1. collectBatch enforces the
50331
+ // same ref-blocking rule applied above so a candidate referencing a
50332
+ // peer's pending POST id won't be batched alongside the head. When a
50333
+ // batch dispatches it consumes the pass (returns), matching the
50334
+ // pre-fill-all behavior. It is also passed inFlightTargetIds so a
50335
+ // batch member can't write a record that another in-flight request
50336
+ // is already writing (the head candidate is pre-checked above).
50337
+ if (this.queueBatching && toDispatch.length === 0) {
50338
+ const handler = this.getHandler(candidate.handler);
50339
+ if (handler.handleBatchActions !== undefined) {
50340
+ const actionIndex = ownedQueue.indexOf(candidate);
50341
+ const batch = this.collectBatch(ownedQueue, actionIndex, blockingDraftIds, inFlightTargetIds);
50342
+ if (batch.length > 1) {
50343
+ batchLog$1(`dispatching batch size=${batch.length}`);
50344
+ if (this.state === DraftQueueState.Waiting) {
50345
+ this.state = DraftQueueState.Started;
50346
+ }
50347
+ // Claim every batch member so a re-entrant
50348
+ // processNextAction (e.g. a peer kick during the await
50349
+ // below) doesn't re-claim any of them.
50350
+ for (const batched of batch) {
50351
+ this.uploadingActionIds.add(batched.id);
50352
+ }
50353
+ for (const batched of batch) {
50354
+ await this.notifyChangedListeners({
50355
+ type: DraftQueueEventType.ActionUploading,
50356
+ action: { ...batched, status: DraftActionStatus.Uploading },
50357
+ draftCount: this.draftStore.getCount(),
50358
+ });
50359
+ }
50360
+ return this.handleBatch(batch);
50361
+ }
50362
+ }
50363
+ }
50364
+ // Eligible — claim the slot now (synchronously) so the next
50365
+ // iteration serializes against it.
50366
+ this.uploadingActionIds.add(candidate.id);
50367
+ if (candidate.targetId) {
50368
+ inFlightTargetIds.add(candidate.targetId);
50369
+ }
50370
+ toDispatch.push(candidate);
50371
+ }
50372
+ if (toDispatch.length === 0) {
50373
+ if (blockedCount > 0 && this.logger) {
50374
+ this.logger.logInfo(`[DraftQueue c=${this.concurrency}] processNextAction: ${blockedCount} action(s) deferred waiting on upstream draft ids`);
50375
+ }
50376
+ if (this.uploadingActionIds.size === 0) {
50377
+ if (this.state === DraftQueueState.Waiting) {
50378
+ this.state = DraftQueueState.Started;
50379
+ }
50380
+ return ProcessActionResult.NO_ACTION_TO_PROCESS;
50381
+ }
50382
+ return ProcessActionResult.ACTION_ALREADY_PROCESSING;
50383
+ }
50384
+ if (this.state === DraftQueueState.Waiting) {
50385
+ this.state = DraftQueueState.Started;
50386
+ }
50387
+ for (const action of toDispatch) {
50388
+ await this.notifyChangedListeners({
50389
+ type: DraftQueueEventType.ActionUploading,
50390
+ action: { ...action, status: DraftActionStatus.Uploading },
50391
+ draftCount: this.draftStore.getCount(),
50392
+ });
50393
+ this.handle(action);
50394
+ }
50395
+ return ProcessActionResult.ACTION_ALREADY_PROCESSING;
50396
+ }
50397
+ // Overrides DurableDraftQueue.removeDraftAction rather than reusing it: the
50398
+ // uploading guard reads the concurrent `uploadingActionIds` set (the base
50399
+ // reads its private single `uploadingActionId`, which this path never sets,
50400
+ // so delegating would defeat the guard), and it additionally clears the
50401
+ // per-action retry timer — a concept that exists only on this subclass. The
50402
+ // rest of the body is kept identical to the base; keep them in sync.
50403
+ async removeDraftAction(actionId) {
50404
+ const queue = await this.getQueueActions();
50405
+ //Get the store key for the removed action
50406
+ const actions = queue.filter((action) => action.id === actionId);
50407
+ if (actions.length === 0) {
50408
+ // eslint-disable-next-line @salesforce/lds/no-error-in-production
50409
+ throw new Error(`No removable action with id ${actionId}`);
50410
+ }
50411
+ const action = actions[0];
50412
+ if (this.uploadingActionIds.has(action.id)) {
50413
+ // eslint-disable-next-line @salesforce/lds/no-error-in-production
50414
+ throw new Error(`Cannot remove an uploading draft action with ID ${actionId}`);
50415
+ }
50416
+ // A removed action must not leave a dangling backoff timer that would
50417
+ // later fire and re-drive the scheduler for an id that no longer exists.
50418
+ this.clearActionRetry(action.id);
50419
+ const handler = this.getHandler(action.handler);
50420
+ const shouldDeleteRelated = handler.shouldDeleteActionByTagOnRemoval(action);
50421
+ if (shouldDeleteRelated) {
50422
+ await this.draftStore.deleteByTag(action.tag);
50423
+ }
50424
+ else {
50425
+ await this.draftStore.deleteDraft(action.id);
50426
+ }
50427
+ await handler.handleActionRemoved(action, queue.filter((x) => x.id !== actionId));
50428
+ await this.notifyChangedListeners({
50429
+ type: DraftQueueEventType.ActionDeleted,
50430
+ action,
50431
+ draftCount: this.draftStore.getCount(),
49103
50432
  });
50433
+ if (this.userState === DraftQueueState.Started &&
50434
+ this.state !== DraftQueueState.Started &&
50435
+ this.replacingAction === undefined) {
50436
+ await this.startQueue();
50437
+ }
49104
50438
  }
49105
50439
  /**
49106
- * Runs the operations that were queued up while reviving the
49107
- * draft store from the durable store
50440
+ * Concurrent-aware, per-action retry scheduler. The serial base parks the
50441
+ * whole queue under one shared timer + one shared exponential interval,
50442
+ * which conflate concurrent retries. This instead gives the failed action
50443
+ * its own timer and its own growing backoff, keyed by id, so it retries on
50444
+ * its own schedule without disturbing siblings still uploading in other
50445
+ * slots.
50446
+ *
50447
+ * Only broadcasts `Waiting` when the queue is genuinely idle (nothing in
50448
+ * flight): a concurrent queue with other slots live is still `Started`, and
50449
+ * on the serial path the failed action was already removed from
50450
+ * `uploadingActionIds` above (size 0), so the Waiting broadcast the regular
50451
+ * queue emits today is preserved.
49108
50452
  */
49109
- runQueuedOperations() {
49110
- const { writeQueue } = this;
49111
- if (writeQueue.length > 0) {
49112
- const queueItem = writeQueue.shift();
49113
- if (queueItem !== undefined) {
49114
- return queueItem().then(() => {
49115
- return this.runQueuedOperations();
49116
- });
50453
+ async scheduleActionRetry(actionId, retryDelayInMs) {
50454
+ const delay = retryDelayInMs !== undefined ? retryDelayInMs : this.nextBackoffInterval(actionId);
50455
+ // Only move to Waiting when the queue is genuinely idle (nothing in
50456
+ // flight). A concurrent queue with other slots still uploading must stay
50457
+ // Started — otherwise sibling completions (which gate their
50458
+ // processNextAction on state === Started) would stop advancing the queue
50459
+ // and free slots would sit idle until this action's retry timer fired.
50460
+ // Only dispatchFreeSlots flips Waiting back to Started, and nothing
50461
+ // reaches it while state is Waiting. On the serial path the failed action
50462
+ // was already removed above (size 0), so the Waiting transition the
50463
+ // regular queue emits today is preserved.
50464
+ if (this.uploadingActionIds.size === 0) {
50465
+ this.state = DraftQueueState.Waiting;
50466
+ await this.notifyChangedListeners({
50467
+ type: DraftQueueEventType.QueueStateChanged,
50468
+ state: DraftQueueState.Waiting,
50469
+ draftCount: this.draftStore.getCount(),
50470
+ });
50471
+ }
50472
+ // Replace any existing timer for this action so a fresh failure resets
50473
+ // its own backoff window without leaking the prior timer.
50474
+ const existing = this.retryTimers.get(actionId);
50475
+ if (existing !== undefined) {
50476
+ clearTimeout(existing);
50477
+ }
50478
+ this.retryTimers.set(actionId, setTimeout(() => {
50479
+ // Only this action becomes eligible again; siblings keep their
50480
+ // own backoff windows.
50481
+ this.retryTimers.delete(actionId);
50482
+ this.retryingActionIds.delete(actionId);
50483
+ if (this.state !== DraftQueueState.Stopped) {
50484
+ this.processNextAction();
49117
50485
  }
50486
+ }, delay));
50487
+ }
50488
+ /**
50489
+ * Per-action exponential backoff, mirroring the base's doubling within the
50490
+ * same [minimum, maximum] bounds but tracked per id so each retrying action
50491
+ * grows its own interval independently.
50492
+ */
50493
+ nextBackoffInterval(actionId) {
50494
+ const current = this.retryIntervals.get(actionId) ?? 0;
50495
+ const next = Math.min(Math.max(current * 2, this.minimumRetryInterval), this.maximumRetryInterval);
50496
+ this.retryIntervals.set(actionId, next);
50497
+ return next;
50498
+ }
50499
+ /**
50500
+ * Drops all retry bookkeeping for an action: cancels its pending timer,
50501
+ * un-parks it, and forgets its backoff interval. Called on completion,
50502
+ * permanent failure, and removal.
50503
+ */
50504
+ clearActionRetry(actionId) {
50505
+ const timer = this.retryTimers.get(actionId);
50506
+ if (timer !== undefined) {
50507
+ clearTimeout(timer);
50508
+ this.retryTimers.delete(actionId);
50509
+ }
50510
+ this.retryIntervals.delete(actionId);
50511
+ this.retryingActionIds.delete(actionId);
50512
+ }
50513
+ /**
50514
+ * Cancels every per-action retry timer. The base clears only its single
50515
+ * `timeoutHandler`; a concurrent queue may have many parked actions, so on
50516
+ * any full stop we must clear them all to avoid a dangling timer firing
50517
+ * against a stopped queue.
50518
+ */
50519
+ clearAllRetryTimers() {
50520
+ for (const timer of this.retryTimers.values()) {
50521
+ clearTimeout(timer);
50522
+ }
50523
+ this.retryTimers.clear();
50524
+ this.retryIntervals.clear();
50525
+ this.retryingActionIds.clear();
50526
+ }
50527
+ stopQueueManually(internalReason) {
50528
+ this.clearAllRetryTimers();
50529
+ super.stopQueueManually(internalReason);
50530
+ }
50531
+ /**
50532
+ * Clears every per-action retry timer on (re)start for the same reason the
50533
+ * base clears its single `timeoutHandler`: a dangling backoff timer must not
50534
+ * fire against a freshly-started queue. Guarded by the same conditions under
50535
+ * which the base actually restarts (not already Started, not mid-replace) so
50536
+ * a `startQueue()` that is a no-op — or a replace-driven restart — doesn't
50537
+ * wipe legitimately-parked backoff windows out from under in-flight retries.
50538
+ */
50539
+ async startQueue() {
50540
+ // Record the caller's intent up front, exactly as the base startQueue does
50541
+ // synchronously. The reconcile below awaits, so a stopQueue() can interleave
50542
+ // with it; capturing intent here lets that later stop flip userState and win
50543
+ // (see the revalidation after the await, R4-6).
50544
+ this.userState = DraftQueueState.Started;
50545
+ if (this.state !== DraftQueueState.Started && this.replacingAction === undefined) {
50546
+ this.clearAllRetryTimers();
50547
+ // Concurrent counterpart: the base scalar is never set here, so check the Set.
50548
+ // Stale ids in uploadingActionIds at restart strand the dispatcher's uploading guard.
50549
+ if (this.uploadingActionIds.size > 0 && this.logger) {
50550
+ this.logger.logWarning(`Draft queue restarting with ${this.uploadingActionIds.size} action(s) still marked uploading (stale uploadingActionIds=${[
50551
+ ...this.uploadingActionIds,
50552
+ ].join(',')}); possible abandoned-upload stuck queue`);
50553
+ }
50554
+ }
50555
+ // Recover from any batch journaled but never confirmed removed (B2)
50556
+ // before the base starts dispatching, so a committed-but-unremoved
50557
+ // member is surfaced as a commit-ambiguous error before collectBatch can
50558
+ // re-batch it (Option A). Guarded to run at most once per process; a no-op
50559
+ // when batching is disabled or the store keeps no journal.
50560
+ await this.reconcileBatchJournalsOnce();
50561
+ // The reconcile awaited above yields the event loop. If a stopQueue() ran
50562
+ // while it was pending, that stop already completed and set
50563
+ // userState=Stopped; honor it rather than clobbering the queue back to
50564
+ // Started via super.startQueue() and dispatching after the caller stopped
50565
+ // it (R4-6).
50566
+ if (this.userState !== DraftQueueState.Started) {
50567
+ return;
50568
+ }
50569
+ return super.startQueue();
50570
+ }
50571
+ async retryAllErrors() {
50572
+ const actions = await this.getOwnedQueueActions();
50573
+ const errored = actions.filter((a) => a.status === DraftActionStatus.Error);
50574
+ if (errored.length === 0) {
50575
+ return;
50576
+ }
50577
+ // Stop the queue before rewriting errored actions to Pending. With
50578
+ // strictOrdering=false the queue keeps running while errored items are
50579
+ // skipped, so a live pass could observe the store mid-rewrite. Stop,
50580
+ // rewrite, then restart safely.
50581
+ this.stopQueueManually(true);
50582
+ for (const action of errored) {
50583
+ // Drop the `error` field that's only present on ErrorDraftAction so
50584
+ // the resulting object structurally matches PendingDraftAction and
50585
+ // satisfies the discriminated-union narrower.
50586
+ const { error: _droppedError, ...rest } = action;
50587
+ const pendingAction = {
50588
+ ...rest,
50589
+ status: DraftActionStatus.Pending,
50590
+ };
50591
+ await this.draftStore.writeAction(pendingAction);
50592
+ await this.notifyChangedListeners({
50593
+ type: DraftQueueEventType.ActionUpdated,
50594
+ action: pendingAction,
50595
+ draftCount: this.draftStore.getCount(),
50596
+ });
50597
+ }
50598
+ await this.startQueueSafe();
50599
+ }
50600
+ }
50601
+ /**
50602
+ * Error surfaced on a batch member whose commit status could not be confirmed:
50603
+ * the batch response was lost (network throw, empty/misaligned 2xx, a retryable
50604
+ * gateway envelope) or the process died mid-flight. Because the batch envelope
50605
+ * carries no server-visible idempotency key, such a write cannot be safely
50606
+ * re-sent without risking a duplicate, so the member is surfaced as an error for
50607
+ * manual resolution rather than auto-replayed (Option A). Shaped like a
50608
+ * FetchResponse so downstream consumers that read action.error.status/.body
50609
+ * behave consistently with a server error.
50610
+ */
50611
+ PerformanceDraftQueue.COMMIT_AMBIGUOUS_ERROR = {
50612
+ status: 0,
50613
+ statusText: 'Batch commit ambiguous',
50614
+ ok: false,
50615
+ headers: {},
50616
+ body: {
50617
+ errorCode: 'BATCH_COMMIT_AMBIGUOUS',
50618
+ message: 'The batched change may or may not have been saved (the server response ' +
50619
+ 'was not received). It was not retried automatically to avoid saving it ' +
50620
+ 'twice; please verify the record and retry or discard the draft.',
50621
+ },
50622
+ };
50623
+
50624
+ class RoutingDraftQueue {
50625
+ /**
50626
+ * @param primaryLane the serial/fallback lane: it owns unregistered handlers and backs
50627
+ * every store read. Passed explicitly so routing never depends on array ordering.
50628
+ * @param secondaryLanes the remaining routed lanes (e.g. the concurrent binary lane).
50629
+ */
50630
+ constructor(primaryLane, secondaryLanes = []) {
50631
+ this.primaryLane = primaryLane;
50632
+ this.lanes = [primaryLane, ...secondaryLanes];
50633
+ }
50634
+ get primary() {
50635
+ return this.primaryLane.queue;
50636
+ }
50637
+ /** Resolve the lane that owns `handlerId`, falling back to the primary lane. */
50638
+ laneForHandler(handlerId) {
50639
+ return (this.lanes.find(({ queue }) => queue.isHandlerRegistered(handlerId)) ?? this.primaryLane);
50640
+ }
50641
+ /** Resolve the sub-queue that owns `handlerId`, falling back to the primary queue. */
50642
+ routeByHandler(handlerId) {
50643
+ return this.laneForHandler(handlerId).queue;
50644
+ }
50645
+ /**
50646
+ * Resolve the sub-queue that owns the action identified by `actionId` by
50647
+ * reading its stamped handler from the shared store (visible via the primary
50648
+ * queue), then routing by handler ownership. Falls back to the primary queue
50649
+ * when the action can't be found.
50650
+ */
50651
+ async routeByActionId(actionId) {
50652
+ const actions = await this.primary.getQueueActions();
50653
+ const action = actions.find((candidate) => candidate.id === actionId);
50654
+ return action !== undefined ? this.routeByHandler(action.handler) : this.primary;
50655
+ }
50656
+ enqueue(handlerId, data, observabilityContext) {
50657
+ return this.routeByHandler(handlerId).enqueue(handlerId, data, observabilityContext);
50658
+ }
50659
+ async getQueueActions() {
50660
+ const actions = await this.primary.getQueueActions();
50661
+ return actions.map((action) => ({
50662
+ ...action,
50663
+ lane: this.laneForHandler(action.handler).lane,
50664
+ }));
50665
+ }
50666
+ addHandler(handler) {
50667
+ return this.primary.addHandler(handler);
50668
+ }
50669
+ addCustomHandler(id, executor) {
50670
+ return this.primary.addCustomHandler(id, executor);
50671
+ }
50672
+ removeHandler(id) {
50673
+ return this.primary.removeHandler(id);
50674
+ }
50675
+ async removeDraftAction(actionId) {
50676
+ const owner = await this.routeByActionId(actionId);
50677
+ return owner.removeDraftAction(actionId);
50678
+ }
50679
+ updateDraftAction(action) {
50680
+ return this.routeByHandler(action.handler).updateDraftAction(action);
50681
+ }
50682
+ async retryAction(actionId) {
50683
+ const owner = await this.routeByActionId(actionId);
50684
+ return owner.retryAction(actionId);
50685
+ }
50686
+ async setMetadata(actionId, metadata) {
50687
+ const owner = await this.routeByActionId(actionId);
50688
+ return owner.setMetadata(actionId, metadata);
50689
+ }
50690
+ async replaceAction(targetActionId, sourceActionId) {
50691
+ const owner = await this.routeByActionId(targetActionId);
50692
+ return owner.replaceAction(targetActionId, sourceActionId);
50693
+ }
50694
+ async mergeActions(targetActionId, sourceActionId) {
50695
+ const owner = await this.routeByActionId(targetActionId);
50696
+ return owner.mergeActions(targetActionId, sourceActionId);
50697
+ }
50698
+ registerOnChangedListener(listener) {
50699
+ const unsubscribes = this.lanes.map(({ queue }) => queue.registerOnChangedListener(listener));
50700
+ return () => Promise.all(unsubscribes.map((unsubscribe) => unsubscribe())).then(() => undefined);
50701
+ }
50702
+ async processNextAction() {
50703
+ const [primaryResult] = await Promise.all(this.lanes.map(({ queue }) => queue.processNextAction()));
50704
+ return primaryResult;
50705
+ }
50706
+ /**
50707
+ * Conservative aggregate of the sub-queue states, most-attention-needed first:
50708
+ * Error (blocked, needs intervention) > Stopped (explicitly halted) > Waiting
50709
+ * (transient network backoff) > Started. Not bridged — DraftManager reports
50710
+ * per-queue state separately; this exists only to satisfy the interface.
50711
+ */
50712
+ getQueueState() {
50713
+ const states = this.lanes.map(({ queue }) => queue.getQueueState());
50714
+ if (states.includes(DraftQueueState.Error)) {
50715
+ return DraftQueueState.Error;
50716
+ }
50717
+ if (states.includes(DraftQueueState.Stopped)) {
50718
+ return DraftQueueState.Stopped;
50719
+ }
50720
+ if (states.includes(DraftQueueState.Waiting)) {
50721
+ return DraftQueueState.Waiting;
50722
+ }
50723
+ return DraftQueueState.Started;
50724
+ }
50725
+ async startQueue() {
50726
+ await Promise.all(this.lanes.map(({ queue }) => queue.startQueue()));
50727
+ }
50728
+ async stopQueue() {
50729
+ await Promise.all(this.lanes.map(({ queue }) => queue.stopQueue()));
50730
+ }
50731
+ /**
50732
+ * Stops every sub-queue, runs `action` once, then restarts every sub-queue.
50733
+ * Not bridged; the per-queue variant preserves userState via an internal stop,
50734
+ * which isn't reachable from here, so this uses the public stop/start pair.
50735
+ */
50736
+ async stopQueueWhileRunning(action) {
50737
+ await Promise.all(this.lanes.map(({ queue }) => queue.stopQueue()));
50738
+ try {
50739
+ await action();
50740
+ }
50741
+ finally {
50742
+ await Promise.all(this.lanes.map(({ queue }) => queue.startQueue()));
49118
50743
  }
49119
- return Promise.resolve();
49120
50744
  }
49121
50745
  }
49122
50746
 
@@ -49184,6 +50808,16 @@
49184
50808
  shouldEmitEvent(event) {
49185
50809
  return this.draftEventsShouldBeEmitted.includes(event.type);
49186
50810
  }
50811
+ /**
50812
+ * Routes a sub-queue change event into the listener pipeline. Exposed to subclasses so they
50813
+ * can register it on an additional queue without duplicating the emit-gating logic.
50814
+ */
50815
+ handleQueueChange(event) {
50816
+ if (this.shouldEmitEvent(event)) {
50817
+ return this.callListeners(event);
50818
+ }
50819
+ return Promise.resolve();
50820
+ }
49187
50821
  constructor(draftQueue) {
49188
50822
  this.listeners = [];
49189
50823
  this.draftEventsShouldBeEmitted = [
@@ -49196,12 +50830,7 @@
49196
50830
  DraftQueueEventType.QueueStateChanged,
49197
50831
  ];
49198
50832
  this.draftQueue = draftQueue;
49199
- draftQueue.registerOnChangedListener((event) => {
49200
- if (this.shouldEmitEvent(event)) {
49201
- return this.callListeners(event);
49202
- }
49203
- return Promise.resolve();
49204
- });
50833
+ draftQueue.registerOnChangedListener((event) => this.handleQueueChange(event));
49205
50834
  }
49206
50835
  draftQueueEventTypeToOperationType(type) {
49207
50836
  switch (type) {
@@ -49401,6 +51030,11 @@
49401
51030
  if (isDraftQueueStateChangeEvent(event)) {
49402
51031
  operationType = DraftQueueOperationType.QueueStateChanged;
49403
51032
  }
51033
+ else if (event.type === DraftQueueEventType.BatchRecoveryFailed) {
51034
+ // Telemetry-only signal (batch-recovery failure): it carries no
51035
+ // draft item, so DraftManager has nothing to surface to its listeners.
51036
+ return;
51037
+ }
49404
51038
  else {
49405
51039
  const { action, type } = event;
49406
51040
  item = this.buildDraftQueueItem(action);
@@ -49479,6 +51113,560 @@
49479
51113
  return (dataAsAny !== undefined && dataAsAny.method !== undefined && dataAsAny.body !== undefined);
49480
51114
  }
49481
51115
 
51116
+ function hasGetOwnedQueueActions(queue) {
51117
+ return typeof queue.getOwnedQueueActions === 'function';
51118
+ }
51119
+ function hasGetCreatedDraftIds(queue) {
51120
+ return typeof queue.getCreatedDraftIds === 'function';
51121
+ }
51122
+ function hasGetOwnedActionsReferencing(queue) {
51123
+ return typeof queue.getOwnedActionsReferencing === 'function';
51124
+ }
51125
+ function hasRetryAllErrors(queue) {
51126
+ return typeof queue.retryAllErrors === 'function';
51127
+ }
51128
+ /**
51129
+ * Gate-ON DraftManager: the concurrent-binary upload variant, instantiated only when the
51130
+ * `concurrentBinaryQueue` feature flag is on. It wraps a strict-ordering serial draft queue
51131
+ * plus a parallel binary queue over one shared store, stamps each draft's routing `lane`, and
51132
+ * drives the binary-queue lifecycle/read methods the native Pending Images UI consumes.
51133
+ *
51134
+ * The base `DraftManager` (gate-OFF) keeps every one of these methods as an inert stub so the
51135
+ * native bridge surface is identical regardless of the flag; this subclass supplies the real
51136
+ * behavior. Current (non-SCE) users never instantiate this class — they run the unchanged base.
51137
+ */
51138
+ class RoutingDraftManager extends DraftManager {
51139
+ constructor(draftQueue, binaryQueue, laneLabels) {
51140
+ super(draftQueue);
51141
+ this.logger = typeof __nimbus !== 'undefined' &&
51142
+ __nimbus.plugins !== undefined &&
51143
+ __nimbus.plugins.JSLoggerPlugin !== undefined
51144
+ ? __nimbus.plugins.JSLoggerPlugin
51145
+ : undefined;
51146
+ this.binaryQueue = binaryQueue;
51147
+ this.laneLabels = laneLabels;
51148
+ // Surface binary-queue change events through the same listener pipeline as the serial
51149
+ // queue (super already registered the serial queue; reuse the inherited handler here).
51150
+ this.binaryQueue.registerOnChangedListener((event) => this.handleQueueChange(event));
51151
+ }
51152
+ getQueue() {
51153
+ return Promise.all([this.draftQueue.getQueueActions(), this.binaryActionsById()])
51154
+ .then(([queueActions, binaryById]) => queueActions.map((action) => {
51155
+ const binaryAction = binaryById.get(action.id);
51156
+ return this.buildDraftQueueItem(this.stampLane(binaryAction ?? action, binaryAction !== undefined));
51157
+ }))
51158
+ .then(toQueueState(this.draftQueue));
51159
+ }
51160
+ /**
51161
+ * Get the current state of each of the DraftActions in the binary DraftQueue
51162
+ * (ContentDocument/Version/Link uploads).
51163
+ */
51164
+ getBinaryQueue() {
51165
+ const queue = this.binaryQueue;
51166
+ const getActions = hasGetOwnedQueueActions(queue)
51167
+ ? queue.getOwnedQueueActions()
51168
+ : queue.getQueueActions();
51169
+ return getActions
51170
+ .then((queueActions) => {
51171
+ return queueActions.map(this.buildDraftQueueItem);
51172
+ })
51173
+ .then(toQueueState(queue));
51174
+ }
51175
+ /**
51176
+ * Starts both the serial and binary draft queues and begins processing their items.
51177
+ */
51178
+ startQueue() {
51179
+ return Promise.all([this.draftQueue.startQueue(), this.binaryQueue.startQueue()]).then(() => undefined);
51180
+ }
51181
+ /**
51182
+ * Stops both the serial and binary draft queues from processing more draft items after
51183
+ * any in-progress items are finished.
51184
+ */
51185
+ stopQueue() {
51186
+ return Promise.all([this.draftQueue.stopQueue(), this.binaryQueue.stopQueue()]).then(() => undefined);
51187
+ }
51188
+ /**
51189
+ * Starts the binary draft queue (for ContentDocument/Version/Link uploads). Separate from
51190
+ * startQueue() so the binary queue can be paused/resumed independently from the regular
51191
+ * draft queue and from stopQueueWhileRunning() interruptions.
51192
+ */
51193
+ startBinaryQueue() {
51194
+ return this.binaryQueue.startQueue();
51195
+ }
51196
+ /** Stops the binary draft queue; pairs with startBinaryQueue(). */
51197
+ stopBinaryQueue() {
51198
+ return this.binaryQueue.stopQueue();
51199
+ }
51200
+ async retryAllErrors() {
51201
+ if (hasRetryAllErrors(this.draftQueue)) {
51202
+ await this.draftQueue.retryAllErrors();
51203
+ }
51204
+ if (hasRetryAllErrors(this.binaryQueue)) {
51205
+ await this.binaryQueue.retryAllErrors();
51206
+ }
51207
+ }
51208
+ /**
51209
+ * Routes the remove to the queue that owns the action. The serial queue reads the shared
51210
+ * store, so it sees binary-owned actions too, but has no handler registered for them and
51211
+ * would throw.
51212
+ */
51213
+ removeDraftAction(actionId) {
51214
+ return this.binaryActionsById()
51215
+ .then((binaryById) => {
51216
+ const binaryAction = binaryById.get(actionId);
51217
+ return binaryAction !== undefined
51218
+ ? this.removeBinaryActionAndDependents(binaryAction)
51219
+ : this.draftQueue.removeDraftAction(actionId);
51220
+ })
51221
+ .then(() => this.getQueue());
51222
+ }
51223
+ /**
51224
+ * Removes a binary action plus the serial actions that reference a draft id it creates
51225
+ * (e.g. the ContentDocumentLink native enqueues to link an uploaded image to a record).
51226
+ * Nothing resolves those ids once the binary action is gone, so a dependent left behind
51227
+ * would dispatch with an unresolved draft id, fail, and block the serial queue.
51228
+ *
51229
+ * Rejects up front when a dependent is uploading: an errored binary action no longer holds
51230
+ * its dependents back, so one can be mid-dispatch. Otherwise the binary action goes first
51231
+ * so a rejected remove (e.g. it is uploading) leaves its dependents untouched.
51232
+ */
51233
+ async removeBinaryActionAndDependents(action) {
51234
+ const dependents = await this.serialActionsDependingOn(action);
51235
+ const uploading = dependents.find((d) => d.status === DraftActionStatus.Uploading);
51236
+ if (uploading !== undefined) {
51237
+ // eslint-disable-next-line @salesforce/lds/no-error-in-production
51238
+ throw new Error(`Cannot remove draft action ${action.id} while its dependent draft action ${uploading.id} is uploading`);
51239
+ }
51240
+ await this.binaryQueue.removeDraftAction(action.id);
51241
+ for (const dependent of dependents) {
51242
+ const current = (await this.draftQueue.getQueueActions()).find((a) => a.id === dependent.id);
51243
+ // Removing an earlier dependent may already have deleted this one by shared tag.
51244
+ if (current === undefined) {
51245
+ continue;
51246
+ }
51247
+ // It started uploading after the check above. The binary action is already gone,
51248
+ // so keep going rather than report a failed remove; this one will error and can be
51249
+ // removed on its own.
51250
+ if (current.status === DraftActionStatus.Uploading) {
51251
+ if (this.logger) {
51252
+ this.logger.logWarning(`RoutingDraftManager: dependent draft action ${dependent.id} of removed draft action ${action.id} started uploading and was not removed`);
51253
+ }
51254
+ continue;
51255
+ }
51256
+ await this.draftQueue.removeDraftAction(dependent.id);
51257
+ }
51258
+ }
51259
+ serialActionsDependingOn(action) {
51260
+ const binary = this.binaryQueue;
51261
+ const serial = this.draftQueue;
51262
+ if (!hasGetCreatedDraftIds(binary) || !hasGetOwnedActionsReferencing(serial)) {
51263
+ return Promise.resolve([]);
51264
+ }
51265
+ const draftIds = binary.getCreatedDraftIds(action);
51266
+ return draftIds.length === 0
51267
+ ? Promise.resolve([])
51268
+ : serial.getOwnedActionsReferencing(draftIds);
51269
+ }
51270
+ /**
51271
+ * Binary-lane actions keyed by id, carrying the lane's transient `Uploading` status.
51272
+ * `getQueue` uses it to stamp the `concurrentBinary` lane and source the correct status.
51273
+ * An empty map means "treat every action as serial" — returned both when the binary queue
51274
+ * can't report ownership and (harmlessly) when it is simply empty; the caller doesn't need
51275
+ * to tell those apart.
51276
+ */
51277
+ binaryActionsById() {
51278
+ const binary = this.binaryQueue;
51279
+ if (!hasGetOwnedQueueActions(binary)) {
51280
+ return Promise.resolve(new Map());
51281
+ }
51282
+ return binary
51283
+ .getOwnedQueueActions()
51284
+ .then((actions) => new Map(actions.map((action) => [action.id, action])));
51285
+ }
51286
+ /** Stamp `action`'s routing `lane` from lane membership. */
51287
+ stampLane(action, isBinaryOwned) {
51288
+ const lane = isBinaryOwned ? this.laneLabels.concurrentBinary : this.laneLabels.serial;
51289
+ return { ...action, lane };
51290
+ }
51291
+ /**
51292
+ * Extends the base projection with the routing `lane` stamped onto the action. Only the
51293
+ * gate-ON path carries a lane; the base DraftManager never emits the field, so the gate-OFF
51294
+ * projection stays identical to before the concurrent-binary queue existed.
51295
+ */
51296
+ buildDraftQueueItem(action) {
51297
+ const item = super.buildDraftQueueItem(action);
51298
+ if (action.lane !== undefined) {
51299
+ item.lane = action.lane;
51300
+ }
51301
+ return item;
51302
+ }
51303
+ }
51304
+
51305
+ function clone$1(obj) {
51306
+ return parse$6(stringify$6(obj));
51307
+ }
51308
+
51309
+ const DRAFT_ACTION_KEY_JUNCTION = '__DraftAction__';
51310
+ function buildDraftDurableStoreKey(recordKey, draftActionId) {
51311
+ return `${recordKey}${DRAFT_ACTION_KEY_JUNCTION}${draftActionId}`;
51312
+ }
51313
+ /**
51314
+ * Implements a write-through InMemoryStore for Drafts, storing all drafts in a
51315
+ * in-memory store with a write through to the DurableStore.
51316
+ *
51317
+ * Before any reads or writes come in from the draft queue, we need to revive the draft
51318
+ * queue into memory. During this initial revive, any writes are queued up and operated on the
51319
+ * queue once it's in memory. Similarly any reads are delayed until the queue is in memory.
51320
+ *
51321
+ */
51322
+ class DurableDraftStore {
51323
+ constructor(durableStore, instrumentation, useSnapshot = true) {
51324
+ this.draftStore = {};
51325
+ // Snapshot: holds deep clones of each draft, refreshed at write time.
51326
+ // getAllDrafts serves from this snapshot with zero deep clones per read.
51327
+ this.cleanSnapshot = {};
51328
+ // queue of writes that were made during the initial sync
51329
+ this.writeQueue = [];
51330
+ this.durableStore = durableStore;
51331
+ this.instrumentation = instrumentation;
51332
+ this.useSnapshot = useSnapshot;
51333
+ this.resyncDraftStore();
51334
+ }
51335
+ // When no instrumentation is injected the promise is returned as-is (zero overhead).
51336
+ timeDurableOp(op, segment, run) {
51337
+ const onDurableOperation = this.instrumentation?.onDurableOperation;
51338
+ if (onDurableOperation === undefined) {
51339
+ return run();
51340
+ }
51341
+ const start = Date.now();
51342
+ return run().finally(() => {
51343
+ onDurableOperation(op, segment, Date.now() - start);
51344
+ });
51345
+ }
51346
+ writeAction(action) {
51347
+ const addAction = () => {
51348
+ const { id, tag } = action;
51349
+ this.draftStore[id] = action;
51350
+ this.cleanSnapshot[id] = clone$1(action);
51351
+ const durableEntryKey = buildDraftDurableStoreKey(tag, id);
51352
+ const entry = {
51353
+ data: action,
51354
+ };
51355
+ const entries = { [durableEntryKey]: entry };
51356
+ return this.timeDurableOp('setEntries', DRAFT_SEGMENT, () => this.durableStore.setEntries(entries, DRAFT_SEGMENT));
51357
+ };
51358
+ return this.enqueueAction(addAction);
51359
+ }
51360
+ getAllDrafts() {
51361
+ const waitForOngoingSync = this.syncPromise || Promise.resolve();
51362
+ return waitForOngoingSync.then(() => {
51363
+ const actionArray = [];
51364
+ if (this.useSnapshot) {
51365
+ const { cleanSnapshot } = this;
51366
+ const keys$1 = keys$4(cleanSnapshot);
51367
+ for (let i = 0, len = keys$1.length; i < len; i++) {
51368
+ const key = keys$1[i];
51369
+ // Return shallow spread of snapshot entries so top-level caller mutations
51370
+ // (e.g., .status =) don't persist into the snapshot. The snapshot itself
51371
+ // is a deep clone of draftStore, so no nested mutation reaches draftStore.
51372
+ actionArray.push({ ...cleanSnapshot[key] });
51373
+ }
51374
+ }
51375
+ else {
51376
+ // Killswitch (W-23515682): original pre-fix behavior — deep-clone every draft on
51377
+ // every read so the internal draft store is never exposed. O(N) clones per read.
51378
+ const { draftStore } = this;
51379
+ const keys$1 = keys$4(draftStore);
51380
+ for (let i = 0, len = keys$1.length; i < len; i++) {
51381
+ actionArray.push(clone$1(draftStore[keys$1[i]]));
51382
+ }
51383
+ }
51384
+ return actionArray;
51385
+ });
51386
+ }
51387
+ deleteDraft(id) {
51388
+ const deleteAction = () => {
51389
+ const draft = this.draftStore[id];
51390
+ if (draft !== undefined) {
51391
+ delete this.draftStore[id];
51392
+ delete this.cleanSnapshot[id];
51393
+ const durableKey = buildDraftDurableStoreKey(draft.tag, draft.id);
51394
+ return this.timeDurableOp('evictEntries', DRAFT_SEGMENT, () => this.durableStore.evictEntries([durableKey], DRAFT_SEGMENT));
51395
+ }
51396
+ return Promise.resolve();
51397
+ };
51398
+ return this.enqueueAction(deleteAction);
51399
+ }
51400
+ deleteByTag(tag) {
51401
+ const deleteAction = () => {
51402
+ const { draftStore } = this;
51403
+ const keys$1 = keys$4(draftStore);
51404
+ const durableKeys = [];
51405
+ for (let i = 0, len = keys$1.length; i < len; i++) {
51406
+ const key = keys$1[i];
51407
+ const action = draftStore[key];
51408
+ if (action.tag === tag) {
51409
+ delete draftStore[action.id];
51410
+ delete this.cleanSnapshot[action.id];
51411
+ durableKeys.push(buildDraftDurableStoreKey(action.tag, action.id));
51412
+ }
51413
+ }
51414
+ return this.timeDurableOp('evictEntries', DRAFT_SEGMENT, () => this.durableStore.evictEntries(durableKeys, DRAFT_SEGMENT));
51415
+ };
51416
+ return this.enqueueAction(deleteAction);
51417
+ }
51418
+ completeAction(queueOperations) {
51419
+ const action = () => {
51420
+ const durableStoreOperations = [];
51421
+ const { draftStore } = this;
51422
+ for (let i = 0, len = queueOperations.length; i < len; i++) {
51423
+ const operation = queueOperations[i];
51424
+ if (operation.type === QueueOperationType.Delete) {
51425
+ const action = draftStore[operation.id];
51426
+ if (action !== undefined) {
51427
+ delete draftStore[operation.id];
51428
+ delete this.cleanSnapshot[operation.id];
51429
+ const key = buildDraftDurableStoreKey(action.tag, action.id);
51430
+ durableStoreOperations.push({
51431
+ ids: [key],
51432
+ type: 'evictEntries',
51433
+ segment: DRAFT_SEGMENT,
51434
+ });
51435
+ }
51436
+ }
51437
+ else {
51438
+ const { action } = operation;
51439
+ const key = buildDraftDurableStoreKey(action.tag, action.id);
51440
+ draftStore[action.id] = action;
51441
+ this.cleanSnapshot[action.id] = clone$1(action);
51442
+ durableStoreOperations.push({
51443
+ type: 'setEntries',
51444
+ segment: DRAFT_SEGMENT,
51445
+ entries: {
51446
+ [key]: {
51447
+ data: operation.action,
51448
+ },
51449
+ },
51450
+ });
51451
+ }
51452
+ }
51453
+ return this.timeDurableOp('batchOperations', DRAFT_SEGMENT, () => this.durableStore.batchOperations(durableStoreOperations));
51454
+ };
51455
+ return this.enqueueAction(action);
51456
+ }
51457
+ getCount() {
51458
+ return keys$4(this.draftStore).length;
51459
+ }
51460
+ /**
51461
+ * Runs a write operation against the draft store, if the initial
51462
+ * revive is still in progress, the action gets enqueued to run once the
51463
+ * initial revive is complete
51464
+ * @param action
51465
+ * @returns a promise that is resolved once the action has run
51466
+ */
51467
+ enqueueAction(action) {
51468
+ const { syncPromise, writeQueue: pendingMerges } = this;
51469
+ // if the initial sync is done and existing operations have been run, no need to queue, just run
51470
+ if (syncPromise === undefined && pendingMerges.length === 0) {
51471
+ return action();
51472
+ }
51473
+ const deferred = new Promise((resolve, reject) => {
51474
+ this.writeQueue.push(() => {
51475
+ return action()
51476
+ .then((x) => {
51477
+ resolve(x);
51478
+ })
51479
+ .catch((err) => reject(err));
51480
+ });
51481
+ });
51482
+ return deferred;
51483
+ }
51484
+ /**
51485
+ * Revives the draft store from the durable store. Once the draft store is
51486
+ * revived, executes any queued up draft store operations that came in while
51487
+ * reviving
51488
+ */
51489
+ resyncDraftStore() {
51490
+ const sync = () => {
51491
+ this.syncPromise = this.durableStore
51492
+ .getAllEntries(DRAFT_SEGMENT)
51493
+ .then((durableEntries) => {
51494
+ if (durableEntries === undefined) {
51495
+ this.draftStore = {};
51496
+ this.cleanSnapshot = {};
51497
+ return this.runQueuedOperations();
51498
+ }
51499
+ const { draftStore } = this;
51500
+ const keys$1 = keys$4(durableEntries);
51501
+ for (let i = 0, len = keys$1.length; i < len; i++) {
51502
+ const entry = durableEntries[keys$1[i]];
51503
+ const action = entry.data;
51504
+ draftStore[action.id] = action;
51505
+ this.cleanSnapshot[action.id] = clone$1(action);
51506
+ }
51507
+ return this.runQueuedOperations();
51508
+ })
51509
+ .finally(() => {
51510
+ this.syncPromise = undefined;
51511
+ });
51512
+ return this.syncPromise;
51513
+ };
51514
+ // if there's an ongoing sync populating the in memory store, wait for it to complete before re-syncing
51515
+ const { syncPromise } = this;
51516
+ if (syncPromise === undefined) {
51517
+ return sync();
51518
+ }
51519
+ return syncPromise.then(() => {
51520
+ return sync();
51521
+ });
51522
+ }
51523
+ /**
51524
+ * Runs the operations that were queued up while reviving the
51525
+ * draft store from the durable store
51526
+ */
51527
+ runQueuedOperations() {
51528
+ const { writeQueue } = this;
51529
+ if (writeQueue.length > 0) {
51530
+ const queueItem = writeQueue.shift();
51531
+ if (queueItem !== undefined) {
51532
+ return queueItem().then(() => {
51533
+ return this.runQueuedOperations();
51534
+ });
51535
+ }
51536
+ }
51537
+ return Promise.resolve();
51538
+ }
51539
+ }
51540
+
51541
+ /**
51542
+ * Durable-store segment holding batch-completion journal entries (B2). Kept
51543
+ * separate from DRAFT_SEGMENT so journal rows are never mistaken for draft
51544
+ * actions by DurableDraftStore's resync (which parses every DRAFT_SEGMENT entry
51545
+ * as a DraftAction).
51546
+ */
51547
+ const DRAFT_BATCH_JOURNAL_SEGMENT = 'DRAFT_BATCH_JOURNAL';
51548
+ /**
51549
+ * The Performance Upload draft store. Extends {@link DurableDraftStore} — whose
51550
+ * body is byte-identical to today's shipped store — and serializes concurrent
51551
+ * writes through a `writeChain` so that when two queues share a single store
51552
+ * their snapshot-then-mutate sequences cannot interleave (which would otherwise
51553
+ * let one queue's queue-operation rewrites clobber another's). Only constructed
51554
+ * on the gate-ON code path; gate-off customers use a plain DurableDraftStore.
51555
+ */
51556
+ class PerformanceDraftStore extends DurableDraftStore {
51557
+ constructor(durableStore) {
51558
+ super(durableStore);
51559
+ // Serializes writes so two queues sharing this store can't interleave; see
51560
+ // class doc.
51561
+ this.writeChain = Promise.resolve();
51562
+ // Identifies THIS store session (process launch). Minted fresh per
51563
+ // construction and never persisted, so a journal entry stamped with a
51564
+ // different token is crash residue from a dead process (recoverable), while
51565
+ // one stamped with this token is a batch still in flight this session. Sibling
51566
+ // queues share one store instance (the Performance Upload model) and therefore
51567
+ // one token; see readRecoverableBatchJournals.
51568
+ this.sessionToken = uuidv4();
51569
+ this.durableStoreRef = durableStore;
51570
+ }
51571
+ /**
51572
+ * Records, BEFORE a batch is dispatched, the ids of the members about to be
51573
+ * sent as one all-or-none request (B2). If the app dies — or the durable
51574
+ * removal fails — between the server commit and the client removing the
51575
+ * committed members, the next startQueue reconcile reads this journal and
51576
+ * pins any still-Pending member to the key-preserving serial path instead
51577
+ * of re-batching it (which would drop its idempotency key and replay a
51578
+ * committed write). Journal rows live in their own segment and never flow
51579
+ * through the draft writeChain — they are keyed by batchId, independent of
51580
+ * the draft snapshot-then-mutate sequence the chain serializes.
51581
+ */
51582
+ writeBatchJournal(batchId, memberIds) {
51583
+ const entry = {
51584
+ // Stamp the live session token so a later reconcile can tell this
51585
+ // in-flight batch apart from crash residue (see sessionToken).
51586
+ data: { batchId, memberIds, owner: this.sessionToken },
51587
+ };
51588
+ return this.durableStoreRef.setEntries({ [batchId]: entry }, DRAFT_BATCH_JOURNAL_SEGMENT);
51589
+ }
51590
+ /** Removes a batch-completion journal entry once its members are durably resolved. */
51591
+ clearBatchJournal(batchId) {
51592
+ return this.durableStoreRef.evictEntries([batchId], DRAFT_BATCH_JOURNAL_SEGMENT);
51593
+ }
51594
+ /**
51595
+ * Returns only crash-residue journals — those written by a PRIOR store session
51596
+ * (owner token != this session's). Entries stamped with the live session's
51597
+ * token are batches still in flight this launch (possibly on a sibling queue
51598
+ * sharing this store) and are deliberately excluded, so a startup reconcile can
51599
+ * never quarantine a live peer batch's members out from under it. This is the
51600
+ * only read the queue reconcile uses; readBatchJournals (all entries) is kept
51601
+ * for inspection/tests.
51602
+ *
51603
+ * Relies on sibling queues sharing ONE store INSTANCE (the documented
51604
+ * Performance Upload model), so they share one session token. If two queues
51605
+ * were ever given separate store instances over the same durable store, the
51606
+ * token would have to move down to the durable store.
51607
+ */
51608
+ readRecoverableBatchJournals() {
51609
+ return this.readBatchJournals().then((journals) => journals.filter((journal) => journal.owner !== this.sessionToken));
51610
+ }
51611
+ /** Returns every persisted batch-completion journal entry (inspection/tests). */
51612
+ readBatchJournals() {
51613
+ return this.durableStoreRef
51614
+ .getAllEntries(DRAFT_BATCH_JOURNAL_SEGMENT)
51615
+ .then((entries) => {
51616
+ if (entries === undefined) {
51617
+ return [];
51618
+ }
51619
+ const keys$1 = keys$4(entries);
51620
+ const journals = [];
51621
+ for (let i = 0, len = keys$1.length; i < len; i++) {
51622
+ journals.push(entries[keys$1[i]].data);
51623
+ }
51624
+ return journals;
51625
+ });
51626
+ }
51627
+ getAllDrafts() {
51628
+ const waitForOngoingSync = this.syncPromise || Promise.resolve();
51629
+ // Also wait for any outstanding writes serialized through the
51630
+ // write chain so callers observe writes initiated before this read.
51631
+ const waitForOutstandingWrites = this.writeChain;
51632
+ return Promise.all([waitForOngoingSync, waitForOutstandingWrites]).then(() => {
51633
+ const { draftStore } = this;
51634
+ const keys$1 = keys$4(draftStore);
51635
+ const actionArray = [];
51636
+ for (let i = 0, len = keys$1.length; i < len; i++) {
51637
+ const key = keys$1[i];
51638
+ // clone draft so we don't expose the internal draft store
51639
+ actionArray.push(clone$1(draftStore[key]));
51640
+ }
51641
+ return actionArray;
51642
+ });
51643
+ }
51644
+ enqueueAction(action) {
51645
+ const { syncPromise, writeQueue: pendingMerges } = this;
51646
+ // During initial sync, queue the write to run after sync completes.
51647
+ if (syncPromise !== undefined || pendingMerges.length > 0) {
51648
+ const deferred = new Promise((resolve, reject) => {
51649
+ this.writeQueue.push(() => {
51650
+ return action()
51651
+ .then((x) => {
51652
+ resolve(x);
51653
+ })
51654
+ .catch((err) => reject(err));
51655
+ });
51656
+ });
51657
+ return deferred;
51658
+ }
51659
+ // Sync done. Serialize concurrent writes through writeChain so that
51660
+ // when two queues share this store, their snapshot-then-mutate
51661
+ // sequences cannot interleave.
51662
+ const next = this.writeChain.then(action, action);
51663
+ // Don't propagate failures into the chain — every caller awaits its own
51664
+ // result and a single rejection must not poison subsequent writes.
51665
+ this.writeChain = next.catch(() => undefined);
51666
+ return next;
51667
+ }
51668
+ }
51669
+
49482
51670
  const { hasOwnProperty: ObjectPrototypeHasOwnProperty$1$1 } = Object.prototype;
49483
51671
  const { keys: ObjectKeys$1$2, create: ObjectCreate$1$2 } = Object;
49484
51672
  const { stringify: JSONStringify$1 } = JSON;
@@ -80304,6 +82492,7 @@
80304
82492
  const DRAFT_QUEUE_ACTION_UPDATED = 'draft-queue-action-updated';
80305
82493
  const DRAFT_QUEUE_ACTION_FAILED = 'draft-queue-action-failed';
80306
82494
  const DRAFT_QUEUE_ACTION_UPLOAD_ERROR = 'draft-queue-action-upload-error';
82495
+ const DRAFT_QUEUE_BATCH_RECOVERY_FAILED = 'draft-queue-batch-recovery-failed';
80307
82496
  const DRAFT_QUEUE_TOTAL_MERGE_ACTIONS_CALLS = 'draft-queue-total-mergeActions-calls';
80308
82497
  // Emitted every time a draft's Idempotency-Key is rotated (replaced with a fresh uuid).
80309
82498
  // Used to triage the WOLI duplicate-record investigation in Splunk — correlate by draftId
@@ -80354,6 +82543,11 @@
80354
82543
  __nimbus.plugins.JSLoggerPlugin !== undefined
80355
82544
  ? __nimbus.plugins.JSLoggerPlugin
80356
82545
  : undefined;
82546
+ function logInfo(message) {
82547
+ if (nimbusLogger) {
82548
+ nimbusLogger.logInfo(message);
82549
+ }
82550
+ }
80357
82551
  function reportGraphqlQueryParseError(err) {
80358
82552
  const error = normalizeError$2(err);
80359
82553
  const errorCode = GRAPHQL_QUERY_PARSE_ERROR;
@@ -80399,9 +82593,7 @@
80399
82593
  ldsMobileInstrumentation$2.incrementCounter(GRAPHQL_SNAPSHOT_REFRESH_UNDEFINED);
80400
82594
  }
80401
82595
  function reportDraftActionEvent(state, draftCount, message) {
80402
- if (nimbusLogger) {
80403
- nimbusLogger.logInfo(`Draft action event: ${state}, depth: ${draftCount}${message ? `, message: ${message}` : ''}`);
80404
- }
82596
+ logInfo(`Draft action event: ${state}, depth: ${draftCount}${message ? `, message: ${message}` : ''}`);
80405
82597
  switch (state) {
80406
82598
  case 'added':
80407
82599
  ldsMobileInstrumentation$2.incrementCounter(DRAFT_QUEUE_ACTION_ADDED);
@@ -80601,6 +82793,26 @@
80601
82793
  });
80602
82794
  ldsMobileInstrumentation$2.error(normalized, DRAFT_QUEUE_ACTION_UPLOAD_ERROR);
80603
82795
  }
82796
+ /**
82797
+ * Reports a batch-journal crash-recovery failure on the Performance Upload
82798
+ * batching path (B2, currently feature-gated and dormant): a maybe-committed
82799
+ * all-or-none batch's members could not be durably quarantined this session, a
82800
+ * startup reconcile could not surface a prior crash's batch, or a resolved journal
82801
+ * row could not be cleared. Each is otherwise visible only in device logs; this
82802
+ * surfaces it to o11y (greppable log line + reason-tagged counter) so the batching
82803
+ * path can be monitored when it ships.
82804
+ *
82805
+ * PII: carries ONLY the bounded reason code and the queue depth — never a record
82806
+ * id, draft id, tag, or free-text error message (device-log PII policy).
82807
+ */
82808
+ function reportBatchRecoveryFailure(reason, draftCount) {
82809
+ if (nimbusLogger) {
82810
+ nimbusLogger.logInfo(`Batch recovery failed: reason=${reason}, depth=${draftCount}`);
82811
+ }
82812
+ ldsMobileInstrumentation$2.incrementCounter(DRAFT_QUEUE_BATCH_RECOVERY_FAILED, 1, true, {
82813
+ reason,
82814
+ });
82815
+ }
80604
82816
  /**
80605
82817
  * Emits a single greppable Splunk log line (and an o11y counter) every time a draft's
80606
82818
  * Idempotency-Key is rotated — i.e. replaced with a fresh uuid rather than reused on
@@ -80630,9 +82842,7 @@
80630
82842
  });
80631
82843
  }
80632
82844
  function reportDraftQueueState(state, draftCount) {
80633
- if (nimbusLogger) {
80634
- nimbusLogger.logInfo(`Draft state changed: ${state}, depth: ${draftCount}`);
80635
- }
82845
+ logInfo(`Draft state changed: ${state}, depth: ${draftCount}`);
80636
82846
  switch (state) {
80637
82847
  case 'started':
80638
82848
  ldsMobileInstrumentation$2.incrementCounter(DRAFT_QUEUE_STATE_STARTED);
@@ -80745,6 +82955,26 @@
80745
82955
 
80746
82956
  const DEFAULT_FIELD_LAST_MODIFIED_DATE$1 = 'LastModifiedDate';
80747
82957
  const DEFAULT_FIELD_CREATED_DATE$1 = 'CreatedDate';
82958
+ // UI-API Records Batch endpoint. Reuses the same baseUri pattern as the
82959
+ // generated record adapters (see e.g. getUiApiRecordsByRecordId.ts).
82960
+ const BATCH_BASE_URI = '/services/data/v68.0';
82961
+ const BATCH_BASE_PATH = '/ui-api/records/batch';
82962
+ // Non-attributive error codes returned by an allOrNone=true batch. When one
82963
+ // operation fails, UI-API rolls the whole batch back and marks the *innocent*
82964
+ // siblings with one of these codes — they are not themselves the offender:
82965
+ // - ALL_OR_NONE_OPERATION_ROLLED_BACK: the canonical all-or-none rollback
82966
+ // marker applied to every sibling that would otherwise have succeeded.
82967
+ // - PROCESSING_HALTED: applied to operations skipped because processing was
82968
+ // halted (e.g. a dependency/composite-ordering stop) before they ran.
82969
+ // The true offender is any member whose errorCode is neither of these.
82970
+ const PROCESSING_HALTED_ERROR_CODE = 'PROCESSING_HALTED';
82971
+ const ALL_OR_NONE_ROLLED_BACK_ERROR_CODE = 'ALL_OR_NONE_OPERATION_ROLLED_BACK';
82972
+ // Codes that mark a batch member as a rolled-back innocent sibling rather than
82973
+ // the operation that actually caused the batch to fail.
82974
+ const ROLLBACK_SIBLING_ERROR_CODES = new Set([
82975
+ PROCESSING_HALTED_ERROR_CODE,
82976
+ ALL_OR_NONE_ROLLED_BACK_ERROR_CODE,
82977
+ ]);
80748
82978
  class AbstractResourceRequestActionHandler {
80749
82979
  constructor(draftQueue, networkAdapter, getLuvio, recordService) {
80750
82980
  this.draftQueue = draftQueue;
@@ -80907,23 +83137,7 @@
80907
83137
  actionDataChanged = true;
80908
83138
  }
80909
83139
  }
80910
- if (response.status === HttpStatusCode$2.BadRequest &&
80911
- this.isBackdatingError(response.body, action)) {
80912
- updatedAction.timestamp = Date.now();
80913
- updatedAction.data.body.fields = {
80914
- ...updatedAction.data.body.fields,
80915
- LastModifiedDate: new Date(updatedAction.timestamp).toISOString(),
80916
- };
80917
- // The record content changed (LastModifiedDate is bumped), so this is a NEW
80918
- // logical write. Rotate the idempotency key so the server treats it as such
80919
- // rather than deduping it against the stale, pre-backdate request. This is a
80920
- // legitimate rotation — the request body is modified — unlike the generic-400
80921
- // fallback above which must preserve the key. @W-23445154
80922
- if (this.hasIdempotencySupport() &&
80923
- updatedAction.data.headers[HTTP_HEADER_IDEMPOTENCY_KEY] !== undefined) {
80924
- updatedAction.data.headers[HTTP_HEADER_IDEMPOTENCY_KEY] = uuidv4();
80925
- reportIdempotencyKeyRotated(updatedAction.targetId, 'backdating-collision');
80926
- }
83140
+ if (this.applyBackdatingRetryIfNeeded(response, updatedAction)) {
80927
83141
  shouldRetry = true;
80928
83142
  actionDataChanged = true;
80929
83143
  }
@@ -81021,6 +83235,113 @@
81021
83235
  };
81022
83236
  await actionErrored(retryingAction, true, retryDelayInMs, true);
81023
83237
  }
83238
+ /**
83239
+ * If `response` is a LastModifiedDate backdating collision (@W-23445154) for
83240
+ * `action`, mutate the action so it is treated as a NEW logical write — bump
83241
+ * its timestamp + LastModifiedDate, and rotate the idempotency key when one
83242
+ * is present (the body changed, so the server must not dedupe it against the
83243
+ * stale pre-backdate request) — and return true. Otherwise return false and
83244
+ * leave the action untouched.
83245
+ *
83246
+ * Extracted from the single-action handleAction path so the batch upload
83247
+ * path (UiApiActionHandler.classifyBatchFailure) treats a backdating
83248
+ * collision identically instead of diverging (B4).
83249
+ */
83250
+ applyBackdatingRetryIfNeeded(response, action) {
83251
+ if (response.status !== HttpStatusCode$2.BadRequest ||
83252
+ !this.isBackdatingError(response.body, action)) {
83253
+ return false;
83254
+ }
83255
+ action.timestamp = Date.now();
83256
+ action.data.body.fields = {
83257
+ ...action.data.body.fields,
83258
+ LastModifiedDate: new Date(action.timestamp).toISOString(),
83259
+ };
83260
+ if (this.hasIdempotencySupport() &&
83261
+ action.data.headers[HTTP_HEADER_IDEMPOTENCY_KEY] !== undefined) {
83262
+ action.data.headers[HTTP_HEADER_IDEMPOTENCY_KEY] = uuidv4();
83263
+ reportIdempotencyKeyRotated(action.targetId, 'backdating-collision');
83264
+ }
83265
+ return true;
83266
+ }
83267
+ /**
83268
+ * Classifies a failure surfaced by the batch upload path — either a non-ok
83269
+ * batch envelope (whole request failed) or a single offending operation's
83270
+ * error result — into a retry-or-terminal decision, reusing the SAME policy
83271
+ * the single-action handleAction path applies to the same failure shapes so
83272
+ * the two do not diverge (B4). The caller routes the decision: a retry goes
83273
+ * through retryOrCap (so the durable attempt cap and backoff apply), a
83274
+ * terminal result errors the action with no retry.
83275
+ *
83276
+ * Note the idempotency-key state machine in handleAction (key rotation on
83277
+ * recognized idempotency error codes, isIdempotencySupported toggling) is
83278
+ * intentionally NOT shared here: those branches are keyed on the request's
83279
+ * per-action Idempotency-Key, which the batch envelope does not carry.
83280
+ *
83281
+ * commitAmbiguous distinguishes retryable failures that MAY have committed the
83282
+ * all-or-none batch at the origin (a 5xx/408/dropped-connection — or a generic
83283
+ * gateway 400 — returned after the write landed, with the success response lost
83284
+ * in transit) from retryable failures that definitively rolled back or never
83285
+ * reached the origin (a 429 rate-limit, a backdating collision the server
83286
+ * evaluated and rejected). The batch envelope carries no server-visible
83287
+ * idempotency key, so a commit-ambiguous failure cannot be safely re-sent; the
83288
+ * caller returns NETWORK_ERROR and the queue surfaces the members for manual
83289
+ * resolution instead of auto-replaying them (Option A). A definitively
83290
+ * rolled-back failure committed nothing, so a keyless re-batch is replay-safe.
83291
+ */
83292
+ classifyBatchFailure(response, action) {
83293
+ const status = response.status ?? 0;
83294
+ // Transient / infrastructure failures — retry, honoring any server-sent
83295
+ // Retry-After (mirrors handleAction's 408/503 handling). 0 = no response
83296
+ // (connection dropped / suspended in flight); 408 Request Timeout; 429
83297
+ // Too Many Requests; 5xx server errors.
83298
+ if (status === 0 ||
83299
+ status === 408 ||
83300
+ status === 429 ||
83301
+ status >= HttpStatusCode$2.ServerError) {
83302
+ return {
83303
+ shouldRetry: true,
83304
+ retryDelayInMs: getRetryAfterInMs(response.headers),
83305
+ actionDataChanged: false,
83306
+ // 0 (dropped connection), 408 (timeout), and 5xx (gateway/LB/origin
83307
+ // server error) can each be returned AFTER the all-or-none batch
83308
+ // committed at the origin with the 2xx lost — commit-ambiguous, like
83309
+ // handleAction's NoResponse/ServiceUnavailable path. A 429 is a rate
83310
+ // limiter rejecting the request before it is processed, so nothing
83311
+ // committed and a keyless re-batch is replay-safe.
83312
+ commitAmbiguous: status !== 429,
83313
+ };
83314
+ }
83315
+ // LastModifiedDate backdating collision — bump the record + retry, same
83316
+ // as the single-action path. The server evaluated and rejected the write,
83317
+ // so the batch rolled back — not commit-ambiguous.
83318
+ if (this.applyBackdatingRetryIfNeeded(response, action)) {
83319
+ return { shouldRetry: true, actionDataChanged: true, commitAmbiguous: false };
83320
+ }
83321
+ // A 400 with no recognizable server-generated error body is an edge- or
83322
+ // gateway-injected rejection whose commit status is genuinely ambiguous: an
83323
+ // edge proxy or load balancer can return a bare 400 AFTER the origin has
83324
+ // already committed the all-or-none batch. The envelope carries no
83325
+ // server-visible idempotency key, so a keyless re-send could double-apply.
83326
+ // Treat it as commit-ambiguous (like status 0/408/5xx) so the queue does NOT
83327
+ // auto-replay it and surfaces the members for manual resolution instead.
83328
+ if (status === HttpStatusCode$2.BadRequest && !this.isServerGeneratedError(response.body)) {
83329
+ return { shouldRetry: true, actionDataChanged: false, commitAmbiguous: true };
83330
+ }
83331
+ // A real, server-generated client error (validation, permissions, a
83332
+ // deterministic 4xx) — terminal. Retrying it unchanged would only be
83333
+ // rejected again.
83334
+ return { shouldRetry: false, actionDataChanged: false, commitAmbiguous: false };
83335
+ }
83336
+ /**
83337
+ * Reports an upload failure to o11y using the same channel and attempt
83338
+ * numbering as the single-action handleAction catch. Exposed so the batch
83339
+ * upload path surfaces a thrown/deterministic failure to telemetry instead
83340
+ * of swallowing it (B4).
83341
+ */
83342
+ reportUploadFailure(error, action) {
83343
+ reportDraftActionUploadError(error, this.handlerId, this.getUploadRetryCount(action) + 1);
83344
+ }
81024
83345
  async handleActionEnqueued(action) {
81025
83346
  const impactedKeys = await this.recordService.setSideEffectsForActions([action], 'enqueued');
81026
83347
  await this.recordService.reapplyRecordSideEffects(impactedKeys, 'enqueued');
@@ -81483,6 +83804,23 @@
81483
83804
 
81484
83805
  const LDS_ACTION_HANDLER_ID = 'LDS_ACTION_HANDLER';
81485
83806
  const LDS_ACTION_METADATA_API_NAME = 'LDS_ACTION_METADATA_API_NAME';
83807
+ // Request headers/query params that the UI-API records/batch envelope can omit
83808
+ // without changing the write, so their presence must not disqualify an action
83809
+ // from batching (see isActionBatchable, B1). Everything not listed here does
83810
+ // change server behavior and forces single-action dispatch.
83811
+ const BATCH_OMITTABLE_HEADERS = new Set([HTTP_HEADER_IDEMPOTENCY_KEY]);
83812
+ const BATCH_OMITTABLE_QUERY_PARAMS = new Set(['includeFieldsInBody']);
83813
+ // Diagnostic log line for the local-experimental batching path. Mirrors the
83814
+ // helper in PerformanceDraftQueue so handler-side events show up in the same
83815
+ // device-log stream with the same `Batch ` prefix.
83816
+ function batchLog(line) {
83817
+ try {
83818
+ globalThis.__nimbus?.plugins?.JSLoggerPlugin?.logInfo(`Batch ${line}`);
83819
+ }
83820
+ catch {
83821
+ // debug-only
83822
+ }
83823
+ }
81486
83824
  class UiApiActionHandler extends AbstractResourceRequestActionHandler {
81487
83825
  constructor(getLuvio, networkAdapter, draftQueue, getRecordAdapter, objectInfoService, isDraftId, recordService, sideEffectService, durableRecordStore) {
81488
83826
  super(draftQueue, networkAdapter, getLuvio, recordService);
@@ -81665,6 +84003,500 @@
81665
84003
  getIdFromResponseBody(responseBody) {
81666
84004
  return responseBody.id;
81667
84005
  }
84006
+ /**
84007
+ * The UI-API records/batch envelope carries only { type, records:[{ apiName?,
84008
+ * fields }] } per operation — it has no place for per-operation headers or
84009
+ * query params (see buildBatchOperations / executeBatch, which send empty
84010
+ * headers and queryParams). An action whose request relies on a header or
84011
+ * query param that changes the *write* would silently lose that semantic if
84012
+ * folded into a batch, so it must be dispatched through single-action
84013
+ * handleAction (which sends the request verbatim) instead (B1).
84014
+ *
84015
+ * Two request members are present on essentially every update/delete draft
84016
+ * and are safe for the batch to omit, so they must NOT disqualify batching
84017
+ * (doing so would disable it wholesale):
84018
+ * - Idempotency-Key header: dropped from the batch envelope by design (the
84019
+ * batch has no per-op key). Its absence weakens replay protection, which
84020
+ * is addressed by journaled batch completion (B2) — it is not a per-op
84021
+ * write-semantic change, so it does not block batching here.
84022
+ * - includeFieldsInBody query param: only shapes the response body, not the
84023
+ * write; updateRecord adds it unconditionally.
84024
+ * Any OTHER header (e.g. If-Unmodified-Since — a conditional update that would
84025
+ * become an unconditional lost-update) or query param (useDefaultRule,
84026
+ * triggerUserEmail, triggerOtherEmail — assignment-rule / email side effects)
84027
+ * does change server behavior and makes the action non-batchable.
84028
+ *
84029
+ * The request BODY is checked too: buildBatchOperations copies ONLY `fields`
84030
+ * into the batch UPDATE/DELETE operation, so any other body member — e.g.
84031
+ * `allowSaveOnDuplicate`, which changes duplicate-rule handling — would be
84032
+ * silently dropped if the action were batched. An action whose body carries
84033
+ * anything beyond `fields` is therefore dispatched single-action (handleAction
84034
+ * sends the body verbatim), preserving those semantics.
84035
+ *
84036
+ * resolveResourceRequest only rewrites basePath/body/urlParams (never headers
84037
+ * or queryParams), and a body option like allowSaveOnDuplicate survives that
84038
+ * rewrite, so the raw action.data is authoritative for this check and we avoid
84039
+ * the cost of resolving here.
84040
+ */
84041
+ isActionBatchable(action) {
84042
+ const request = action.data;
84043
+ if (request.headers !== undefined) {
84044
+ const headerNames = keys$3(request.headers);
84045
+ for (let i = 0; i < headerNames.length; i++) {
84046
+ if (!BATCH_OMITTABLE_HEADERS.has(headerNames[i])) {
84047
+ return false;
84048
+ }
84049
+ }
84050
+ }
84051
+ if (request.queryParams !== undefined) {
84052
+ const queryNames = keys$3(request.queryParams);
84053
+ for (let i = 0; i < queryNames.length; i++) {
84054
+ if (!BATCH_OMITTABLE_QUERY_PARAMS.has(queryNames[i])) {
84055
+ return false;
84056
+ }
84057
+ }
84058
+ }
84059
+ // Only `fields` is carried into the batch op (buildBatchOperations); any
84060
+ // other body member (e.g. allowSaveOnDuplicate) would be dropped, so it
84061
+ // disqualifies the action from batching.
84062
+ const body = request.body;
84063
+ if (body !== undefined && body !== null) {
84064
+ const bodyKeys = keys$3(body);
84065
+ for (let i = 0; i < bodyKeys.length; i++) {
84066
+ if (bodyKeys[i] !== 'fields') {
84067
+ return false;
84068
+ }
84069
+ }
84070
+ }
84071
+ return true;
84072
+ }
84073
+ /**
84074
+ * Sends a single UI-API records/batch request that combines all given actions.
84075
+ * Uses allOrNone=true so any failure rolls back the whole batch on the server,
84076
+ * preserving today's "one bad mutation blocks the queue" UX.
84077
+ *
84078
+ * Per-action result routing pairs each result with its action by index. The
84079
+ * endpoint exposes no per-result correlation key (no client-supplied
84080
+ * referenceId; DELETE/error results carry no id) and does not contractually
84081
+ * guarantee results order matches operations order, so index pairing is
84082
+ * guarded: the result count must equal the action count, and each UPDATE
84083
+ * result's returned record id must match the action's resolved recordId.
84084
+ * - Success (hasErrors === false): each result is paired with its action by
84085
+ * index and forwarded to actionCompleted in order.
84086
+ * - Server-reported failure (hasErrors === true): exactly one result carries
84087
+ * the real errorCode; the innocent siblings carry a non-attributive
84088
+ * rollback code (ALL_OR_NONE_OPERATION_ROLLED_BACK or PROCESSING_HALTED).
84089
+ * The batch rolled back, so a retryable offender parks EVERY member
84090
+ * together (C4/R4-3 — no self-driven prefix/suffix retry) charging a retry
84091
+ * attempt only to the offender, and a deterministic offender is errored
84092
+ * terminally while innocent siblings are left Pending (B5); returns
84093
+ * ACTION_ERRORED (journal cleared, replay-safe).
84094
+ * - Commit-ambiguous transport failure (network throw, empty-body or
84095
+ * misaligned 2xx, result count/id mismatch, or a retryable 5xx/408/dropped-
84096
+ * connection or generic-400 envelope that may have committed with the 2xx
84097
+ * lost): return NETWORK_ERROR WITHOUT retrying. The batch envelope carries
84098
+ * no server-visible idempotency key, so it cannot be safely re-sent; the
84099
+ * queue surfaces the members for manual resolution and retains/clears the
84100
+ * B2 journal instead of auto-replaying them (Option A / R4-4).
84101
+ */
84102
+ async handleBatchActions(actions, actionCompleted, actionErrored) {
84103
+ return this.executeBatch(actions, actionCompleted, actionErrored);
84104
+ }
84105
+ async executeBatch(actions, actionCompleted, actionErrored) {
84106
+ if (actions.length === 0) {
84107
+ return ProcessActionResult.NO_ACTION_TO_PROCESS;
84108
+ }
84109
+ try {
84110
+ const operations = this.buildBatchOperations(actions);
84111
+ batchLog(`sending size=${actions.length}`);
84112
+ const batchRequest = {
84113
+ baseUri: BATCH_BASE_URI,
84114
+ basePath: BATCH_BASE_PATH,
84115
+ method: 'post',
84116
+ body: { allOrNone: true, operations },
84117
+ urlParams: {},
84118
+ queryParams: {},
84119
+ headers: {},
84120
+ priority: 'normal',
84121
+ };
84122
+ // Attribute the batch request to the head action's observability
84123
+ // context. A batch collapses N actions into one request, so there is
84124
+ // no per-member correlation; the head action's context is used as a
84125
+ // representative, mirroring the single-action dispatch path in
84126
+ // handleAction. (If members carried differing contexts, only the
84127
+ // head's is reported.)
84128
+ const dispatchOptions = actions[0].observabilityContext !== undefined
84129
+ ? {
84130
+ requestCorrelator: {
84131
+ observabilityContext: actions[0].observabilityContext,
84132
+ },
84133
+ }
84134
+ : {};
84135
+ const response = await this.networkAdapter(batchRequest, dispatchOptions);
84136
+ if (!response.ok) {
84137
+ // Classify the envelope with the SAME body-aware policy the
84138
+ // single-action path uses (classifyBatchFailure) instead of a
84139
+ // status-only check. Three outcomes, routing EVERY member (not just
84140
+ // the head — C4):
84141
+ // - Commit-AMBIGUOUS (5xx/408/dropped connection, or a generic
84142
+ // gateway 400): may have committed at the origin with the 2xx
84143
+ // lost, and the batch carries no server-visible idempotency key,
84144
+ // so a keyless re-send could double-apply. Return NETWORK_ERROR
84145
+ // WITHOUT retrying; the queue surfaces the members for manual
84146
+ // resolution and retains/clears the journal instead of
84147
+ // auto-replaying them (Option A / R4-4).
84148
+ // - Definitively rolled back but retryable (429 rate-limit,
84149
+ // backdating collision the server evaluated): committed nothing,
84150
+ // so a keyless re-batch is replay-safe — park every member for a
84151
+ // bounded, backed-off retry (capped, honoring Retry-After).
84152
+ // - Deterministic server-generated client error: terminal, so a
84153
+ // rejected batch is not retried forever (C3/B4).
84154
+ const decision = this.classifyBatchFailure(response, actions[0]);
84155
+ batchLog(`http error status=${response.status ?? 0} retry=${decision.shouldRetry}` +
84156
+ ` ambiguous=${decision.commitAmbiguous} size=${actions.length}`);
84157
+ if (decision.shouldRetry) {
84158
+ if (decision.commitAmbiguous) {
84159
+ return ProcessActionResult.NETWORK_ERROR;
84160
+ }
84161
+ await this.retryBatchMembers(actions, response, actionErrored, decision.retryDelayInMs);
84162
+ }
84163
+ else {
84164
+ await this.terminalBatchMembers(actions, response, actionErrored);
84165
+ }
84166
+ return ProcessActionResult.ACTION_ERRORED;
84167
+ }
84168
+ if (response.body === undefined || response.body === null) {
84169
+ // 2xx with no body — a misaligned/empty response. We got a success
84170
+ // status but cannot confirm what committed, so this is
84171
+ // commit-AMBIGUOUS: return NETWORK_ERROR so the queue surfaces the
84172
+ // members for manual resolution and retains/clears the journal,
84173
+ // rather than auto-replaying a keyless batch that may have committed
84174
+ // (Option A / R4-4).
84175
+ batchLog(`empty body ambiguous size=${actions.length}`);
84176
+ return ProcessActionResult.NETWORK_ERROR;
84177
+ }
84178
+ const body = response.body;
84179
+ // Both branches below pair body.results to actions positionally.
84180
+ // The UI-API batch endpoint supplies no per-result correlation key
84181
+ // (no client referenceId, and DELETE/error results carry no id) and
84182
+ // does not contractually guarantee results order matches operations
84183
+ // order, so a short/over-length response would silently mis-pair
84184
+ // completions with the wrong drafts. Refuse to pair positionally
84185
+ // unless the counts match exactly; on mismatch complete nothing. This is
84186
+ // commit-AMBIGUOUS (2xx received, response untrustworthy): return
84187
+ // NETWORK_ERROR so the queue surfaces the members for manual resolution
84188
+ // and retains/clears the journal (Option A / R4-4).
84189
+ if (!isArray$3$1(body.results) || body.results.length !== actions.length) {
84190
+ batchLog(`result count mismatch expected=${actions.length}` +
84191
+ ` got=${isArray$3$1(body.results) ? body.results.length : 'none'}` +
84192
+ ` ambiguous`);
84193
+ return ProcessActionResult.NETWORK_ERROR;
84194
+ }
84195
+ if (body.hasErrors === false) {
84196
+ // Strict success envelope (R4-4): the endpoint claims the batch
84197
+ // succeeded, but we only complete (and remove) a draft when the
84198
+ // member's OWN result confirms it committed — otherwise it is
84199
+ // commit-AMBIGUOUS: return NETWORK_ERROR so the queue surfaces the
84200
+ // members for manual resolution and retains/clears the journal,
84201
+ // rather than silently dropping a write that may never have landed.
84202
+ // (CREATE is never batched; DELETE carries no body, so only its 2xx
84203
+ // status is required — id echo is UPDATE-only.)
84204
+ for (let i = 0; i < actions.length; i++) {
84205
+ const action = actions[i];
84206
+ const method = action.data.method;
84207
+ const result = body.results[i];
84208
+ const childStatus = result?.statusCode;
84209
+ if (childStatus === undefined ||
84210
+ childStatus < HttpStatusCode$2.Ok ||
84211
+ childStatus >= 300) {
84212
+ batchLog(`success envelope unconfirmed child index=${i}` +
84213
+ ` status=${childStatus ?? 'none'} ambiguous`);
84214
+ return ProcessActionResult.NETWORK_ERROR;
84215
+ }
84216
+ if (method === 'patch') {
84217
+ const resolved = this.resolveResourceRequest(action.data);
84218
+ const expectedId = resolved.urlParams['recordId'];
84219
+ const returnedId = !isArray$3$1(result.result)
84220
+ ? result.result.id
84221
+ : undefined;
84222
+ // UPDATE must echo a matching record id; a missing or
84223
+ // mismatched id is commit-AMBIGUOUS.
84224
+ if (returnedId === undefined || returnedId !== expectedId) {
84225
+ // Log the reason code + whether an id came back, NOT the
84226
+ // ids themselves: expectedId/returnedId are record ids,
84227
+ // and the device-log PII policy (metrics.ts) forbids
84228
+ // record ids in device logs (4065667273).
84229
+ batchLog(`success envelope bad update id index=${i}` +
84230
+ ` returned=${returnedId === undefined ? 'none' : 'mismatch'} ambiguous`);
84231
+ return ProcessActionResult.NETWORK_ERROR;
84232
+ }
84233
+ }
84234
+ }
84235
+ batchLog(`completed size=${actions.length}`);
84236
+ for (let i = 0; i < actions.length; i++) {
84237
+ const action = actions[i];
84238
+ const result = body.results[i];
84239
+ const recordBody = result !== undefined && !isArray$3$1(result.result)
84240
+ ? result.result
84241
+ : undefined;
84242
+ const perActionResponse = {
84243
+ status: (result?.statusCode ?? HttpStatusCode$2.Ok),
84244
+ statusText: 'OK',
84245
+ ok: true,
84246
+ headers: {},
84247
+ body: recordBody,
84248
+ };
84249
+ await actionCompleted({
84250
+ ...action,
84251
+ response: perActionResponse,
84252
+ status: DraftActionStatus.Completed,
84253
+ });
84254
+ }
84255
+ return ProcessActionResult.ACTION_SUCCEEDED;
84256
+ }
84257
+ // hasErrors is neither false (clean success, handled above) nor true
84258
+ // (attributable failure, handled below): the envelope is incomplete
84259
+ // or malformed — we can neither confirm a commit nor attribute a
84260
+ // failure, so treat it as commit-AMBIGUOUS (return NETWORK_ERROR, as
84261
+ // above).
84262
+ if (body.hasErrors !== true) {
84263
+ batchLog(`success envelope missing hasErrors flag ambiguous`);
84264
+ return ProcessActionResult.NETWORK_ERROR;
84265
+ }
84266
+ // Locate the action whose result is the real failure. Under
84267
+ // allOrNone=true the server rolls the whole batch back and marks the
84268
+ // innocent siblings with a non-attributive code (B3): the canonical
84269
+ // all-or-none marker ALL_OR_NONE_OPERATION_ROLLED_BACK, or
84270
+ // PROCESSING_HALTED for operations skipped before they ran. The true
84271
+ // offender is the member whose errorCode is neither — matching only
84272
+ // PROCESSING_HALTED would mis-pick an innocent rolled-back sibling as
84273
+ // the offender and leave the real invalid action Pending forever.
84274
+ let offendingIndex = -1;
84275
+ let offendingResult;
84276
+ for (let i = 0; i < body.results.length; i++) {
84277
+ const r = body.results[i];
84278
+ if (r === undefined)
84279
+ continue;
84280
+ if (!isArray$3$1(r.result))
84281
+ continue;
84282
+ const code = r.result[0]?.errorCode;
84283
+ if (code !== undefined && !ROLLBACK_SIBLING_ERROR_CODES.has(code)) {
84284
+ offendingIndex = i;
84285
+ offendingResult = r;
84286
+ break;
84287
+ }
84288
+ }
84289
+ // Unattributed: every failing result carries only a non-attributive
84290
+ // rollback-sibling code with no clear offender. Treat as a transient
84291
+ // server error — retry the whole batch.
84292
+ if (offendingIndex < 0 || offendingResult === undefined) {
84293
+ batchLog(`hasErrors size=${actions.length} unattributed retry=true`);
84294
+ await this.retryBatchMembers(actions, response, actionErrored);
84295
+ return ProcessActionResult.ACTION_ERRORED;
84296
+ }
84297
+ const offendingAction = actions[offendingIndex];
84298
+ const errorBody = offendingResult?.result ?? [];
84299
+ const errorResponse = {
84300
+ status: (offendingResult?.statusCode ??
84301
+ HttpStatusCode$2.BadRequest),
84302
+ statusText: 'Bad Request',
84303
+ ok: false,
84304
+ headers: {},
84305
+ body: errorBody,
84306
+ };
84307
+ const offendingErrorCode = isArray$3$1(offendingResult?.result)
84308
+ ? offendingResult.result[0]?.errorCode ?? 'unknown'
84309
+ : 'unknown';
84310
+ // Classify the offender, reusing the single-action retry/terminal
84311
+ // policy (B4): a transient/ambiguous failure retries through the
84312
+ // durable cap, a deterministic server-generated client error is
84313
+ // terminal so the user can fix or remove it. classifyBatchFailure may
84314
+ // mutate offendingAction (e.g. a backdating bump) and reports
84315
+ // actionDataChanged so the mutation is persisted before the retry.
84316
+ // The batch endpoint returns each member's failure as a UiApiError[]
84317
+ // (the offender loop above keys on r.result[0].errorCode). The shared
84318
+ // classifiers reused by classifyBatchFailure — isBackdatingError /
84319
+ // isServerGeneratedError — and the single-action handleAction path
84320
+ // expect the origin's structured enhancedErrorType envelope, where a
84321
+ // backdating collision surfaces as
84322
+ // { enhancedErrorType: 'RecordError', output: { errors: [...] } }.
84323
+ // Left as a bare array, isBackdatingError never matches, so a batched
84324
+ // LastModifiedDate CollisionDetectedException would be errored
84325
+ // terminally instead of getting the single-action backdating bump +
84326
+ // key-rotate + retry (B4). Wrap the member's error array in that
84327
+ // envelope for CLASSIFICATION ONLY. This is behavior-preserving for
84328
+ // every non-collision offender: isServerGeneratedError is already
84329
+ // true for a UiApiError[] offender, so the terminal decision is
84330
+ // unchanged; only a genuine collision (which also requires the action
84331
+ // to carry a LastModifiedDate field) now recovers. The error surfaced
84332
+ // to the draft (errorResponse) keeps the raw array — unchanged.
84333
+ const classificationResponse = {
84334
+ ...errorResponse,
84335
+ body: {
84336
+ enhancedErrorType: 'RecordError',
84337
+ output: {
84338
+ errors: isArray$3$1(offendingResult?.result) ? offendingResult.result : [],
84339
+ },
84340
+ },
84341
+ };
84342
+ const decision = this.classifyBatchFailure(classificationResponse, offendingAction);
84343
+ batchLog(`failed size=${actions.length} offendingIndex=${offendingIndex}` +
84344
+ ` errorCode=${offendingErrorCode} retry=${decision.shouldRetry}`);
84345
+ if (decision.shouldRetry) {
84346
+ // Transient/ambiguous offender: the whole batch rolled back and
84347
+ // NONE committed. Parking only the offender and leaving the innocent
84348
+ // siblings Pending would let the queue immediately re-collect them
84349
+ // into a fresh, backoff-free batch — and if that batch's new head
84350
+ // draws the same transient response, [A,B,C] retries as [B,C] then
84351
+ // [C], up to 25 + 24 + ... + 1 = 325 operations before the first
84352
+ // backoff expires (R4-3). Park EVERY member together so the whole
84353
+ // batch backs off — but charge a durable retry attempt ONLY to the
84354
+ // offender (via retryOrCap). The innocent rolled-back siblings
84355
+ // produced no error of their own, so they are parked for the same
84356
+ // backoff WITHOUT consuming their retry budget; charging them could
84357
+ // push an innocent later action to its cap for another action's
84358
+ // transient failure (#9, matching the terminal branch's B5
84359
+ // treatment). The offender's own cap still bounds the shared-batch
84360
+ // retry loop. Only the offender carries any classifier mutation (a
84361
+ // backdating bump), so it alone reports actionDataChanged to persist
84362
+ // that mutation before its retry.
84363
+ for (const member of actions) {
84364
+ if (member === offendingAction) {
84365
+ await this.retryOrCap(member, errorResponse, actionErrored, decision.retryDelayInMs, decision.actionDataChanged);
84366
+ }
84367
+ else {
84368
+ await actionErrored(member, true, decision.retryDelayInMs);
84369
+ }
84370
+ }
84371
+ }
84372
+ else {
84373
+ // Deterministic, server-generated offender: terminal. Route ONLY the
84374
+ // offender and leave the innocent rolled-back siblings Pending and
84375
+ // unparked — reporting nothing for them (B5). Charging a sibling a
84376
+ // durable retry attempt for another action's terminal failure could
84377
+ // push an innocent later action to its cap without it ever producing
84378
+ // its own error. The queue re-collects the siblings on the next pass
84379
+ // (bounded: each pass terminally resolves one offender, so there is
84380
+ // no zero-delay storm), and it owns stop state / backoff / retry
84381
+ // budgets — the handler no longer self-drives prefix/suffix retries
84382
+ // (that recursion bypassed stopQueue and could launch a new request
84383
+ // while the original was in flight).
84384
+ await actionErrored({
84385
+ ...offendingAction,
84386
+ error: errorResponse,
84387
+ status: DraftActionStatus.Error,
84388
+ }, false);
84389
+ }
84390
+ return ProcessActionResult.ACTION_ERRORED;
84391
+ }
84392
+ catch (e) {
84393
+ batchLog(`network error ambiguous size=${actions.length}`);
84394
+ const failure = normalizeError$1(e);
84395
+ // A throw here means the request was dispatched but no response was
84396
+ // received — the all-or-none batch may have committed at the origin
84397
+ // with the response lost, and the envelope carries no server-visible
84398
+ // idempotency key. This is commit-AMBIGUOUS: surface the failure to o11y
84399
+ // (the single-action handleAction catch reports every upload error, and
84400
+ // the batch path swallowed them until now — B4; attributed to the head
84401
+ // as a representative, matching the dispatch correlation above), then
84402
+ // return NETWORK_ERROR so the queue surfaces the members for manual
84403
+ // resolution instead of auto-replaying them (Option A).
84404
+ this.reportUploadFailure(failure, actions[0]);
84405
+ return ProcessActionResult.NETWORK_ERROR;
84406
+ }
84407
+ }
84408
+ /**
84409
+ * Retries every given batch member through the durable retry cap
84410
+ * (retryOrCap). Used for whole-batch transient failures (network throw,
84411
+ * misaligned/short/empty response, unattributed rollback, retryable envelope
84412
+ * status) and for the surviving members of a partial failure, so that (a)
84413
+ * every member is parked with backoff before the queue re-arms — not just
84414
+ * the head (C4) — and (b) the durable attempt cap bounds the retries instead
84415
+ * of looping forever (C3). When the cap gate is closed, retryOrCap is a
84416
+ * passthrough that parks each member exactly as before (retry=true).
84417
+ */
84418
+ async retryBatchMembers(members, lastFailure, actionErrored, retryDelayInMs) {
84419
+ for (const member of members) {
84420
+ await this.retryOrCap(member, lastFailure, actionErrored, retryDelayInMs);
84421
+ }
84422
+ }
84423
+ /**
84424
+ * Terminally errors every given batch member (no retry). Used when the batch
84425
+ * envelope is rejected with a deterministic client error (4xx other than
84426
+ * 408/429): retrying an unchanged, server-rejected batch would only be
84427
+ * rejected again, so the drafts are errored for the user to fix or discard —
84428
+ * consistent with the single-action response policy — instead of being
84429
+ * retried forever (C3).
84430
+ */
84431
+ async terminalBatchMembers(members, error, actionErrored) {
84432
+ for (const member of members) {
84433
+ await actionErrored({ ...member, error, status: DraftActionStatus.Error }, false);
84434
+ }
84435
+ }
84436
+ /**
84437
+ * Maps each draft action to a UI-API records/batch operation. Each op has
84438
+ * exactly one record entry; the order of returned ops matches the order of
84439
+ * input actions so per-action result routing works on index alone.
84440
+ *
84441
+ * - method='post' -> CREATE with apiName + fields
84442
+ * - method='patch' -> UPDATE with fields including Id (Id pulled from
84443
+ * urlParams.recordId)
84444
+ * - method='delete' -> DELETE with fields containing only Id
84445
+ *
84446
+ * Each request is re-resolved through resolveResourceRequest first. The
84447
+ * draft-id redirect rewrite that runs when an upstream POST completes only
84448
+ * updates a queued action's basePath/body — NOT its urlParams — so a stored
84449
+ * PATCH/DELETE can still carry a stale draft id in urlParams.recordId at
84450
+ * drain time. resolveResourceRequest re-canonicalizes urlParams.recordId
84451
+ * (and body fields) against the store, and is idempotent when the id is
84452
+ * already canonical, so it is safe to call unconditionally here.
84453
+ */
84454
+ buildBatchOperations(actions) {
84455
+ const operations = [];
84456
+ for (const action of actions) {
84457
+ const request = this.resolveResourceRequest(action.data);
84458
+ const method = request.method;
84459
+ if (method === 'post') {
84460
+ const body = request.body;
84461
+ operations.push({
84462
+ type: 'CREATE',
84463
+ records: [
84464
+ {
84465
+ apiName: body.apiName,
84466
+ fields: { ...body.fields },
84467
+ },
84468
+ ],
84469
+ });
84470
+ }
84471
+ else if (method === 'patch') {
84472
+ const recordId = request.urlParams['recordId'];
84473
+ const body = request.body;
84474
+ operations.push({
84475
+ type: 'UPDATE',
84476
+ records: [
84477
+ {
84478
+ fields: { Id: recordId, ...body.fields },
84479
+ },
84480
+ ],
84481
+ });
84482
+ }
84483
+ else if (method === 'delete') {
84484
+ const recordId = request.urlParams['recordId'];
84485
+ operations.push({
84486
+ type: 'DELETE',
84487
+ records: [
84488
+ {
84489
+ fields: { Id: recordId },
84490
+ },
84491
+ ],
84492
+ });
84493
+ }
84494
+ else {
84495
+ throw Error(`Unsupported method '${method}' in UiApiActionHandler.buildBatchOperations`);
84496
+ }
84497
+ }
84498
+ return operations;
84499
+ }
81668
84500
  async draftActionToSideEffects(action) {
81669
84501
  const method = action.data.method;
81670
84502
  const apiName = action.metadata[LDS_ACTION_METADATA_API_NAME];
@@ -82147,6 +84979,12 @@
82147
84979
  const CONTENT_DOCUMENT_DRAFT_ID_KEY = 'CONTENT_DOCUMENT_DRAFT_ID';
82148
84980
  const CONTENT_VERSION_DRAFT_ID_KEY = 'CONTENT_VERSION_DRAFT_ID';
82149
84981
  const CONTENT_DOCUMENT_LINK_DRAFT_ID_KEY = 'CONTENT_DOCUMENT_LINK_DRAFT_ID';
84982
+ /**
84983
+ * Metadata key carrying the LDS binary store URI for the upload's local file.
84984
+ * Set in `buildPendingAction` so the Swift "Pending Images" UI can render
84985
+ * a thumbnail from the local bytes without a JSC round-trip.
84986
+ */
84987
+ const LDS_BINARY_HANDLE_KEY = 'LDS_BINARY_HANDLE';
82150
84988
  const RESPONSE_CONTENT_DOCUMENT_FIELDS = [
82151
84989
  'ContentSize',
82152
84990
  'CreatedDate',
@@ -82398,6 +85236,15 @@
82398
85236
  return Promise.reject(new DraftSynthesisError('Cannot generate local ContentDocumentLink synthetic id', 'CANNOT_GENERATE_ID'));
82399
85237
  });
82400
85238
  pendingAction.metadata[CONTENT_DOCUMENT_LINK_DRAFT_ID_KEY] = contentDocumentLinkId;
85239
+ // Stash the LDS binary store URI so the Swift "Pending Images" section
85240
+ // can render a thumbnail directly from the local file bytes.
85241
+ try {
85242
+ const fileRef = getFileReferenceFromResourceRequest(request);
85243
+ pendingAction.metadata[LDS_BINARY_HANDLE_KEY] = fileRef.handle;
85244
+ }
85245
+ catch {
85246
+ // No file data — leave metadata unset; UI falls back to placeholder.
85247
+ }
82401
85248
  const objectInfo = await this.objectInfoService.getObjectInfo(CONTENT_VERSION_API_NAME);
82402
85249
  if (objectInfo === undefined) {
82403
85250
  throw Error(`Could not generate draft. Object info is missing`);
@@ -83000,7 +85847,18 @@
83000
85847
  return ids.map((id) => recordMap.get(id) || null);
83001
85848
  }
83002
85849
  }
83003
- return new DataLoader(batchRecordQuery);
85850
+ const loader = new DataLoader(batchRecordQuery);
85851
+ const loads = new Map();
85852
+ return Object.assign(loader, {
85853
+ prefetch(id) {
85854
+ const existing = loads.get(id);
85855
+ if (existing)
85856
+ return existing;
85857
+ const load = loader.load(id).then((record) => ({ record }), (error) => ({ error }));
85858
+ loads.set(id, load);
85859
+ return load;
85860
+ },
85861
+ });
83004
85862
  }
83005
85863
 
83006
85864
  function createContext(store, objectInfos, eventEmitter, settings, snapshot, mappedCursors = new Map(), draftFunctions) {
@@ -83034,6 +85892,8 @@
83034
85892
  draftFunctions,
83035
85893
  mappedCursors,
83036
85894
  missingFieldsMap: new Map(),
85895
+ parentPrefetchEnabled: !parentRelationshipPrefetchKillSwitch.isOpen({ fallback: false }),
85896
+ parentResults: new Map(),
83037
85897
  };
83038
85898
  }
83039
85899
 
@@ -83081,47 +85941,41 @@
83081
85941
  DateLiteral["YESTERDAY"] = "YESTERDAY";
83082
85942
  DateLiteral["NEXT_WEEK"] = "NEXT_WEEK";
83083
85943
  })(DateLiteral || (DateLiteral = {}));
83084
- // ranged adjusted to be between now and the end of the day,
83085
- // you cannot do a straight = comparison in SQL
83086
85944
  const TODAY_RANGE = {
83087
85945
  lowerBound: `date('now')`,
83088
- upperBound: `date('now', '+1 day', '-0.001 seconds')`,
85946
+ upperBound: `datetime('now', 'start of day', '+1 day', '-1 seconds')`,
83089
85947
  };
83090
- // ranged adjusted to be between today +1 day and +2 days -.001 seconds to be the very end of tomorrows date
83091
- // you cannot do a straight = comparison in SQL
83092
85948
  const TOMORROW_RANGE = {
83093
85949
  lowerBound: `date('now', '+1 day')`,
83094
- upperBound: `date('now', '+2 day', '-0.001 seconds')`,
85950
+ upperBound: `datetime('now', 'start of day', '+2 days', '-1 seconds')`,
83095
85951
  };
83096
85952
  const YESTERDAY_RANGE = {
83097
85953
  lowerBound: `date('now', '-1 day')`,
83098
- upperBound: `date('now', '-0.001 seconds')`,
85954
+ upperBound: `datetime('now', 'start of day', '-1 seconds')`,
83099
85955
  };
83100
85956
  const THIS_WEEK_RANGE = {
83101
- lowerBound: `date('now', 'weekday 0', '-7 days')`,
83102
- upperBound: `date('now', 'weekday 0', '-1 day')`,
85957
+ lowerBound: `date('now', 'start of day', '-6 days', 'weekday ${FIRST_DAY_OF_WEEK}')`,
85958
+ upperBound: `datetime('now', 'start of day', '-6 days', 'weekday ${FIRST_DAY_OF_WEEK}', '+7 days', '-1 seconds')`,
83103
85959
  };
83104
85960
  const LAST_WEEK_RANGE = {
83105
- lowerBound: `date('now', 'weekday 0', '-14 days')`,
83106
- upperBound: `date('now', 'weekday 0', '-8 days')`,
85961
+ lowerBound: `date('now', 'start of day', '-6 days', 'weekday ${FIRST_DAY_OF_WEEK}', '-7 days')`,
85962
+ upperBound: `datetime('now', 'start of day', '-6 days', 'weekday ${FIRST_DAY_OF_WEEK}', '-1 seconds')`,
83107
85963
  };
83108
85964
  const NEXT_WEEK_RANGE = {
83109
- lowerBound: `date('now', 'weekday 0')`,
83110
- upperBound: `date('now', 'weekday 0', '+6 days')`,
85965
+ lowerBound: `date('now', 'start of day', '-6 days', 'weekday ${FIRST_DAY_OF_WEEK}', '+7 days')`,
85966
+ upperBound: `datetime('now', 'start of day', '-6 days', 'weekday ${FIRST_DAY_OF_WEEK}', '+14 days', '-1 seconds')`,
83111
85967
  };
83112
85968
  const THIS_MONTH_RANGE = {
83113
85969
  lowerBound: `date('now', 'start of month')`,
83114
- upperBound: `date('now', 'start of month', '1 month', '0 day')`,
85970
+ upperBound: `datetime('now', 'start of month', '+1 month', '-1 seconds')`,
83115
85971
  };
83116
85972
  const LAST_MONTH_RANGE = {
83117
85973
  lowerBound: `date('now', 'start of month', '-1 month')`,
83118
- upperBound: `date('now', 'start of month', '0 day')`,
85974
+ upperBound: `datetime('now', 'start of month', '-1 seconds')`,
83119
85975
  };
83120
85976
  const NEXT_MONTH_RANGE = {
83121
85977
  lowerBound: `date('now', 'start of month', '+1 month')`,
83122
- // -1 day is the day before the end of the month
83123
- // this needs to be 0 day
83124
- upperBound: `date('now', 'start of month', '+2 month', '0 day')`,
85978
+ upperBound: `datetime('now', 'start of month', '+2 months', '-1 seconds')`,
83125
85979
  };
83126
85980
  const THIS_QUARTER_RANGE = () => {
83127
85981
  return computeQuarterDateRange(DateLiteral.THIS_QUARTER);
@@ -83134,23 +85988,23 @@
83134
85988
  };
83135
85989
  const THIS_YEAR_RANGE = {
83136
85990
  lowerBound: `date('now', 'start of year')`,
83137
- upperBound: `date('now', 'start of month', '1 year', '-1 day')`,
85991
+ upperBound: `datetime('now', 'start of year', '+1 year', '-1 seconds')`,
83138
85992
  };
83139
85993
  const LAST_YEAR_RANGE = {
83140
85994
  lowerBound: `date('now', 'start of year', '-1 year')`,
83141
- upperBound: `date('now', 'start of year', '-1 day')`,
85995
+ upperBound: `datetime('now', 'start of year', '-1 seconds')`,
83142
85996
  };
83143
85997
  const NEXT_YEAR_RANGE = {
83144
85998
  lowerBound: `date('now', 'start of year', '+1 year')`,
83145
- upperBound: `date('now', 'start of year', '+2 years', '-1 day')`,
85999
+ upperBound: `datetime('now', 'start of year', '+2 years', '-1 seconds')`,
83146
86000
  };
83147
86001
  const LAST_90_DAYS_RANGE = {
83148
86002
  lowerBound: `date('now', '-90 days')`,
83149
- upperBound: `date('now')`,
86003
+ upperBound: `datetime('now')`,
83150
86004
  };
83151
86005
  const NEXT_90_DAYS_RANGE = {
83152
86006
  lowerBound: `date('now', '+1 day')`,
83153
- upperBound: `date('now', '+90 days')`,
86007
+ upperBound: `datetime('now', 'start of day', '+91 days', '-1 seconds')`,
83154
86008
  };
83155
86009
  // querter start date in UTC
83156
86010
  function quarterStart(date) {
@@ -83164,23 +86018,17 @@
83164
86018
  case DateLiteral.LAST_QUARTER:
83165
86019
  return {
83166
86020
  lowerBound: `date('${curQuarterStart}', '-3 months')`,
83167
- // -1 days would set the day before the end of the quarter
83168
- // 0 days sets it to the end of the month
83169
- upperBound: `date('${curQuarterStart}', '0 days')`,
86021
+ upperBound: `datetime('${curQuarterStart}', '-1 seconds')`,
83170
86022
  };
83171
86023
  case DateLiteral.THIS_QUARTER:
83172
86024
  return {
83173
86025
  lowerBound: `date('${curQuarterStart}')`,
83174
- // -1 days would set the day before the end of the quarter
83175
- // 0 days sets it to the end of the month
83176
- upperBound: `date('${curQuarterStart}', '3 months', '0 days')`,
86026
+ upperBound: `datetime('${curQuarterStart}', '+3 months', '-1 seconds')`,
83177
86027
  };
83178
86028
  case DateLiteral.NEXT_QUARTER:
83179
86029
  return {
83180
86030
  lowerBound: `date('${curQuarterStart}', '+3 months')`,
83181
- // -1 days would set the day before the end of the quarter
83182
- // 0 days sets it to the end of the month
83183
- upperBound: `date('${curQuarterStart}', '+6 months', '0 days')`,
86031
+ upperBound: `datetime('${curQuarterStart}', '+6 months', '-1 seconds')`,
83184
86032
  };
83185
86033
  }
83186
86034
  }
@@ -83399,11 +86247,11 @@
83399
86247
  case DateRange.next_n_days: {
83400
86248
  return {
83401
86249
  binding: {
83402
- upper: `${input} days`,
86250
+ upper: `${input + 1} days`,
83403
86251
  lower: '1 days',
83404
86252
  },
83405
86253
  range: {
83406
- upper: `${dateFunction}('now', 'start of day', ?)`,
86254
+ upper: `${dateFunction}('now', 'start of day', ?, '-1 seconds')`,
83407
86255
  lower: `${dateFunction}('now', 'start of day', ?)`,
83408
86256
  },
83409
86257
  };
@@ -83440,19 +86288,14 @@
83440
86288
  // starts at 00:00:00 the first day of the week n weeks after the current week
83441
86289
  // ends 23:59:59 of the last day of week before the current
83442
86290
  case DateRange.next_n_weeks: {
83443
- const isStartOfWeek = isTodayStartOfWeek();
83444
86291
  return {
83445
86292
  binding: {
83446
- // weekday FIRST_DAY_OF_WEEK goes to the next weekday of FIRST_DAY_OF_WEEK if not that current day
83447
- // if that current day then it will be that day
83448
- // so for the upper bound if today is the start of the week we need to add 7 more days to the value to get next week as the start
83449
- upper: `${input * 7 + (isStartOfWeek ? 7 : 0)} days`,
83450
- //lower bound starts out 00:00:00 of next week
83451
- lower: `${isStartOfWeek ? 7 : 0} days`,
86293
+ upper: `${(input + 1) * 7} days`,
86294
+ lower: '7 days',
83452
86295
  },
83453
86296
  range: {
83454
- upper: `${dateFunction}('now', 'weekday ${FIRST_DAY_OF_WEEK}', 'start of day', ?, '-1 seconds')`,
83455
- lower: `${dateFunction}('now', 'weekday ${FIRST_DAY_OF_WEEK}', 'start of day', ?)`,
86297
+ upper: `${dateFunction}('now', 'start of day', '-6 days', 'weekday ${FIRST_DAY_OF_WEEK}', ?, '-1 seconds')`,
86298
+ lower: `${dateFunction}('now', 'start of day', '-6 days', 'weekday ${FIRST_DAY_OF_WEEK}', ?)`,
83456
86299
  },
83457
86300
  };
83458
86301
  }
@@ -83460,19 +86303,14 @@
83460
86303
  // starts at 00:00:00 of day 0 n weeks before the current week
83461
86304
  // ends 23:59:59 at last day of the week before the current week
83462
86305
  case DateRange.last_n_weeks: {
83463
- const isStartOfWeek = isTodayStartOfWeek();
83464
86306
  return {
83465
86307
  binding: {
83466
- // ending on at 23:59:59 of the week before the current week
83467
- // -7 more days if today not start of week
83468
- upper: `${-(isStartOfWeek ? 0 : 7)} days`,
83469
- // starting on 00:00:00 of n * weeks before current week
83470
- // -7 more days if today not the start of the week because weekday FIRST_DAY_OF_WEEK puts us next week day 0
83471
- lower: `${-input * 7 - (isStartOfWeek ? 0 : 7)} days`,
86308
+ upper: '0 days',
86309
+ lower: `${-input * 7} days`,
83472
86310
  },
83473
86311
  range: {
83474
- upper: `${dateFunction}('now', 'weekday ${FIRST_DAY_OF_WEEK}', 'start of day', ?, '-1 seconds')`,
83475
- lower: `${dateFunction}('now', 'weekday ${FIRST_DAY_OF_WEEK}', 'start of day', ?)`,
86312
+ upper: `${dateFunction}('now', 'start of day', '-6 days', 'weekday ${FIRST_DAY_OF_WEEK}', ?, '-1 seconds')`,
86313
+ lower: `${dateFunction}('now', 'start of day', '-6 days', 'weekday ${FIRST_DAY_OF_WEEK}', ?)`,
83476
86314
  },
83477
86315
  };
83478
86316
  }
@@ -83480,21 +86318,14 @@
83480
86318
  // starts 00:00:00 on day 0 of n weeks before the current week
83481
86319
  // ends 7 days after the start
83482
86320
  case DateRange.n_weeks_ago: {
83483
- const isStartOfWeek = isTodayStartOfWeek();
83484
86321
  return {
83485
86322
  binding: {
83486
- // end of week n weeks before current week
83487
- // if today is start of the week then we need to -7 from the days given by n
83488
- // so add 7 to remove a week from -(n)
83489
- upper: `${-input * 7 + (isStartOfWeek ? 7 : 0)} days`,
83490
- // start of the week n weeks from the current week
83491
- // if today is the start of the week then -(n) will do to be the previous week, but
83492
- // if today is not the start of the week we need to go back 1 more week since weekday FIRST_DAY_OF_WEEK will put us next week
83493
- lower: `${-input * 7 - (isStartOfWeek ? 0 : 7)} days`,
86323
+ upper: `${-input * 7 + 7} days`,
86324
+ lower: `${-input * 7} days`,
83494
86325
  },
83495
86326
  range: {
83496
- upper: `${dateFunction}('now', 'weekday ${FIRST_DAY_OF_WEEK}', 'start of day', ?, '-1 seconds')`,
83497
- lower: `${dateFunction}('now', 'weekday ${FIRST_DAY_OF_WEEK}', 'start of day', ?)`,
86327
+ upper: `${dateFunction}('now', 'start of day', '-6 days', 'weekday ${FIRST_DAY_OF_WEEK}', ?, '-1 seconds')`,
86328
+ lower: `${dateFunction}('now', 'start of day', '-6 days', 'weekday ${FIRST_DAY_OF_WEEK}', ?)`,
83498
86329
  },
83499
86330
  };
83500
86331
  }
@@ -83644,9 +86475,6 @@
83644
86475
  throw new Error(`DateRangeInput ${dateRange} is not supported in local evalutation`);
83645
86476
  }
83646
86477
  }
83647
- function isTodayStartOfWeek() {
83648
- return new Date().getDay() === FIRST_DAY_OF_WEEK;
83649
- }
83650
86478
 
83651
86479
  const JSON_EXTRACT_PATH_INGESTION_TIMESTAMP = '$.ingestionTimestamp';
83652
86480
  const JSON_EXTRACT_PATH_INGESTION_APINAME = '$.apiName';
@@ -86213,6 +89041,207 @@
86213
89041
  return metadataMap;
86214
89042
  }
86215
89043
 
89044
+ function getDirectiveBooleanValue(argument, variables) {
89045
+ if (!argument)
89046
+ return undefined;
89047
+ if (argument.value.kind === Kind$2.BOOLEAN)
89048
+ return argument.value.value;
89049
+ if (argument.value.kind === Kind$2.VARIABLE) {
89050
+ const value = variables[argument.value.name.value];
89051
+ return typeof value === 'boolean' ? value : undefined;
89052
+ }
89053
+ return undefined;
89054
+ }
89055
+ function isSelectionIncluded(selection, variables) {
89056
+ for (const directive of selection.directives || []) {
89057
+ if (directive.name.value !== 'skip' && directive.name.value !== 'include')
89058
+ continue;
89059
+ const argument = (directive.arguments || []).find((argument) => argument.name.value === 'if');
89060
+ const value = getDirectiveBooleanValue(argument, variables);
89061
+ if (value === undefined)
89062
+ return false;
89063
+ if (directive.name.value === 'skip' && value === true)
89064
+ return false;
89065
+ if (directive.name.value === 'include' && value === false)
89066
+ return false;
89067
+ }
89068
+ return true;
89069
+ }
89070
+ function matchesTypeCondition(typeCondition, runtimeType, schema) {
89071
+ if (!typeCondition)
89072
+ return true;
89073
+ const conditionType = schema.getType(typeCondition.name.value);
89074
+ if (!conditionType || conditionType === runtimeType)
89075
+ return conditionType === runtimeType;
89076
+ const kind = conditionType.astNode?.kind;
89077
+ if (kind !== Kind$2.INTERFACE_TYPE_DEFINITION && kind !== Kind$2.UNION_TYPE_DEFINITION)
89078
+ return false;
89079
+ return schema.isSubType(conditionType, runtimeType);
89080
+ }
89081
+ function relationshipId(fieldName, record) {
89082
+ let id;
89083
+ if (fieldName === 'RecordType')
89084
+ id = record.recordTypeId;
89085
+ else if (fieldName.endsWith('__r')) {
89086
+ const relationshipField = record.fields[fieldName.replace('__r', '__c')];
89087
+ id = relationshipField && relationshipField.value;
89088
+ }
89089
+ else {
89090
+ const relationshipField = record.fields[fieldName + 'Id'];
89091
+ id = relationshipField && relationshipField.value;
89092
+ }
89093
+ return typeof id === 'string' && id ? id : null;
89094
+ }
89095
+ function getObjectFields(type) {
89096
+ return isObjectType(type) ? type.getFields() : {};
89097
+ }
89098
+ function getSelectedParentFields(selectionSet, runtimeType, info, visitedFragments) {
89099
+ if (!selectionSet)
89100
+ return [];
89101
+ const selectedParentFields = [];
89102
+ const variables = info.variableValues;
89103
+ for (const selection of selectionSet.selections) {
89104
+ if (!isSelectionIncluded(selection, variables))
89105
+ continue;
89106
+ if (selection.kind === Kind$2.FIELD) {
89107
+ const field = getObjectFields(runtimeType)[selection.name.value];
89108
+ const fieldType = field && getNamedType(field.type);
89109
+ const isParent = fieldType &&
89110
+ ((isObjectType(fieldType) &&
89111
+ fieldType.getInterfaces().some((iface) => iface.name === 'Record')) ||
89112
+ fieldType.name === 'Record');
89113
+ if (isParent &&
89114
+ selection.selectionSet &&
89115
+ !(isObjectType(fieldType) && fieldType.name.endsWith('Connection'))) {
89116
+ selectedParentFields.push({
89117
+ name: selection.name.value,
89118
+ selectionSet: selection.selectionSet,
89119
+ });
89120
+ }
89121
+ }
89122
+ else if (selection.kind === Kind$2.INLINE_FRAGMENT &&
89123
+ matchesTypeCondition(selection.typeCondition, runtimeType, info.schema)) {
89124
+ selectedParentFields.push(...getSelectedParentFields(selection.selectionSet, runtimeType, info, visitedFragments));
89125
+ }
89126
+ else if (selection.kind === Kind$2.FRAGMENT_SPREAD) {
89127
+ const fragment = info.fragments[selection.name.value];
89128
+ if (!fragment ||
89129
+ visitedFragments.has(fragment.name.value) ||
89130
+ !matchesTypeCondition(fragment.typeCondition, runtimeType, info.schema))
89131
+ continue;
89132
+ visitedFragments.add(fragment.name.value);
89133
+ selectedParentFields.push(...getSelectedParentFields(fragment.selectionSet, runtimeType, info, visitedFragments));
89134
+ }
89135
+ }
89136
+ return selectedParentFields;
89137
+ }
89138
+ function getActiveFieldSelectionSets(selectionSet, fieldName, runtimeType, info, visitedFragments) {
89139
+ if (!selectionSet)
89140
+ return [];
89141
+ const fieldSelectionSets = [];
89142
+ const variables = info.variableValues;
89143
+ for (const selection of selectionSet.selections) {
89144
+ if (!isSelectionIncluded(selection, variables))
89145
+ continue;
89146
+ if (selection.kind === Kind$2.FIELD &&
89147
+ selection.name.value === fieldName &&
89148
+ selection.selectionSet)
89149
+ fieldSelectionSets.push(selection.selectionSet);
89150
+ else if (selection.kind === Kind$2.INLINE_FRAGMENT &&
89151
+ matchesTypeCondition(selection.typeCondition, runtimeType, info.schema)) {
89152
+ fieldSelectionSets.push(...getActiveFieldSelectionSets(selection.selectionSet, fieldName, runtimeType, info, visitedFragments));
89153
+ }
89154
+ else if (selection.kind === Kind$2.FRAGMENT_SPREAD) {
89155
+ const fragment = info.fragments[selection.name.value];
89156
+ if (!fragment ||
89157
+ visitedFragments.has(fragment.name.value) ||
89158
+ !matchesTypeCondition(fragment.typeCondition, runtimeType, info.schema))
89159
+ continue;
89160
+ visitedFragments.add(fragment.name.value);
89161
+ fieldSelectionSets.push(...getActiveFieldSelectionSets(fragment.selectionSet, fieldName, runtimeType, info, visitedFragments));
89162
+ }
89163
+ }
89164
+ return fieldSelectionSets;
89165
+ }
89166
+ async function prefetchParentBatch(parents, state) {
89167
+ const { info, context, parentOutcomes, visitedSelections } = state;
89168
+ const nextParents = [];
89169
+ const parentLoads = [];
89170
+ for (const parentWork of parents) {
89171
+ let selectionsByType = visitedSelections.get(parentWork.record.recordRepresentation.id);
89172
+ if (!selectionsByType)
89173
+ visitedSelections.set(parentWork.record.recordRepresentation.id, (selectionsByType = new Map()));
89174
+ let visitedSelectionSets = selectionsByType.get(parentWork.runtimeType);
89175
+ if (!visitedSelectionSets)
89176
+ selectionsByType.set(parentWork.runtimeType, (visitedSelectionSets = new Set()));
89177
+ if (visitedSelectionSets.has(parentWork.selectionSet))
89178
+ continue;
89179
+ visitedSelectionSets.add(parentWork.selectionSet);
89180
+ for (const parentField of getSelectedParentFields(parentWork.selectionSet, parentWork.runtimeType, info, new Set())) {
89181
+ const parentId = relationshipId(parentField.name, parentWork.record.recordRepresentation);
89182
+ if (!parentId)
89183
+ continue;
89184
+ const knownOutcome = parentOutcomes.get(parentId) || context.parentResults.get(parentId);
89185
+ if (knownOutcome && 'error' in knownOutcome)
89186
+ continue;
89187
+ const parentLoad = context.Record.prefetch(parentId);
89188
+ parentLoads.push(parentLoad.then((outcome) => {
89189
+ parentOutcomes.set(parentId, outcome);
89190
+ if ('error' in outcome || outcome.record === null)
89191
+ return;
89192
+ const parentRecord = outcome.record;
89193
+ const declaredParentType = getNamedType(getObjectFields(parentWork.runtimeType)[parentField.name].type);
89194
+ const recordInterface = declaredParentType.name === 'Record' &&
89195
+ declaredParentType.astNode?.kind === Kind$2.INTERFACE_TYPE_DEFINITION
89196
+ ? declaredParentType
89197
+ : undefined;
89198
+ const resolveRecordType = recordInterface?.resolveType;
89199
+ const resolvedParentType = resolveRecordType
89200
+ ? resolveRecordType({
89201
+ recordRepresentation: parentRecord,
89202
+ ingestionTimestamp: parentWork.record.ingestionTimestamp,
89203
+ }, context, info)
89204
+ : declaredParentType;
89205
+ const parentRuntimeType = typeof resolvedParentType === 'string'
89206
+ ? info.schema.getType(resolvedParentType)
89207
+ : resolvedParentType;
89208
+ if (!isObjectType(parentRuntimeType))
89209
+ return;
89210
+ nextParents.push({
89211
+ record: {
89212
+ recordRepresentation: parentRecord,
89213
+ ingestionTimestamp: parentWork.record.ingestionTimestamp,
89214
+ },
89215
+ selectionSet: parentField.selectionSet,
89216
+ runtimeType: parentRuntimeType,
89217
+ });
89218
+ }));
89219
+ }
89220
+ }
89221
+ await Promise.all(parentLoads);
89222
+ if (nextParents.length > 0)
89223
+ await prefetchParentBatch(nextParents, state);
89224
+ }
89225
+ async function prefetchSelectedParents(records, info, context) {
89226
+ const parentOutcomes = new Map();
89227
+ const connectionType = getNamedType(info.returnType);
89228
+ const edgeType = getNamedType(getObjectFields(connectionType).edges?.type);
89229
+ const nodeType = getNamedType(getObjectFields(edgeType).node?.type);
89230
+ if (!isObjectType(connectionType) || !isObjectType(edgeType) || !isObjectType(nodeType)) {
89231
+ return parentOutcomes;
89232
+ }
89233
+ const edgeSelectionSets = info.fieldNodes.flatMap((node) => getActiveFieldSelectionSets(node.selectionSet, 'edges', connectionType, info, new Set()));
89234
+ const nodeSelectionSets = edgeSelectionSets.flatMap((selectionSet) => getActiveFieldSelectionSets(selectionSet, 'node', edgeType, info, new Set()));
89235
+ if (records.length > 0 && nodeSelectionSets.length > 0) {
89236
+ await prefetchParentBatch(records.flatMap((record) => nodeSelectionSets.map((selectionSet) => ({
89237
+ record,
89238
+ selectionSet,
89239
+ runtimeType: nodeType,
89240
+ }))), { info, context, parentOutcomes, visitedSelections: new Map() });
89241
+ }
89242
+ return parentOutcomes;
89243
+ }
89244
+
86216
89245
  /*
86217
89246
  resolves connections...
86218
89247
  */
@@ -86336,6 +89365,11 @@
86336
89365
  earlyEnd,
86337
89366
  };
86338
89367
  });
89368
+ if (context.parentPrefetchEnabled) {
89369
+ const results = await prefetchSelectedParents(records, info, context);
89370
+ for (const [id, outcome] of results)
89371
+ context.parentResults.set(id, outcome);
89372
+ }
86339
89373
  return {
86340
89374
  records,
86341
89375
  hasNextPage,
@@ -86608,7 +89642,8 @@
86608
89642
  .getInterfaces()
86609
89643
  .find((iface) => iface.name === 'Record')) ||
86610
89644
  (recordFieldType && recordFieldType.name === 'Record')) {
86611
- field.resolve = async function relationResolver(obj, _args, { Record, seenRecordIds }) {
89645
+ field.resolve = function relationResolver(obj, _args, context) {
89646
+ const { Record, seenRecordIds, parentPrefetchEnabled } = context;
86612
89647
  const fetchRecordOrNull = async (key) => {
86613
89648
  const recordRepresentation = await Record.load(key);
86614
89649
  return recordRepresentation !== null
@@ -86619,29 +89654,22 @@
86619
89654
  : null;
86620
89655
  };
86621
89656
  const { recordRepresentation: record, ingestionTimestamp } = obj;
86622
- let id = undefined;
86623
- if (field.name === 'RecordType') {
86624
- // RecordTypeId has special handling during ingest and is
86625
- // not in record.fields, so check for it at the UIAPI root property location
86626
- id = record.recordTypeId;
86627
- }
86628
- else if (field.name.endsWith('__r')) {
86629
- // Custom relationships end in `__r` and the corresponding ID field should be `__c`
86630
- let fieldName = field.name.replace('__r', '__c');
86631
- id = record.fields[fieldName] && record.fields[fieldName].value;
86632
- }
86633
- else {
86634
- // Standard relationships are just FieldNameId
86635
- let fieldName = field.name + 'Id';
86636
- id = record.fields[fieldName] && record.fields[fieldName].value;
86637
- }
86638
- if (!id || typeof id !== 'string') {
86639
- // possibly field injection did not inject the necessary Id field
86640
- // for the relationship, or we found a non-string value.
86641
- return null;
89657
+ const id = relationshipId(field.name, record);
89658
+ if (!id) {
89659
+ return parentPrefetchEnabled ? null : Promise.resolve(null);
86642
89660
  }
86643
89661
  seenRecordIds.add(id);
86644
- return fetchRecordOrNull(id);
89662
+ if (!parentPrefetchEnabled)
89663
+ return fetchRecordOrNull(id);
89664
+ const prefetched = context.parentResults.get(id);
89665
+ if (!prefetched)
89666
+ return fetchRecordOrNull(id);
89667
+ if ('error' in prefetched)
89668
+ return Promise.reject(prefetched.error);
89669
+ const recordRepresentation = prefetched.record;
89670
+ return recordRepresentation === null
89671
+ ? null
89672
+ : { recordRepresentation, ingestionTimestamp };
86645
89673
  };
86646
89674
  }
86647
89675
  else if (isObjectType(recordFieldType) &&
@@ -90589,10 +93617,21 @@
90589
93617
  *
90590
93618
  * This instance is leveraged in the LMR webview which proxies DraftQueue requests
90591
93619
  * to a concrete implementation running in jscore.
93620
+ *
93621
+ * The Nimbus plugin name targeted by this proxy can be parameterized via the
93622
+ * constructor (defaults to 'LdsDraftQueue'); pass 'LdsPerformanceDraftQueue' to
93623
+ * target the primer's Performance Upload draft queue.
90592
93624
  */
90593
93625
  class NimbusDraftQueue {
93626
+ constructor(pluginName = 'LdsDraftQueue') {
93627
+ this.pluginName = pluginName;
93628
+ }
93629
+ get callProxyMethod() {
93630
+ const plugin = __nimbus.plugins[this.pluginName];
93631
+ return plugin && plugin.callProxyMethod;
93632
+ }
90594
93633
  enqueue(handlerId, data, observabilityContext) {
90595
- const callProxyMethod = __nimbus.plugins.LdsDraftQueue.callProxyMethod;
93634
+ const callProxyMethod = this.callProxyMethod;
90596
93635
  if (callProxyMethod === undefined) {
90597
93636
  return Promise.reject(new Error('callProxyMethod not defined on the nimbus plugin'));
90598
93637
  }
@@ -90619,7 +93658,7 @@
90619
93658
  return Promise.reject(new Error('Cannot call processNextAction from the NimbusDraftQueue'));
90620
93659
  }
90621
93660
  getQueueActions() {
90622
- const callProxyMethod = __nimbus.plugins.LdsDraftQueue.callProxyMethod;
93661
+ const callProxyMethod = this.callProxyMethod;
90623
93662
  if (callProxyMethod === undefined) {
90624
93663
  return Promise.reject(new Error('callProxyMethod not defined on the nimbus plugin'));
90625
93664
  }
@@ -90632,7 +93671,7 @@
90632
93671
  });
90633
93672
  }
90634
93673
  getDataForAction(action) {
90635
- const callProxyMethod = __nimbus.plugins.LdsDraftQueue.callProxyMethod;
93674
+ const callProxyMethod = this.callProxyMethod;
90636
93675
  if (callProxyMethod === undefined) {
90637
93676
  return Promise.reject('callProxyMethod not defined on the nimbus plugin');
90638
93677
  }
@@ -90773,6 +93812,12 @@
90773
93812
  case DraftQueueEventType.ActionUpdated:
90774
93813
  reportDraftActionEvent('updated', draftQueueEvent.draftCount);
90775
93814
  break;
93815
+ case DraftQueueEventType.BatchRecoveryFailed:
93816
+ {
93817
+ const recoveryEvent = draftQueueEvent;
93818
+ reportBatchRecoveryFailure(recoveryEvent.reason, recoveryEvent.draftCount);
93819
+ }
93820
+ break;
90776
93821
  }
90777
93822
  return Promise.resolve();
90778
93823
  });
@@ -90821,14 +93866,42 @@
90821
93866
 
90822
93867
  // so eslint doesn't complain about nimbus
90823
93868
  /* global __nimbus */
90824
- function buildLdsDraftQueue(durableStore) {
93869
+ /**
93870
+ * Builds the regular draft queue.
93871
+ *
93872
+ * One-arg call = legacy plain DurableDraftQueue (unchanged shipped behavior);
93873
+ * the 3-arg call builds a PerformanceDraftQueue (serial, concurrency=1, cross-queue
93874
+ * coordination + optional batching) over the shared store for the gate-ON path.
93875
+ */
93876
+ function buildLdsDraftQueue(durableStore, draftStore, options) {
90825
93877
  if (typeof __nimbus !== 'undefined' &&
90826
93878
  __nimbus.plugins !== undefined &&
90827
93879
  __nimbus.plugins.LdsDraftQueue !== undefined) {
90828
- return new NimbusDraftQueue();
93880
+ return new NimbusDraftQueue('LdsDraftQueue');
93881
+ }
93882
+ // Legacy gate-OFF path: no shared store / options → today's plain queue.
93883
+ if (draftStore === undefined && options === undefined) {
93884
+ const useSnapshot = draftQueueStoreSnapshotGate.isOpen({ fallback: true });
93885
+ const draftQueue = new DurableDraftQueue(new DurableDraftStore(durableStore, draftQueueInstrumentation, useSnapshot), draftQueueInstrumentation);
93886
+ return instrumentDraftQueue(draftQueue);
90829
93887
  }
90830
- const useSnapshot = draftQueueStoreSnapshotGate.isOpen({ fallback: true });
90831
- const draftQueue = new DurableDraftQueue(new DurableDraftStore(durableStore, draftQueueInstrumentation, useSnapshot), draftQueueInstrumentation);
93888
+ // Performance Upload path: serial queue participating in cross-queue coordination.
93889
+ const store = draftStore ?? new DurableDraftStore(durableStore);
93890
+ const draftQueue = new PerformanceDraftQueue(store, options);
93891
+ return instrumentDraftQueue(draftQueue);
93892
+ }
93893
+ /**
93894
+ * Builds the Performance Upload draft queue (ContentDocument/Version/Link
93895
+ * multipart uploads, concurrency > 1). Only constructed on the gate-ON path.
93896
+ */
93897
+ function buildLdsPerformanceDraftQueue(durableStore, draftStore, options) {
93898
+ if (typeof __nimbus !== 'undefined' &&
93899
+ __nimbus.plugins !== undefined &&
93900
+ __nimbus.plugins.LdsPerformanceDraftQueue !== undefined) {
93901
+ return new NimbusDraftQueue('LdsPerformanceDraftQueue');
93902
+ }
93903
+ const store = draftStore ?? new DurableDraftStore(durableStore);
93904
+ const draftQueue = new PerformanceDraftQueue(store, options);
90832
93905
  return instrumentDraftQueue(draftQueue);
90833
93906
  }
90834
93907
 
@@ -95771,13 +98844,17 @@
95771
98844
  };
95772
98845
  }
95773
98846
 
95774
- function applyOperationRequestOptions$1(init, operationType) {
95775
- if (operationType === void 0) {
98847
+ function applyOperationRequestOptions$3(init, operationType, requestOptions) {
98848
+ if (operationType === void 0 && requestOptions === void 0) {
95776
98849
  return init;
95777
98850
  }
95778
98851
  return {
95779
98852
  ...init,
95780
- onestore: { ...init?.onestore, retrySafe: operationType === "query" }
98853
+ onestore: {
98854
+ ...requestOptions,
98855
+ ...init?.onestore,
98856
+ ...operationType !== void 0 && { retrySafe: operationType === "query" }
98857
+ }
95781
98858
  };
95782
98859
  }
95783
98860
  class AuraNetworkCommand extends NetworkCommand$1 {
@@ -95810,6 +98887,10 @@
95810
98887
  get operationType() {
95811
98888
  return void 0;
95812
98889
  }
98890
+ /** Declared `onestore` options (`handleServerError`, `compression`); generated overrides. HTTP fallback only. */
98891
+ get requestOptions() {
98892
+ return void 0;
98893
+ }
95813
98894
  coerceAuraErrors(auraErrors) {
95814
98895
  const response = getFetchResponseFromAuraError(auraErrors[0]);
95815
98896
  return new UserVisibleError(
@@ -95916,7 +98997,11 @@
95916
98997
  );
95917
98998
  } else if (this.shouldUseFetch()) {
95918
98999
  const [input, init] = this.fetchParams;
95919
- const initWithOptions = applyOperationRequestOptions$1(init, this.operationType);
99000
+ const initWithOptions = applyOperationRequestOptions$3(
99001
+ init,
99002
+ this.operationType,
99003
+ this.requestOptions
99004
+ );
95920
99005
  try {
95921
99006
  const fetchCall = initWithOptions === void 0 ? this.services.fetch(input) : this.services.fetch(input, initWithOptions);
95922
99007
  return this.convertFetchResponseToData(fetchCall);
@@ -96484,6 +99569,19 @@
96484
99569
  };
96485
99570
  }
96486
99571
 
99572
+ function applyOperationRequestOptions$2(init, operationType, requestOptions) {
99573
+ if (operationType === void 0 && requestOptions === void 0) {
99574
+ return init;
99575
+ }
99576
+ return {
99577
+ ...init,
99578
+ onestore: {
99579
+ ...requestOptions,
99580
+ ...init?.onestore,
99581
+ ...operationType !== void 0 && { retrySafe: operationType === "query" }
99582
+ }
99583
+ };
99584
+ }
96487
99585
  class AuraCacheControlCommand extends CacheControlCommand {
96488
99586
  constructor(services) {
96489
99587
  super(services);
@@ -96501,6 +99599,20 @@
96501
99599
  "Fetch parameters must be specified when using HTTP transport on an Aura adapter"
96502
99600
  );
96503
99601
  }
99602
+ /** Declared `onestore` options (`handleServerError`, `compression`); generated overrides. HTTP fallback only. */
99603
+ get requestOptions() {
99604
+ return void 0;
99605
+ }
99606
+ /**
99607
+ * `params` with request options merged into `init.onestore` (see `applyOperationRequestOptions`).
99608
+ * `retrySafe` follows `operationType` (default `'query'`): hand-written writes must override it.
99609
+ */
99610
+ withRequestOptions([
99611
+ input,
99612
+ init
99613
+ ]) {
99614
+ return [input, applyOperationRequestOptions$2(init, this.operationType, this.requestOptions)];
99615
+ }
96504
99616
  shouldUseAuraNetwork() {
96505
99617
  return this.services.auraNetwork !== void 0 && (this.networkPreference === "aura" || !this.services.fetch);
96506
99618
  }
@@ -96519,7 +99631,7 @@
96519
99631
  (errs) => this.coerceAuraErrors(errs)
96520
99632
  );
96521
99633
  } else if (this.shouldUseFetch()) {
96522
- const params = this.fetchParams;
99634
+ const params = this.withRequestOptions(this.fetchParams);
96523
99635
  try {
96524
99636
  return this.convertFetchResponseToData(this.services.fetch(...params));
96525
99637
  } catch (reason) {
@@ -96666,13 +99778,17 @@
96666
99778
  async afterRequestHooks(_options) {
96667
99779
  }
96668
99780
  }
96669
- function applyOperationRequestOptions(init, operationType) {
96670
- if (operationType === void 0) {
99781
+ function applyOperationRequestOptions$1(init, operationType, requestOptions) {
99782
+ if (operationType === void 0 && requestOptions === void 0) {
96671
99783
  return init;
96672
99784
  }
96673
99785
  return {
96674
99786
  ...init,
96675
- onestore: { ...init?.onestore, retrySafe: operationType === "query" }
99787
+ onestore: {
99788
+ ...requestOptions,
99789
+ ...init?.onestore,
99790
+ ...operationType !== void 0 && { retrySafe: operationType === "query" }
99791
+ }
96676
99792
  };
96677
99793
  }
96678
99794
  function isAbortableCommand(command) {
@@ -96753,11 +99869,19 @@
96753
99869
  get operationType() {
96754
99870
  return void 0;
96755
99871
  }
99872
+ /** Declared `onestore` options (`handleServerError`, `compression`); generated overrides (`generateRequestOptionsGetter`). */
99873
+ get requestOptions() {
99874
+ return void 0;
99875
+ }
96756
99876
  fetch(contextSeed) {
96757
99877
  try {
96758
99878
  const [input, init] = this.fetchParams;
96759
99879
  const operationType = this.operationType;
96760
- const initWithOptions = applyOperationRequestOptions(init, operationType);
99880
+ const initWithOptions = applyOperationRequestOptions$1(
99881
+ init,
99882
+ operationType,
99883
+ this.requestOptions
99884
+ );
96761
99885
  const seed = operationType === void 0 ? contextSeed : { operationType, ...contextSeed };
96762
99886
  const initWithSeed = seed === void 0 ? initWithOptions : { ...initWithOptions, __contextSeed: seed };
96763
99887
  const fetchCall = initWithSeed === void 0 ? this.services.fetch(input) : this.services.fetch(input, initWithSeed);
@@ -96832,6 +99956,19 @@
96832
99956
  };
96833
99957
  }
96834
99958
 
99959
+ function applyOperationRequestOptions(init, operationType, requestOptions) {
99960
+ if (operationType === void 0 && requestOptions === void 0) {
99961
+ return init;
99962
+ }
99963
+ return {
99964
+ ...init,
99965
+ onestore: {
99966
+ ...requestOptions,
99967
+ ...init?.onestore,
99968
+ ...operationType !== void 0 && { retrySafe: operationType === "query" }
99969
+ }
99970
+ };
99971
+ }
96835
99972
  class HttpCacheControlCommand extends CacheControlCommand {
96836
99973
  constructor(services) {
96837
99974
  super(services);
@@ -96841,9 +99978,25 @@
96841
99978
  requestFromNetwork() {
96842
99979
  return this.fetch();
96843
99980
  }
99981
+ /** Declared `onestore` options (`handleServerError`, `compression`); generated overrides. */
99982
+ get requestOptions() {
99983
+ return void 0;
99984
+ }
99985
+ /**
99986
+ * `params` with request options merged into `init.onestore` (see `applyOperationRequestOptions`).
99987
+ * `retrySafe` follows `operationType` (default `'query'`): hand-written writes must override it.
99988
+ */
99989
+ withRequestOptions([
99990
+ input,
99991
+ init
99992
+ ]) {
99993
+ return [input, applyOperationRequestOptions(init, this.operationType, this.requestOptions)];
99994
+ }
96844
99995
  fetch() {
96845
99996
  try {
96846
- return this.convertFetchResponseToData(this.services.fetch(...this.fetchParams));
99997
+ return this.convertFetchResponseToData(
99998
+ this.services.fetch(...this.withRequestOptions(this.fetchParams))
99999
+ );
96847
100000
  } catch (reason) {
96848
100001
  return resolvedPromiseLike$2(err$1(toError(reason)));
96849
100002
  }
@@ -97853,7 +101006,7 @@
97853
101006
  },
97854
101007
  };
97855
101008
  }
97856
- // version: 1.465.0-91ef58ca05
101009
+ // version: 1.467.0-4f065d2a2a
97857
101010
 
97858
101011
  /**
97859
101012
  * Copyright (c) 2022, Salesforce, Inc.,
@@ -97879,7 +101032,7 @@
97879
101032
  },
97880
101033
  };
97881
101034
  }
97882
- // version: 1.465.0-91ef58ca05
101035
+ // version: 1.467.0-4f065d2a2a
97883
101036
 
97884
101037
  function findExecutableOperation$1(input) {
97885
101038
  const operations = input.query.definitions.filter(
@@ -98076,7 +101229,7 @@
98076
101229
  return buildSubscribableResult$1(result, this.buildSubscribe(), () => this.refresh());
98077
101230
  });
98078
101231
  } else if (this.shouldUseFetch()) {
98079
- const params = this.originalFetchParams;
101232
+ const params = this.withRequestOptions(this.originalFetchParams);
98080
101233
  try {
98081
101234
  return this.convertFetchResponseToData(this.services.fetch(...params)).then(
98082
101235
  (result) => {
@@ -98231,7 +101384,7 @@
98231
101384
  }
98232
101385
  runOriginalRequest() {
98233
101386
  return this.convertFetchResponseToData(
98234
- this.services.fetch(...this.originalFetchParams)
101387
+ this.services.fetch(...this.withRequestOptions(this.originalFetchParams))
98235
101388
  ).then((result) => {
98236
101389
  return buildSubscribableResult$1(result, this.buildSubscribe(), () => this.refresh());
98237
101390
  });
@@ -100291,10 +103444,6 @@
100291
103444
  const instrumentationServiceDescriptor = buildServiceDescriptor$e(loggerService);
100292
103445
  const nimbusSqliteOneStoreCacheServiceDescriptor = buildNimbusDurableCacheInclusionPolicyService(cacheServiceDescriptor.service, new NimbusSqliteOneStoreCache(sqliteStore));
100293
103446
  const featureFlagsServiceDescriptor = buildServiceDescriptor$c();
100294
- const featureFlagsService = featureFlagsServiceDescriptor.service;
100295
- // This disables the GraphQL mapping from lightning/uiGraphQLApi to lightning/graphql in this runtime
100296
- // The gate is likely NOT to rollout ever, but we keep it here for completeness
100297
- featureFlagsService.set('useOneStoreGraphQL', false);
100298
103447
  const luvioUiapiRecordsServiceDescriptor = buildServiceDescriptor$a((configs) => {
100299
103448
  return notifyUpdateAvailableFactory(luvio)(configs);
100300
103449
  }, (recordId, reader) => {
@@ -100393,7 +103542,13 @@
100393
103542
  // so eslint doesn't complain about nimbus
100394
103543
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
100395
103544
  /* global __nimbus */
103545
+ const DRAFT_QUEUE_LANE = {
103546
+ serial: 'serial',
103547
+ concurrentBinary: 'concurrentBinary',
103548
+ };
100396
103549
  let lazyDraftQueue;
103550
+ let lazyBinaryDraftQueue;
103551
+ let lazyRoutingDraftQueue;
100397
103552
  let lazyDraftManager;
100398
103553
  let lazyLuvio;
100399
103554
  let lazyInternalLuvio;
@@ -100482,8 +103637,37 @@
100482
103637
  return false;
100483
103638
  },
100484
103639
  });
100485
- // draft queue
100486
- lazyDraftQueue = buildLdsDraftQueue(lazyDurableStore);
103640
+ // Concurrent binary draft queue configuration is supplied by the native
103641
+ // host on globalThis.LDS, injected before the lds bundle loads so the
103642
+ // runtime sees the Performance Upload Queue levers at startup.
103643
+ const concurrentBinaryQueueConfig = (typeof globalThis !== 'undefined' &&
103644
+ globalThis.LDS) ||
103645
+ {};
103646
+ const useConcurrentBinaryQueue = concurrentBinaryQueueConfig.enableConcurrentBinaryQueue === true;
103647
+ const useQueueBatching = concurrentBinaryQueueConfig.enableDraftBatching === true;
103648
+ const usePerformanceDraftQueue = useConcurrentBinaryQueue || useQueueBatching;
103649
+ if (usePerformanceDraftQueue) {
103650
+ const sharedDraftStore = new PerformanceDraftStore(lazyDurableStore);
103651
+ logInfo(`Serial draft queue created with batching=${useQueueBatching}, strictOrdering=true`);
103652
+ lazyDraftQueue = buildLdsDraftQueue(lazyDurableStore, sharedDraftStore, {
103653
+ // No peer queues when there's no binary lane (batching-only).
103654
+ getPeerQueues: () => (useConcurrentBinaryQueue ? [lazyBinaryDraftQueue] : []),
103655
+ concurrency: 1,
103656
+ strictOrdering: true,
103657
+ queueBatching: useQueueBatching,
103658
+ });
103659
+ if (useConcurrentBinaryQueue) {
103660
+ lazyBinaryDraftQueue = buildLdsPerformanceDraftQueue(lazyDurableStore, sharedDraftStore, {
103661
+ getPeerQueues: () => [lazyDraftQueue],
103662
+ concurrency: 4,
103663
+ strictOrdering: false,
103664
+ });
103665
+ logInfo('Concurrent draft queue created with concurrency=4, strictOrdering=false');
103666
+ }
103667
+ }
103668
+ else {
103669
+ lazyDraftQueue = buildLdsDraftQueue(lazyDurableStore);
103670
+ }
100487
103671
  lazySideEffectStore = new SideEffectStore(lazyDurableStore);
100488
103672
  lazySideEffectService = new SideEffectService(lazyDurableRecordStore, lazyObjectInfoService, userId, formatDisplayValue);
100489
103673
  const draftService = new UiApiDraftRecordService(() => lazyLuvio, lazyDurableStore, lazyObjectInfoService, newRecordId, lazySideEffectStore, lazyDurableRecordStore);
@@ -100491,13 +103675,46 @@
100491
103675
  uiApiRecordHandler.setSideEffectHooks(createSfsSideEffectHooks(lazyDurableRecordStore));
100492
103676
  const quickActionHandler = new QuickActionExecutionRepresentationHandler(() => lazyLuvio, draftService, lazyDraftQueue, lazyNetworkAdapter, isGenerated, lazySideEffectService, lazyObjectInfoService, getRecord);
100493
103677
  const updateRecordQuickActionHandler = new UpdateRecordQuickActionExecutionRepresentationHandler(() => lazyLuvio, draftService, lazyDraftQueue, lazyNetworkAdapter, isGenerated, lazySideEffectService);
100494
- const contentDocumentCompositeActionHandler = new ContentDocumentCompositeRepresentationActionHandler(() => lazyLuvio, userId, draftService, lazyObjectInfoService, lazyDraftQueue, lazyNetworkAdapter, isGenerated, NimbusBinaryStore, lazySideEffectService);
103678
+ // Route the CD composite to the concurrent binary queue when gate-ON so
103679
+ // multipart uploads run in parallel and don't block other DML.
103680
+ const contentDocumentTargetQueue = useConcurrentBinaryQueue
103681
+ ? lazyBinaryDraftQueue
103682
+ : lazyDraftQueue;
103683
+ logInfo(`ContentDocumentVersion handler routed to ${useConcurrentBinaryQueue ? 'concurrent draft queue' : 'serial draft queue'}`);
103684
+ const contentDocumentCompositeActionHandler = new ContentDocumentCompositeRepresentationActionHandler(() => lazyLuvio, userId, draftService, lazyObjectInfoService, contentDocumentTargetQueue, lazyNetworkAdapter, isGenerated, NimbusBinaryStore, lazySideEffectService);
100495
103685
  lazyDraftQueue.addHandler(uiApiRecordHandler);
100496
103686
  lazyDraftQueue.addHandler(quickActionHandler);
100497
103687
  lazyDraftQueue.addHandler(updateRecordQuickActionHandler);
100498
- lazyDraftQueue.addHandler(contentDocumentCompositeActionHandler);
100499
- // draft manager
100500
- lazyDraftManager = new DraftManager(lazyDraftQueue);
103688
+ contentDocumentTargetQueue.addHandler(contentDocumentCompositeActionHandler);
103689
+ // Build the routing façade only with the flag on AND over concrete, handler-owning
103690
+ // queues. Only the worker-realm queues expose isHandlerRegistered; the WebView realm's
103691
+ // Nimbus proxies lack it. Feature-detect that method rather than sniffing the realm via
103692
+ // `window`: it tests the exact invariant the DurableDraftQueue casts below rely on, and
103693
+ // stays correct in non-browser (e.g. jest) realms where `window` is an unreliable realm
103694
+ // signal. The flag must stay the first term — it short-circuits the queue accesses, and
103695
+ // the gate-OFF path never builds lazyBinaryDraftQueue (it is undefined there).
103696
+ const shouldBuildRoutingQueue = useConcurrentBinaryQueue &&
103697
+ typeof lazyDraftQueue.isHandlerRegistered ===
103698
+ 'function' &&
103699
+ typeof lazyBinaryDraftQueue
103700
+ .isHandlerRegistered === 'function';
103701
+ if (shouldBuildRoutingQueue) {
103702
+ // Serial is the primary/fallback lane, so the bridged `draftQueue` routes enqueue
103703
+ // by handler ownership in the worker. Lane labels are stamped onto each action
103704
+ // by getQueueActions so the WebView can display the two queues separately.
103705
+ lazyRoutingDraftQueue = new RoutingDraftQueue({
103706
+ queue: lazyDraftQueue,
103707
+ lane: DRAFT_QUEUE_LANE.serial,
103708
+ }, [
103709
+ {
103710
+ queue: lazyBinaryDraftQueue,
103711
+ lane: DRAFT_QUEUE_LANE.concurrentBinary,
103712
+ },
103713
+ ]);
103714
+ }
103715
+ lazyDraftManager = useConcurrentBinaryQueue
103716
+ ? new RoutingDraftManager(lazyDraftQueue, lazyBinaryDraftQueue, DRAFT_QUEUE_LANE)
103717
+ : new DraftManager(lazyDraftQueue);
100501
103718
  const objectInfoCachingEnvironment = enableObjectInfoCaching(durableEnv, lazyObjectInfoService.ensureObjectInfoCached);
100502
103719
  let draftEnv = makeEnvironmentUiApiRecordDraftAware(lazyLuvio, {
100503
103720
  store,
@@ -100572,7 +103789,9 @@
100572
103789
  internalLuvio: lazyInternalLuvio,
100573
103790
  environment: lazyEnvironment,
100574
103791
  draftManager: lazyDraftManager,
100575
- draftQueue: lazyDraftQueue,
103792
+ draftQueue: lazyRoutingDraftQueue ?? lazyDraftQueue,
103793
+ serialDraftQueue: lazyDraftQueue,
103794
+ binaryDraftQueue: lazyBinaryDraftQueue,
100576
103795
  createPrimingSession: (config) => {
100577
103796
  return primingSessionFactory({
100578
103797
  store: lazyDurableStore,
@@ -100598,7 +103817,7 @@
100598
103817
  id: '@salesforce/lds-network-adapter',
100599
103818
  instrument: instrument$2,
100600
103819
  });
100601
- // version: 1.465.0-91ef58ca05
103820
+ // version: 1.467.0-4f065d2a2a
100602
103821
 
100603
103822
  const { create: create$2, keys: keys$2 } = Object;
100604
103823
  const { stringify, parse } = JSON;
@@ -107993,7 +111212,6 @@
107993
111212
  let graphql_imperative_legacy;
107994
111213
  let graphql_imperative_legacy_v1_import;
107995
111214
  let graphql_state_manager;
107996
- let useOneStoreGraphQL = false;
107997
111215
  const serviceRequirements = {
107998
111216
  instrumentCommand: {
107999
111217
  type: 'instrumentCommand',
@@ -108137,9 +111355,8 @@
108137
111355
  const baseCommand = new executeMutation_ctor(config, mutationType, services);
108138
111356
  return applyDecorators(baseCommand, [services.fetchNetworkCommandBaseClass.availableDecorators.abortable], options);
108139
111357
  });
108140
- useOneStoreGraphQL = services.featureFlags?.get('useOneStoreGraphQL') ?? false;
108141
111358
  if (provisionedCallback) {
108142
- provisionedCallback(graphql$1, graphql_imperative$1, graphql_imperative_legacy, graphql_state_manager, useOneStoreGraphQL);
111359
+ provisionedCallback(graphql$1, graphql_imperative$1, graphql_imperative_legacy, graphql_state_manager);
108143
111360
  }
108144
111361
  }, (_err) => { });
108145
111362
  function registerCallback(cb) {
@@ -108147,10 +111364,10 @@
108147
111364
  provisionedCallback = cb;
108148
111365
  }
108149
111366
  else {
108150
- cb(graphql_v1_import, graphql_imperative$1, graphql_imperative_legacy_v1_import, graphql_state_manager, useOneStoreGraphQL);
111367
+ cb(graphql_v1_import, graphql_imperative$1, graphql_imperative_legacy_v1_import, graphql_state_manager);
108151
111368
  }
108152
111369
  }
108153
- // version: 1.465.0-4371689bb7
111370
+ // version: 1.467.0-049a8b6027
108154
111371
 
108155
111372
  function createFragmentMap(documentNode) {
108156
111373
  const fragments = {};
@@ -136849,7 +140066,7 @@
136849
140066
  // Imperative GET Adapters
136850
140067
  let graphql_imperative;
136851
140068
  let graphqlBatch_imperative;
136852
- let refreshGraphQL = (data) => {
140069
+ const refreshGraphQL = (data) => {
136853
140070
  return refresh$3(data, 'refreshUiApi');
136854
140071
  };
136855
140072
  // Adapter Metadata
@@ -136873,21 +140090,7 @@
136873
140090
  ({ graphql, graphqlBatch, graphql_imperative, graphqlBatch_imperative } = bindExportsTo(luvio));
136874
140091
  // We register the callback here rather than above withDefaultLuvio because we want environments
136875
140092
  // that don't have onestore bootstrapped with the required services to default to the luvio versions of the adapters
136876
- registerCallback((oneStoreGraphql, oneStoreGraphql_imperative, oneStoreGraphql_imperative_legacy, oneStoreGraphql_state_manager, useOneStoreGraphQL) => {
136877
- if (useOneStoreGraphQL) {
136878
- graphql = oneStoreGraphql;
136879
- graphql_imperative = oneStoreGraphql_imperative_legacy;
136880
- refreshGraphQL = (data) => {
136881
- if (data?.data === undefined && data?.errors === undefined) {
136882
- return Promise.resolve();
136883
- }
136884
- if (typeof data?.refresh === 'function') {
136885
- return data.refresh();
136886
- }
136887
- // eslint-disable-next-line @salesforce/lds/no-error-in-production
136888
- throw new Error('Component attempted to refresh a GraphQL result that is not refreshable');
136889
- };
136890
- }
140093
+ registerCallback((oneStoreGraphql, oneStoreGraphql_imperative, _oneStoreGraphql_imperative_legacy, oneStoreGraphql_state_manager) => {
136891
140094
  });
136892
140095
  });
136893
140096
 
@@ -137393,7 +140596,7 @@
137393
140596
  configuration: { ...configurationForGraphQLAdapters$1 },
137394
140597
  instrument: instrument$1,
137395
140598
  });
137396
- // version: 1.465.0-4371689bb7
140599
+ // version: 1.467.0-049a8b6027
137397
140600
 
137398
140601
  // On core the unstable adapters are re-exported with different names,
137399
140602
  // we want to match them here.
@@ -137405,7 +140608,7 @@
137405
140608
  gql: gql,
137406
140609
  get graphql () { return graphql; },
137407
140610
  get graphqlBatch () { return graphqlBatch; },
137408
- get refreshGraphQL () { return refreshGraphQL; },
140611
+ refreshGraphQL: refreshGraphQL,
137409
140612
  get unstable_getActionOverrides_imperative () { return unstable_getActionOverrides_imperative; },
137410
140613
  get unstable_getFlexipageFormulaOverrides_imperative () { return unstable_getFlexipageFormulaOverrides_imperative; },
137411
140614
  get unstable_getGlobalActions_imperative () { return unstable_getGlobalActions_imperative; },
@@ -137545,7 +140748,7 @@
137545
140748
  unstable_graphQL_imperative = createImperativeAdapter(luvio, createInstrumentedAdapter(ldsAdapter, adapterMetadata), adapterMetadata);
137546
140749
  graphQLImperative = ldsAdapter;
137547
140750
  });
137548
- // version: 1.465.0-4371689bb7
140751
+ // version: 1.467.0-049a8b6027
137549
140752
 
137550
140753
  var gqlApi = /*#__PURE__*/Object.freeze({
137551
140754
  __proto__: null,
@@ -138397,7 +141600,7 @@
138397
141600
  function register(r) {
138398
141601
  callbacks$1.forEach((callback) => callback(r));
138399
141602
  }
138400
- // version: 1.465.0-91ef58ca05
141603
+ // version: 1.467.0-4f065d2a2a
138401
141604
 
138402
141605
  /**
138403
141606
  * Copyright (c) 2022, Salesforce, Inc.,
@@ -139708,4 +142911,4 @@
139708
142911
  exports.subscribeToAdapter = subscribeToAdapter;
139709
142912
 
139710
142913
  }));
139711
- // version: 1.465.0-91ef58ca05
142914
+ // version: 1.467.0-4f065d2a2a