@salesforce/lds-worker-api 1.466.0 → 1.468.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.
@@ -4276,7 +4276,7 @@ function withDefaultLuvio(callback) {
4276
4276
  }
4277
4277
  callbacks.push(callback);
4278
4278
  }
4279
- // version: 1.466.0-b7b08a8e3c
4279
+ // version: 1.468.0-9ef0fbdaee
4280
4280
 
4281
4281
  // TODO [TD-0081508]: once that TD is fulfilled we can probably change this file
4282
4282
  function instrumentAdapter$1(createFunction, _metadata) {
@@ -5320,7 +5320,7 @@ function createGraphQLWireAdapterConstructor(luvio, adapter, metadata, astResolv
5320
5320
  const { apiFamily, name } = metadata;
5321
5321
  return createGraphQLWireAdapterConstructor$1(adapter, `${apiFamily}.${name}`, luvio, astResolver);
5322
5322
  }
5323
- // version: 1.466.0-b7b08a8e3c
5323
+ // version: 1.468.0-9ef0fbdaee
5324
5324
 
5325
5325
  function isSupportedEntity(_objectApiName) {
5326
5326
  return true;
@@ -32565,7 +32565,7 @@ withDefaultLuvio((luvio) => {
32565
32565
  throttle(60, 60000, setupNotifyAllListRecordUpdateAvailable(luvio));
32566
32566
  throttle(60, 60000, setupNotifyAllListInfoSummaryUpdateAvailable(luvio));
32567
32567
  });
32568
- // version: 1.466.0-ba624febc8
32568
+ // version: 1.468.0-c060a9b9c6
32569
32569
 
32570
32570
  var draftQueueMaxRetryAttemptsGate = {
32571
32571
  isOpen: function (e) {
@@ -32646,6 +32646,15 @@ var allowUpdatesForNonCachedRecords = {
32646
32646
  },
32647
32647
  };
32648
32648
 
32649
+ var sideEffectRefreshKillSwitch = {
32650
+ isOpen: function (e) {
32651
+ return e.fallback;
32652
+ },
32653
+ hasError: function () {
32654
+ return !0;
32655
+ },
32656
+ };
32657
+
32649
32658
  var caseSensitiveUserId = '005B0000000GR4OIAW';
32650
32659
 
32651
32660
  /**
@@ -44097,6 +44106,15 @@ function parseAndVisit(source) {
44097
44106
  return luvioDocumentNode;
44098
44107
  }
44099
44108
 
44109
+ var parentRelationshipPrefetchKillSwitch = {
44110
+ isOpen: function (e) {
44111
+ return e.fallback;
44112
+ },
44113
+ hasError: function () {
44114
+ return !0;
44115
+ },
44116
+ };
44117
+
44100
44118
  var graphqlRelationshipFieldsPerf = {
44101
44119
  isOpen: function (e) {
44102
44120
  return e.fallback;
@@ -48050,6 +48068,14 @@ var DraftQueueEventType;
48050
48068
  * Triggered after the Draft Queue state changes
48051
48069
  */
48052
48070
  DraftQueueEventType["QueueStateChanged"] = "state";
48071
+ /**
48072
+ * Triggered when batch-journal crash recovery (B2, Performance Upload path)
48073
+ * could not complete: a maybe-committed all-or-none batch's members could not
48074
+ * be durably quarantined this session, a startup reconcile failed, or a
48075
+ * resolved journal row could not be cleared. Carries only a bounded reason
48076
+ * code (no ids/messages) so it is safe to surface to o11y.
48077
+ */
48078
+ DraftQueueEventType["BatchRecoveryFailed"] = "batchRecoveryFailed";
48053
48079
  })(DraftQueueEventType || (DraftQueueEventType = {}));
48054
48080
  var QueueOperationType;
48055
48081
  (function (QueueOperationType) {
@@ -48259,6 +48285,8 @@ function customActionHandler(executor, id, draftQueue) {
48259
48285
  mergeActions: () => {
48260
48286
  throw Error('mergeActions not supported for custom actions');
48261
48287
  },
48288
+ getDraftIdsFromAction: (action) => [action.targetId],
48289
+ getRedirectMappings: () => undefined,
48262
48290
  };
48263
48291
  }
48264
48292
 
@@ -48342,6 +48370,13 @@ class DurableDraftQueue {
48342
48370
  const handler = customActionHandler(executor, id, this);
48343
48371
  return this.addHandler(handler);
48344
48372
  }
48373
+ /**
48374
+ * True when a handler for `id` is registered on this queue — i.e. this queue owns
48375
+ * (and drains) the actions carrying that handlerId.
48376
+ */
48377
+ isHandlerRegistered(id) {
48378
+ return this.handlers[id] !== undefined;
48379
+ }
48345
48380
  getQueueState() {
48346
48381
  return this.state;
48347
48382
  }
@@ -48366,6 +48401,11 @@ class DurableDraftQueue {
48366
48401
  }
48367
48402
  this.retryIntervalMilliseconds = 0;
48368
48403
  this.state = DraftQueueState.Started;
48404
+ // Stuck-queue detection: a live upload keeps the queue Started (filtered by the early
48405
+ // return above), so a stale uploadingActionId here strands processNextAction() on it.
48406
+ if (this.uploadingActionId !== undefined && this.logger) {
48407
+ this.logger.logWarning(`Draft queue restarting with action still marked uploading (stale uploadingActionId=${this.uploadingActionId}); possible abandoned-upload stuck queue`);
48408
+ }
48369
48409
  await this.notifyChangedListeners({
48370
48410
  type: DraftQueueEventType.QueueStateChanged,
48371
48411
  state: this.state,
@@ -48670,6 +48710,48 @@ class DurableDraftQueue {
48670
48710
  await this.startQueue();
48671
48711
  }
48672
48712
  }
48713
+ /**
48714
+ * Builds the set of actions deleted by removing `action`, validating
48715
+ * before any store mutation. Same-tag actions are ordered newest-first so
48716
+ * the clicked create is handled last and each intermediate side-effect
48717
+ * replay reflects a valid undo prefix.
48718
+ */
48719
+ resolveActionsToRemove(action, queue, isUploading) {
48720
+ const shouldDeleteRelated = this.getHandler(action.handler).shouldDeleteActionByTagOnRemoval(action);
48721
+ const removedActions = shouldDeleteRelated
48722
+ ? queue.filter((a) => a.tag === action.tag).reverse()
48723
+ : [action];
48724
+ const uploading = removedActions.find((a) => isUploading(a.id));
48725
+ if (uploading !== undefined) {
48726
+ // eslint-disable-next-line @salesforce/lds/no-error-in-production
48727
+ throw new Error(`Cannot remove an uploading draft action with ID ${uploading.id}`);
48728
+ }
48729
+ return {
48730
+ shouldDeleteRelated,
48731
+ removed: removedActions.map((a) => ({
48732
+ action: a,
48733
+ handler: this.getHandler(a.handler),
48734
+ })),
48735
+ };
48736
+ }
48737
+ /**
48738
+ * Runs every removed action's handler cleanup before emitting any
48739
+ * ActionDeleted event, so listeners observe the final side-effect state.
48740
+ */
48741
+ async notifyActionsRemoved(removed, queue) {
48742
+ const removedIds = new Set(removed.map(({ action }) => action.id));
48743
+ const remaining = queue.filter((a) => !removedIds.has(a.id));
48744
+ for (const { action, handler } of removed) {
48745
+ await handler.handleActionRemoved(action, remaining);
48746
+ }
48747
+ for (const { action } of removed) {
48748
+ await this.notifyChangedListeners({
48749
+ type: DraftQueueEventType.ActionDeleted,
48750
+ action,
48751
+ draftCount: this.draftStore.getCount(),
48752
+ });
48753
+ }
48754
+ }
48673
48755
  async removeDraftAction(actionId) {
48674
48756
  const queue = await this.getQueueActions();
48675
48757
  //Get the store key for the removed action
@@ -48679,32 +48761,18 @@ class DurableDraftQueue {
48679
48761
  throw new Error(`No removable action with id ${actionId}`);
48680
48762
  }
48681
48763
  const action = actions[0];
48682
- if (action.id === this.uploadingActionId) {
48683
- // eslint-disable-next-line @salesforce/lds/no-error-in-production
48684
- throw new Error(`Cannot remove an uploading draft action with ID ${actionId}`);
48685
- }
48686
- const handler = this.getHandler(action.handler);
48687
- const shouldDeleteRelated = handler.shouldDeleteActionByTagOnRemoval(action);
48764
+ const { shouldDeleteRelated, removed } = this.resolveActionsToRemove(action, queue, (id) => id === this.uploadingActionId);
48688
48765
  if (shouldDeleteRelated) {
48689
48766
  await this.draftStore.deleteByTag(action.tag);
48690
- queue.forEach((queuedAction) => {
48691
- if (queuedAction.tag === action.tag) {
48692
- this.uploadStartTimes.delete(queuedAction.id);
48693
- this.actionQueueWaitEmittedIds.delete(queuedAction.id);
48694
- }
48695
- });
48696
48767
  }
48697
48768
  else {
48698
48769
  await this.draftStore.deleteDraft(action.id);
48699
- this.uploadStartTimes.delete(action.id);
48700
- this.actionQueueWaitEmittedIds.delete(action.id);
48701
48770
  }
48702
- await handler.handleActionRemoved(action, queue.filter((x) => x.id !== actionId));
48703
- await this.notifyChangedListeners({
48704
- type: DraftQueueEventType.ActionDeleted,
48705
- action,
48706
- draftCount: this.draftStore.getCount(),
48771
+ removed.forEach(({ action: a }) => {
48772
+ this.uploadStartTimes.delete(a.id);
48773
+ this.actionQueueWaitEmittedIds.delete(a.id);
48707
48774
  });
48775
+ await this.notifyActionsRemoved(removed, queue);
48708
48776
  if (this.userState === DraftQueueState.Started &&
48709
48777
  this.state !== DraftQueueState.Started &&
48710
48778
  this.replacingAction === undefined) {
@@ -48743,15 +48811,12 @@ class DurableDraftQueue {
48743
48811
  return this.replaceOrMergeActions(targetActionId, sourceActionId, true);
48744
48812
  }
48745
48813
  async retryAction(actionId) {
48746
- this.stopQueueManually(true);
48747
- const actions = await this.getQueueActions();
48748
- const target = actions[0];
48749
- if (!target || target.id !== actionId) {
48750
- throw Error(`Action ${actionId} not found at the head of the draft queue`);
48751
- }
48814
+ // Validate before stopping so a rejected retry leaves the queue state untouched.
48815
+ const target = await this.getRetryTarget(actionId);
48752
48816
  if (!isDraftError(target)) {
48753
48817
  throw Error(`Action ${actionId} is not in Error state`);
48754
48818
  }
48819
+ this.stopQueueManually(true);
48755
48820
  // A manual retry is a fresh start: clear the persisted upload retry-attempt
48756
48821
  // count so the action doesn't immediately re-cap after a single failure.
48757
48822
  const { [DRAFT_ACTION_RETRY_COUNT_METADATA_KEY]: _retryCount, ...metadataWithoutRetryCount } = target.metadata || {};
@@ -48770,6 +48835,18 @@ class DurableDraftQueue {
48770
48835
  await this.startQueueSafe();
48771
48836
  return pendingAction;
48772
48837
  }
48838
+ /**
48839
+ * The action `retryAction` may retry. Only the head: an errored head is what blocks a
48840
+ * strictly ordered queue.
48841
+ */
48842
+ async getRetryTarget(actionId) {
48843
+ const actions = await this.getQueueActions();
48844
+ const target = actions[0];
48845
+ if (!target || target.id !== actionId) {
48846
+ throw Error(`Action ${actionId} not found at the head of the draft queue`);
48847
+ }
48848
+ return target;
48849
+ }
48773
48850
  async setMetadata(actionId, metadata) {
48774
48851
  const keys$1 = keys$4(metadata);
48775
48852
  const compatibleKeys = keys$1.filter((key) => {
@@ -48896,239 +48973,1827 @@ class DurableDraftQueue {
48896
48973
  }
48897
48974
  }
48898
48975
 
48899
- function clone$1(obj) {
48900
- return parse$6(stringify$6(obj));
48976
+ /**
48977
+ * Narrows a peer DraftQueue to one that also participates in cross-queue
48978
+ * coordination. A peer may be a NimbusDraftQueue (gate-ON on-device path),
48979
+ * which implements DraftQueue but NOT CrossQueueCoordination, so this check
48980
+ * is load-bearing, not defensive.
48981
+ */
48982
+ function isCrossQueueCoordination(queue) {
48983
+ const candidate = queue;
48984
+ return (typeof candidate.applyDraftIdRedirects === 'function' &&
48985
+ typeof candidate.resumeDeferredActions === 'function' &&
48986
+ typeof candidate.getInFlightRewritableDraftIds === 'function' &&
48987
+ typeof candidate.getOwnedQueueActions === 'function');
48901
48988
  }
48902
-
48903
- const DRAFT_ACTION_KEY_JUNCTION = '__DraftAction__';
48904
- function buildDraftDurableStoreKey(recordKey, draftActionId) {
48905
- return `${recordKey}${DRAFT_ACTION_KEY_JUNCTION}${draftActionId}`;
48989
+ /**
48990
+ * Maximum number of draft actions to combine into a single UI-API Batch
48991
+ * request when batching is enabled. Tunable; chosen as a conservative starting
48992
+ * point that bounds payload size and retry cost while still amortizing TTFB
48993
+ * across many actions.
48994
+ */
48995
+ const MAX_BATCH_SIZE$1 = 25;
48996
+ /**
48997
+ * How many times clearBatchJournalIfSupported retries a failed journal clear
48998
+ * before giving up and escalating the log line. Bounded so a permanently-failing
48999
+ * store can't spin. A leftover row is mainly a scale concern (unbounded restart
49000
+ * reconcile); it can also cause a rare, fail-safe over-quarantine — a definitively
49001
+ * rolled-back batch's still-Pending siblings surface as commit-ambiguous on the
49002
+ * next restart (never a replay, since the envelope records no rolled-back outcome).
49003
+ * A small bound suffices for the spin guard; a durable rolled-back/resolved
49004
+ * tombstone is the follow-up to close the over-quarantine before queueBatching is
49005
+ * enabled (4077120435).
49006
+ */
49007
+ const CLEAR_JOURNAL_MAX_ATTEMPTS = 3;
49008
+ /**
49009
+ * Diagnostic log line for the local-experimental batching path. Routes through
49010
+ * JSLoggerPlugin.logInfo so messages surface in the iOS device log, each line
49011
+ * prefixed with `Batch `. Silently no-ops outside the LWR JSC environment.
49012
+ */
49013
+ function batchLog$1(line) {
49014
+ try {
49015
+ globalThis.__nimbus?.plugins?.JSLoggerPlugin?.logInfo(`Batch ${line}`);
49016
+ }
49017
+ catch {
49018
+ // debug-only; never fail real work because of logging
49019
+ }
48906
49020
  }
48907
49021
  /**
48908
- * Implements a write-through InMemoryStore for Drafts, storing all drafts in a
48909
- * in-memory store with a write through to the DurableStore.
48910
- *
48911
- * Before any reads or writes come in from the draft queue, we need to revive the draft
48912
- * queue into memory. During this initial revive, any writes are queued up and operated on the
48913
- * queue once it's in memory. Similarly any reads are delayed until the queue is in memory.
48914
- *
49022
+ * Bounded, scrubbed rendering of a caught value for the device log. Emits only
49023
+ * the error constructor NAME (e.g. "TypeError"), never the free-text message,
49024
+ * which can embed record ids (URL path segments) or server field-validation
49025
+ * strings. Mirrors the device-log PII policy in metrics.ts
49026
+ * (reportDraftActionUploadError): bounded metadata only; full detail belongs on
49027
+ * the structured, scrubbed o11y path (4065667273).
48915
49028
  */
48916
- class DurableDraftStore {
48917
- constructor(durableStore, instrumentation, useSnapshot = true) {
48918
- this.draftStore = {};
48919
- // Snapshot: holds deep clones of each draft, refreshed at write time.
48920
- // getAllDrafts serves from this snapshot with zero deep clones per read.
48921
- this.cleanSnapshot = {};
48922
- // queue of writes that were made during the initial sync
48923
- this.writeQueue = [];
48924
- this.durableStore = durableStore;
48925
- this.instrumentation = instrumentation;
48926
- this.useSnapshot = useSnapshot;
48927
- this.resyncDraftStore();
49029
+ function errName(e) {
49030
+ try {
49031
+ const name = e?.name;
49032
+ if (typeof name !== 'string' || name.length === 0) {
49033
+ return 'Error';
49034
+ }
49035
+ // Strip everything outside printable ASCII, then bound the length. The
49036
+ // id/free-text PII vector is already closed by reading `.name` (a
49037
+ // constructor name) and never `.message` (see above) — but a thrown object
49038
+ // can carry an arbitrary `name` (and reading it can trip a getter), so a
49039
+ // newline or control character could still break device-log-line integrity
49040
+ // and a pathological length could blow up a log entry. This drops control
49041
+ // sequences and bounds length; it does NOT attempt to strip a printable
49042
+ // identifier, since nothing in this path populates `.name` with one
49043
+ // (4077120502).
49044
+ const cleaned = name.replace(/[^\x20-\x7E]/g, '');
49045
+ if (cleaned.length === 0) {
49046
+ return 'Error';
49047
+ }
49048
+ return cleaned.length > 64 ? cleaned.slice(0, 64) : cleaned;
48928
49049
  }
48929
- // When no instrumentation is injected the promise is returned as-is (zero overhead).
48930
- timeDurableOp(op, segment, run) {
48931
- const onDurableOperation = this.instrumentation?.onDurableOperation;
48932
- if (onDurableOperation === undefined) {
48933
- return run();
48934
- }
48935
- const start = Date.now();
48936
- return run().finally(() => {
48937
- onDurableOperation(op, segment, Date.now() - start);
48938
- });
49050
+ catch {
49051
+ // Reading `.name` threw (a hostile getter). Scrubbing a value for the
49052
+ // log must never itself throw and take down the caller's error path.
49053
+ return 'Error';
48939
49054
  }
48940
- writeAction(action) {
48941
- const addAction = () => {
48942
- const { id, tag } = action;
48943
- this.draftStore[id] = action;
48944
- this.cleanSnapshot[id] = clone$1(action);
48945
- const durableEntryKey = buildDraftDurableStoreKey(tag, id);
48946
- const entry = {
48947
- data: action,
48948
- };
48949
- const entries = { [durableEntryKey]: entry };
48950
- return this.timeDurableOp('setEntries', DRAFT_SEGMENT, () => this.durableStore.setEntries(entries, DRAFT_SEGMENT));
49055
+ }
49056
+ /**
49057
+ * Builds the queue operations needed to rewrite every queued action whose
49058
+ * basePath/body text contains a draft id from `redirects`. If a queued
49059
+ * action's tag matches a redirect's draftKey, the action is deleted and
49060
+ * re-added at the canonical key. Pure function — no draft-store side
49061
+ * effects. Used by PerformanceDraftQueue.applyDraftIdRedirects to propagate
49062
+ * a peer queue's POST completion across queues.
49063
+ */
49064
+ function rewriteQueueActionsForRedirects(queue, redirects) {
49065
+ const queueOperations = [];
49066
+ if (redirects.length === 0) {
49067
+ return queueOperations;
49068
+ }
49069
+ for (let i = 0, len = queue.length; i < len; i++) {
49070
+ const queueAction = queue[i];
49071
+ const data = queueAction.data;
49072
+ if (data === undefined ||
49073
+ data === null ||
49074
+ typeof data.method !== 'string' ||
49075
+ data.body === undefined) {
49076
+ continue;
49077
+ }
49078
+ let basePath = data.basePath || '';
49079
+ let stringifiedBody = JSON.stringify(data.body || '');
49080
+ let mutated = false;
49081
+ let updatedTag;
49082
+ let updatedTargetId;
49083
+ for (const r of redirects) {
49084
+ if (basePath.indexOf(r.draftId) >= 0 || stringifiedBody.indexOf(r.draftId) >= 0) {
49085
+ const re = new RegExp(r.draftId, 'g');
49086
+ basePath = basePath.replace(re, r.canonicalId);
49087
+ stringifiedBody = stringifiedBody.replace(re, r.canonicalId);
49088
+ mutated = true;
49089
+ }
49090
+ if (queueAction.tag === r.draftKey) {
49091
+ updatedTag = r.canonicalKey;
49092
+ updatedTargetId = r.canonicalId;
49093
+ }
49094
+ }
49095
+ if (!mutated) {
49096
+ continue;
49097
+ }
49098
+ const baseUpdated = {
49099
+ ...queueAction,
49100
+ data: { ...data, basePath, body: JSON.parse(stringifiedBody) },
48951
49101
  };
48952
- return this.enqueueAction(addAction);
49102
+ if (updatedTag !== undefined && updatedTargetId !== undefined) {
49103
+ queueOperations.push({ type: QueueOperationType.Delete, id: queueAction.id });
49104
+ queueOperations.push({
49105
+ type: QueueOperationType.Add,
49106
+ action: { ...baseUpdated, tag: updatedTag, targetId: updatedTargetId },
49107
+ });
49108
+ }
49109
+ else {
49110
+ queueOperations.push({
49111
+ type: QueueOperationType.Update,
49112
+ id: queueAction.id,
49113
+ action: baseUpdated,
49114
+ });
49115
+ }
48953
49116
  }
48954
- getAllDrafts() {
48955
- const waitForOngoingSync = this.syncPromise || Promise.resolve();
48956
- return waitForOngoingSync.then(() => {
48957
- const actionArray = [];
48958
- if (this.useSnapshot) {
48959
- const { cleanSnapshot } = this;
48960
- const keys$1 = keys$4(cleanSnapshot);
48961
- for (let i = 0, len = keys$1.length; i < len; i++) {
48962
- const key = keys$1[i];
48963
- // Return shallow spread of snapshot entries so top-level caller mutations
48964
- // (e.g., .status =) don't persist into the snapshot. The snapshot itself
48965
- // is a deep clone of draftStore, so no nested mutation reaches draftStore.
48966
- actionArray.push({ ...cleanSnapshot[key] });
48967
- }
49117
+ return queueOperations;
49118
+ }
49119
+ /**
49120
+ * The Performance Upload draft queue. Extends {@link DurableDraftQueue} — the
49121
+ * base class is byte-identical to today's shipped serial queue — and adds the
49122
+ * multi-candidate concurrent scheduler, Set-based in-flight tracking,
49123
+ * cross-queue redirect propagation, and UI-API batching. Only constructed on
49124
+ * the gate-ON code path; gate-off customers never run any of this.
49125
+ */
49126
+ class PerformanceDraftQueue extends DurableDraftQueue {
49127
+ constructor(draftStore, { getPeerQueues = () => [], concurrency = 1, strictOrdering = true, queueBatching = false, } = {}) {
49128
+ super(draftStore);
49129
+ this.uploadingActionIds = new Set();
49130
+ // Reentrancy guard for processNextAction, which dispatches into free concurrency
49131
+ // slots with eligible actions. It is invoked un-awaited from several sites
49132
+ // (actionCompleted, applyDraftIdRedirects, resumeDeferredActions, enqueue)
49133
+ // and awaits mid-pass to read peer state, so without this two passes could
49134
+ // clear the concurrency guard, select the same action, and dispatch it
49135
+ // twice. `isDispatching` is set synchronously before any await; a concurrent
49136
+ // entrant records `redispatchRequested` and bails, and the active pass re-runs
49137
+ // to honor the missed wakeup.
49138
+ this.isDispatching = false;
49139
+ this.redispatchRequested = false;
49140
+ // Monotonic counter used to mint a unique batchId per dispatched batch
49141
+ // (combined with the head action id). Deterministic and dependency-free —
49142
+ // no Date/random — and unique within a session; across restarts a journal
49143
+ // is either cleared on completion or consumed by reconcile, so ids never
49144
+ // collide meaningfully.
49145
+ this.batchSequence = 0;
49146
+ // Guards the one-time batch-completion journal reconcile so it runs on the
49147
+ // first startQueue of the process (crash recovery) and not on every
49148
+ // stop/start cycle. Holds the in-flight reconcile promise rather than a
49149
+ // set-first boolean: startQueue can be called concurrently at launch (the
49150
+ // first call's reconcile awaits before super.startQueue() marks the queue
49151
+ // Started, so a second call still passes the base state guard). A boolean
49152
+ // set before the await would let that second call skip reconcile and begin
49153
+ // dispatching — re-batching a journaled, committed, not-yet-resolved member
49154
+ // and dropping its idempotency key. Every caller awaits the same promise,
49155
+ // so no dispatch begins until the members are surfaced as errors.
49156
+ this.reconcileBatchJournalsPromise = undefined;
49157
+ // Actions currently in retry backoff. A failed-but-retryable action stays
49158
+ // Pending in the store, so without this a re-entrant dispatch pass (a peer
49159
+ // kick or a sibling completion) could re-select and re-dispatch it before
49160
+ // its backoff timer fires. Populated in actionFailed's retry branch and
49161
+ // cleared per-action when that action's own backoff timer elapses, plus on
49162
+ // permanent failure / removal.
49163
+ this.retryingActionIds = new Set();
49164
+ // Members of a maybe-committed batch whose commit status could NOT be moved
49165
+ // to a durable Error this session (both the atomic completion and the
49166
+ // commit-ambiguous quarantine write failed — the store was rejecting writes
49167
+ // right after the server committed). They are still Pending in the store, so
49168
+ // without this guard a later startQueue / retryAllErrors / reconnect would
49169
+ // re-select them and re-batch keyless, replaying a maybe-committed write
49170
+ // (Option A forbids that). Excluded from dispatch (isDispatchable and the
49171
+ // single-action candidate loop) until they are durably resolved. In-memory
49172
+ // only: it bridges the current session; a restart clears it and the retained
49173
+ // batch journal is surfaced by reconcile before dispatch instead.
49174
+ this.commitAmbiguousUnresolvedIds = new Set();
49175
+ // Per-action retry backoff state. The serial base tracks a single
49176
+ // `timeoutHandler` + single `retryIntervalMilliseconds`, which conflate
49177
+ // concurrent retries: two actions failing at different times would overwrite
49178
+ // each other's timer (leaking the earlier one) and share one exponential
49179
+ // interval, so the first timer to fire would unpark every retrying action on
49180
+ // whichever backoff happened to be shortest. A concurrent queue instead
49181
+ // parks each action under its OWN timer and its OWN growing interval, keyed
49182
+ // by action id, so each retries on its own schedule independent of siblings.
49183
+ this.retryTimers = new Map();
49184
+ this.retryIntervals = new Map();
49185
+ // Draft ids of in-flight/just-completed POSTs whose redirects have not yet
49186
+ // been applied to peer queues. actionCompleted resolves a completing POST
49187
+ // out of the store (completeAction) BEFORE propagateRedirectsToPeers
49188
+ // fire-and-forgets the rewrite to peers, so between those two points
49189
+ // getInFlightRewritableDraftIds would no longer see the id in the store and
49190
+ // a peer could dispatch a queued action still carrying the stale draft id.
49191
+ // Ids are retained here before completeAction and released only once the
49192
+ // peer applyDraftIdRedirects calls settle; getInFlightRewritableDraftIds
49193
+ // unions this set so peers keep deferring across the gap.
49194
+ this.redirectingDraftIds = new Set();
49195
+ this.getPeerQueues = getPeerQueues;
49196
+ this.concurrency = concurrency;
49197
+ this.strictOrdering = strictOrdering;
49198
+ this.queueBatching = queueBatching;
49199
+ }
49200
+ /**
49201
+ * One-time batch-journal crash recovery (B2). A journal entry that survives
49202
+ * a restart means a batch was dispatched but its members were never
49203
+ * confirmed removed — the app may have died after the server committed the
49204
+ * all-or-none request. Any journaled member still Pending might therefore be
49205
+ * committed already, and the batch envelope carried no server-visible
49206
+ * idempotency key, so it must not be re-batched OR re-dispatched (either
49207
+ * would risk replaying a committed write). Surface every still-Pending member
49208
+ * as a commit-ambiguous error for manual resolution (Option A). The journal
49209
+ * entry is cleared only once its members are resolved (surfaced or already
49210
+ * gone), so a failure to record the error leaves the entry for the next
49211
+ * startup.
49212
+ */
49213
+ reconcileBatchJournalsOnce() {
49214
+ // Memoize the in-flight promise so concurrent startQueue calls all await
49215
+ // the same reconcile (see reconcileBatchJournalsPromise). Assigning
49216
+ // before the first await is the point: a later caller reuses this
49217
+ // promise instead of racing past a boolean that was flipped before the
49218
+ // pinning work ran.
49219
+ //
49220
+ // Reconcile must fail CLOSED (4065665821): if it rejects, startQueue does
49221
+ // NOT dispatch. But the failure must be RETRYABLE — clear the memo on
49222
+ // rejection so the next startQueue re-runs the reconcile rather than
49223
+ // reusing a cached rejection and never recovering. Success stays memoized
49224
+ // (runs at most once per process).
49225
+ if (this.reconcileBatchJournalsPromise === undefined) {
49226
+ this.reconcileBatchJournalsPromise = this.runBatchJournalReconcile().catch((e) => {
49227
+ this.reconcileBatchJournalsPromise = undefined;
49228
+ // Single choke point for every reconcile failure (journal read
49229
+ // error, resolve failure, or missing journal support): surface one
49230
+ // bounded o11y signal before re-throwing so startQueue still fails
49231
+ // closed. Fire-and-forget — the rejection must propagate unchanged.
49232
+ this.signalBatchRecoveryFailed('reconcile-failed');
49233
+ throw e;
49234
+ });
49235
+ }
49236
+ return this.reconcileBatchJournalsPromise;
49237
+ }
49238
+ async runBatchJournalReconcile() {
49239
+ // Nothing journals when batching is off, so there is nothing to reconcile.
49240
+ if (!this.queueBatching) {
49241
+ return;
49242
+ }
49243
+ const store = this.draftStore;
49244
+ if (typeof store.readRecoverableBatchJournals !== 'function' ||
49245
+ typeof store.clearBatchJournal !== 'function' ||
49246
+ typeof store.writeBatchJournal !== 'function') {
49247
+ // Batching is ON but the store cannot journal. Dispatching would batch
49248
+ // keyless writes with no crash backstop, so refuse to start rather than
49249
+ // silently run unprotected (4065665821). Throwing keeps startQueue
49250
+ // stopped; a store misconfigured this way will keep failing, which is
49251
+ // the intended fail-closed outcome.
49252
+ // eslint-disable-next-line @salesforce/lds/no-error-in-production
49253
+ throw new Error('PerformanceDraftQueue: batching enabled but the DraftStore lacks batch-journal support');
49254
+ }
49255
+ // A read failure THROWS here (not swallowed): it propagates through
49256
+ // reconcileBatchJournalsOnce so startQueue stays stopped and retries,
49257
+ // rather than proceeding to dispatch a member of a batch that may have
49258
+ // already committed (4065665821). Only RECOVERABLE journals are read —
49259
+ // crash residue from a dead process. A journal stamped with the live
49260
+ // session's token is a batch still in flight (possibly on a sibling queue
49261
+ // sharing this store); reconciling it would quarantine live members out
49262
+ // from under their owner, so the store filters those out.
49263
+ const journals = await store.readRecoverableBatchJournals();
49264
+ if (journals.length === 0) {
49265
+ return;
49266
+ }
49267
+ for (const journal of journals) {
49268
+ // A journaled member that never completed was in-flight when the
49269
+ // process died: the batch MAY have committed, and the envelope carried
49270
+ // no server-visible idempotency key, so we do NOT re-batch or
49271
+ // re-dispatch it. Surface every still-Pending member as commit-ambiguous
49272
+ // for manual resolution (Option A), then clear the journal once resolved.
49273
+ // A resolve failure THROWS (via reconcileBatchJournalsOnce) so startup
49274
+ // stays stopped-but-retryable and the journal is retained.
49275
+ //
49276
+ // errorAmbiguousActions reads its own fresh draft snapshot per call
49277
+ // rather than sharing one across this loop: each iteration awaits
49278
+ // completeAction and listener callbacks, which can delete a member
49279
+ // (listener re-entrancy, or a concurrent removeDraftAction/deleteByTag),
49280
+ // and a snapshot held across the loop would let a later journal
49281
+ // resurrect that just-deleted member as an Error draft via the blind
49282
+ // Update in completeAction (4077120311). A fresh read per journal costs
49283
+ // O(journals × drafts), acceptable on this once-per-process
49284
+ // crash-recovery path.
49285
+ const resolved = await this.errorAmbiguousActions(journal.memberIds);
49286
+ if (resolved) {
49287
+ await this.clearBatchJournalIfSupported(journal.batchId);
48968
49288
  }
48969
49289
  else {
48970
- // Killswitch (W-23515682): original pre-fix behavior — deep-clone every draft on
48971
- // every read so the internal draft store is never exposed. O(N) clones per read.
48972
- const { draftStore } = this;
48973
- const keys$1 = keys$4(draftStore);
48974
- for (let i = 0, len = keys$1.length; i < len; i++) {
48975
- actionArray.push(clone$1(draftStore[keys$1[i]]));
48976
- }
49290
+ // eslint-disable-next-line @salesforce/lds/no-error-in-production
49291
+ throw new Error(`PerformanceDraftQueue: batch journal ${journal.batchId} could not be reconciled`);
48977
49292
  }
48978
- return actionArray;
48979
- });
49293
+ }
48980
49294
  }
48981
- deleteDraft(id) {
48982
- const deleteAction = () => {
48983
- const draft = this.draftStore[id];
48984
- if (draft !== undefined) {
48985
- delete this.draftStore[id];
48986
- delete this.cleanSnapshot[id];
48987
- const durableKey = buildDraftDurableStoreKey(draft.tag, draft.id);
48988
- return this.timeDurableOp('evictEntries', DRAFT_SEGMENT, () => this.durableStore.evictEntries([durableKey], DRAFT_SEGMENT));
49295
+ // Overrides DurableDraftQueue.getQueueActions rather than reusing it: the
49296
+ // base marks the single in-flight action from its private `uploadingActionId`
49297
+ // (serial path), while the concurrent path tracks many in-flight ids in
49298
+ // `uploadingActionIds`. The base field is private, so the uploading check
49299
+ // cannot be shared without a base refactor; the rest of the body (sort) is
49300
+ // deliberately kept identical. If the base sort changes, mirror it here.
49301
+ async getQueueActions() {
49302
+ const drafts = (await this.draftStore.getAllDrafts());
49303
+ const queue = [];
49304
+ drafts.forEach((draft) => {
49305
+ if (this.uploadingActionIds.has(draft.id)) {
49306
+ draft.status = DraftActionStatus.Uploading;
49307
+ }
49308
+ queue.push(draft);
49309
+ });
49310
+ return queue.sort((a, b) => {
49311
+ const aTime = parseInt(a.id, 10);
49312
+ const bTime = parseInt(b.id, 10);
49313
+ // safety check
49314
+ if (isNaN(aTime)) {
49315
+ return 1;
49316
+ }
49317
+ if (isNaN(bTime)) {
49318
+ return -1;
48989
49319
  }
49320
+ return aTime - bTime;
49321
+ });
49322
+ }
49323
+ async getOwnedQueueActions() {
49324
+ const all = await this.getQueueActions();
49325
+ return all.filter((a) => this.handlers[a.handler] !== undefined);
49326
+ }
49327
+ /** Draft ids `action` creates — the same set the dispatch-time dependency check uses. */
49328
+ getCreatedDraftIds(action) {
49329
+ const ids = new Set();
49330
+ this.collectDraftIds(action, ids);
49331
+ return Array.from(ids);
49332
+ }
49333
+ /** Owned actions whose basePath or body references any of `draftIds`. */
49334
+ async getOwnedActionsReferencing(draftIds) {
49335
+ const ids = new Set(draftIds);
49336
+ const owned = await this.getOwnedQueueActions();
49337
+ return owned.filter((action) => this.actionReferencesAny(action, ids));
49338
+ }
49339
+ async actionCompleted(action) {
49340
+ return this.workerPool.push({
49341
+ workFn: async () => {
49342
+ const handler = this.getHandler(action.handler);
49343
+ // Compute the redirects this completion produces BEFORE
49344
+ // completeAction resolves the POST out of the store, and retain
49345
+ // the draft ids they resolve. completeAction below removes the
49346
+ // completing POST from the store, so getInFlightRewritableDraftIds
49347
+ // would stop reporting its draft id — but the fire-and-forget
49348
+ // propagation to peers hasn't run yet, so a peer could dispatch a
49349
+ // queued action still carrying that stale draft id. Retaining the
49350
+ // ids (released only once the peer rewrites settle, below) keeps
49351
+ // peers deferring across that gap. Only bother when there are
49352
+ // peers to propagate to — with none, there is nothing to retain
49353
+ // for and no handler consultation to pay for.
49354
+ const peers = this.getPeerQueues().filter(isCrossQueueCoordination);
49355
+ const redirects = peers.length > 0 && handler.getRedirectMappings !== undefined
49356
+ ? handler.getRedirectMappings(action)
49357
+ : undefined;
49358
+ const retainedDraftIds = this.retainRedirectingDraftIds(redirects);
49359
+ try {
49360
+ let queue = await this.getQueueActions();
49361
+ const queueOperations = handler.getQueueOperationsForCompletingDrafts(queue, action);
49362
+ // write the queue operations to the store prior to ingesting the result
49363
+ await this.draftStore.completeAction(queueOperations);
49364
+ await this.finalizeCompletedAction(action, handler, queueOperations);
49365
+ if (this.state === DraftQueueState.Started) {
49366
+ this.processNextAction();
49367
+ }
49368
+ // Propagate redirects to peer queues; fire-and-forget. Awaiting
49369
+ // peer.applyDraftIdRedirects from inside this worker callback
49370
+ // produces a cross-queue worker-pool deadlock when the peer is
49371
+ // also propagating back to this queue. Release the retained ids
49372
+ // only once every peer's rewrite settles.
49373
+ this.propagateRedirectsToPeers(redirects, () => this.releaseRedirectingDraftIds(retainedDraftIds));
49374
+ }
49375
+ catch (e) {
49376
+ // Any step above can throw before propagateRedirectsToPeers
49377
+ // schedules the release. Release the retained ids here so they
49378
+ // don't leak into redirectingDraftIds permanently (which would
49379
+ // make getInFlightRewritableDraftIds report them forever and
49380
+ // strand peers deferring on them). Must be a catch, not a
49381
+ // finally — on the success path release happens only after
49382
+ // peers apply the rewrites, so releasing here too early would
49383
+ // reopen the cross-queue stale-id race.
49384
+ this.releaseRedirectingDraftIds(retainedDraftIds);
49385
+ throw e;
49386
+ }
49387
+ },
49388
+ });
49389
+ }
49390
+ /**
49391
+ * Shared in-memory finalization for a completed action, run after its durable
49392
+ * removal: ingest the response and apply side effects (handleActionCompleted),
49393
+ * drop any retry backoff state, release the in-flight claim, and notify
49394
+ * listeners. Used by both the single-action completion path (actionCompleted)
49395
+ * and the atomic batch completion path (completeBatchAtomically), which each
49396
+ * wrap it in their own error handling.
49397
+ */
49398
+ async finalizeCompletedAction(action, handler, queueOperations) {
49399
+ await handler.handleActionCompleted(action, queueOperations, values$2(this.handlers));
49400
+ // A completed action is no longer backing off: drop its timer, interval,
49401
+ // and parked flag.
49402
+ this.clearActionRetry(action.id);
49403
+ this.uploadingActionIds.delete(action.id);
49404
+ await this.notifyChangedListeners({
49405
+ type: DraftQueueEventType.ActionCompleted,
49406
+ action,
49407
+ draftCount: this.draftStore.getCount(),
49408
+ });
49409
+ }
49410
+ async actionFailed(action, retry, retryDelayInMs, actionDataChanged) {
49411
+ if (actionDataChanged === true) {
49412
+ await this.draftStore.writeAction({
49413
+ ...action,
49414
+ status: DraftActionStatus.Pending,
49415
+ });
49416
+ }
49417
+ this.uploadingActionIds.delete(action.id);
49418
+ if (retry && this.state !== DraftQueueState.Stopped) {
49419
+ // Park the action so a re-entrant dispatch pass (peer kick / sibling
49420
+ // completion) can't re-select it while it's Pending in the store and
49421
+ // waiting on its backoff timer. Cleared when this action's own timer
49422
+ // fires.
49423
+ this.retryingActionIds.add(action.id);
49424
+ await this.scheduleActionRetry(action.id, retryDelayInMs);
49425
+ return;
49426
+ }
49427
+ // Permanent failure. Awaiting handleServerError (rather than returning
49428
+ // it) guarantees the errored action is persisted before this method
49429
+ // resolves, so any dispatch pass on the next tick observes the error
49430
+ // state instead of racing the write.
49431
+ if (isDraftError(action)) {
49432
+ this.clearActionRetry(action.id);
49433
+ await this.handleServerError(action, action.error);
49434
+ // A permanently-failed POST never resolves the draft ids it would
49435
+ // have created, so peers that deferred actions on those ids would
49436
+ // otherwise stall until an unrelated success on a later tick wakes
49437
+ // them. Wake them explicitly so they can re-evaluate (and surface
49438
+ // their own error, rather than hang).
49439
+ this.getPeerQueues()
49440
+ .filter(isCrossQueueCoordination)
49441
+ .forEach((peer) => peer.resumeDeferredActions());
49442
+ }
49443
+ }
49444
+ async handleBatch(actions) {
49445
+ const handler = this.getHandler(actions[0].handler);
49446
+ if (handler === undefined) {
49447
+ return Promise.reject(`No handler for ${actions[0].handler}.`);
49448
+ }
49449
+ if (handler.handleBatchActions === undefined) {
49450
+ return Promise.reject(`Handler ${actions[0].handler} does not support batching.`);
49451
+ }
49452
+ const batchIds = actions.map((a) => a.id);
49453
+ // B2: buffer the members the handler reports completed instead of
49454
+ // removing each from the store as its callback fires. On success they
49455
+ // are removed in ONE atomic durable write (completeBatchAtomically), so
49456
+ // there is no partial-removal window that a crash — or a completion
49457
+ // callback throwing mid-loop — could strand. The stranded-member bug it
49458
+ // prevents: a batch commits all-or-none on the server exactly once, but
49459
+ // members removed one-at-a-time can leave committed members Pending,
49460
+ // which restart (or the handler's broad retry) would re-batch WITHOUT
49461
+ // their per-action idempotency keys (the batch envelope drops them),
49462
+ // replaying a committed PATCH's automation or failing a committed DELETE.
49463
+ const completed = [];
49464
+ const batchActionCompleted = (action) => {
49465
+ completed.push(action);
48990
49466
  return Promise.resolve();
48991
49467
  };
48992
- return this.enqueueAction(deleteAction);
48993
- }
48994
- deleteByTag(tag) {
48995
- const deleteAction = () => {
48996
- const { draftStore } = this;
48997
- const keys$1 = keys$4(draftStore);
48998
- const durableKeys = [];
48999
- for (let i = 0, len = keys$1.length; i < len; i++) {
49000
- const key = keys$1[i];
49001
- const action = draftStore[key];
49002
- if (action.tag === tag) {
49003
- delete draftStore[action.id];
49004
- delete this.cleanSnapshot[action.id];
49005
- durableKeys.push(buildDraftDurableStoreKey(action.tag, action.id));
49468
+ // Mint a unique id for this batch and journal its members BEFORE
49469
+ // dispatch (B2). If the process dies — or the atomic removal below fails
49470
+ // — between the server committing the request and the members being
49471
+ // removed, the next startQueue reconcile finds this journal and surfaces
49472
+ // any still-Pending member as commit-ambiguous (Option A: not auto-replayed,
49473
+ // because the batch envelope carries no server-visible idempotency key).
49474
+ // Journal support is optional; a store without it (never the gate-ON path)
49475
+ // skips this.
49476
+ const batchId = `${actions[0].id}:${(this.batchSequence += 1)}`;
49477
+ try {
49478
+ await this.writeBatchJournalIfSupported(batchId, batchIds);
49479
+ }
49480
+ catch (e) {
49481
+ // Fail CLOSED (4048061181): without a durable journal a mid-flight crash
49482
+ // would revive these members Pending with no record that they were
49483
+ // in-flight, so they would be re-batched — replaying a maybe-committed
49484
+ // write. Do NOT dispatch the batch. Release every member's in-flight
49485
+ // claim and dispatch the head alone via the single-action path, which
49486
+ // sends its idempotency key on the first attempt and so is replay-safe;
49487
+ // the remaining members re-arm and drain one-at-a-time single-action
49488
+ // while journaling is unavailable.
49489
+ batchLog$1(`journal write failed, single-action fallback batchId=${batchId} err=${errName(e)}`);
49490
+ for (const id of batchIds) {
49491
+ this.uploadingActionIds.delete(id);
49492
+ }
49493
+ this.uploadingActionIds.add(actions[0].id);
49494
+ return this.handle(actions[0]);
49495
+ }
49496
+ // Shared post-dispatch bookkeeping: release every batch member's uploading
49497
+ // claim, then handle the batch journal + still-Pending members per the
49498
+ // outcome, and issue the single trailing processNextAction. Two shapes:
49499
+ //
49500
+ // - Commit-AMBIGUOUS (result === NETWORK_ERROR, or a promise rejection):
49501
+ // the batch was dispatched but its outcome could not be confirmed
49502
+ // (network throw, empty-body 2xx, misaligned response, a retryable
49503
+ // 5xx/408/dropped-connection or generic-400 envelope that may have
49504
+ // committed with the 2xx lost — see executeBatch / classifyBatchFailure).
49505
+ // A rejection likewise implies a dispatch attempt whose cleanup then
49506
+ // threw, so it is treated as ambiguous too (it must NOT be read as
49507
+ // "nothing was sent"). The all-or-none request MAY have committed, and
49508
+ // the batch envelope carries no server-visible idempotency key, so a
49509
+ // re-send (batch OR serial) could replay a committed write. Do NOT
49510
+ // auto-replay (Option A): surface every still-Pending member as an error
49511
+ // for manual resolution, then clear the now-resolved journal (its job —
49512
+ // catching a mid-flight crash before we could record the ambiguity — is
49513
+ // done once the members carry the durable error).
49514
+ //
49515
+ // - Definitive non-success (any other non-success result): a terminal or
49516
+ // rolled-back envelope (server-generated 4xx, 429) or a partial
49517
+ // all-or-none failure — the server rolled the whole batch back or
49518
+ // rejected it before commit, so nothing committed and re-batching on
49519
+ // retry is replay-safe. The journal is stale: clear it. The handler
49520
+ // routed the offender (terminal, or parked in retryingActionIds) and
49521
+ // deliberately left innocent rolled-back siblings Pending/unparked (B5);
49522
+ // we only re-notify still-Pending members to clear the "uploading" UI
49523
+ // state.
49524
+ const finalize = async (result, rejected) => {
49525
+ for (const id of batchIds) {
49526
+ this.uploadingActionIds.delete(id);
49527
+ }
49528
+ if (rejected || result === ProcessActionResult.NETWORK_ERROR) {
49529
+ // Commit-ambiguous: surface members for manual resolution, then
49530
+ // clear the journal ONLY once the ambiguity is durably recorded on
49531
+ // every still-Pending member. If a member could not be moved to
49532
+ // Error, retain the journal so the next startQueue reconcile can
49533
+ // surface it — clearing now would strand it Pending with no backstop
49534
+ // and let a restart re-batch it (the replay Option A prevents).
49535
+ const resolved = await this.errorAmbiguousActions(batchIds);
49536
+ if (resolved) {
49537
+ await this.clearBatchJournalIfSupported(batchId);
49538
+ }
49539
+ else {
49540
+ // errorAmbiguousActions could not durably move the members to
49541
+ // Error (its bulk write failed); it stopped the queue and
49542
+ // quarantined them in-memory, and the journal is retained for
49543
+ // the next reconcile. Surface the failure as a bounded signal.
49544
+ await this.signalBatchRecoveryFailed('quarantine-failed');
49006
49545
  }
49007
49546
  }
49008
- return this.timeDurableOp('evictEntries', DRAFT_SEGMENT, () => this.durableStore.evictEntries(durableKeys, DRAFT_SEGMENT));
49547
+ else if (result !== ProcessActionResult.ACTION_SUCCEEDED) {
49548
+ // Definitively rolled back: journal is stale, clear it.
49549
+ await this.clearBatchJournalIfSupported(batchId);
49550
+ const queue = await this.getQueueActions();
49551
+ for (const id of batchIds) {
49552
+ const action = queue.find((a) => a.id === id);
49553
+ if (action && action.status === DraftActionStatus.Pending) {
49554
+ await this.notifyChangedListeners({
49555
+ type: DraftQueueEventType.ActionUpdated,
49556
+ action,
49557
+ draftCount: this.draftStore.getCount(),
49558
+ });
49559
+ }
49560
+ }
49561
+ }
49562
+ if (this.state === DraftQueueState.Started) {
49563
+ this.processNextAction();
49564
+ }
49565
+ return result;
49009
49566
  };
49010
- return this.enqueueAction(deleteAction);
49567
+ return handler
49568
+ .handleBatchActions(actions, batchActionCompleted, this.actionFailed.bind(this))
49569
+ .then(async (result) => {
49570
+ batchLog$1(`dispatch done result=${result} size=${actions.length}`);
49571
+ if (result === ProcessActionResult.ACTION_SUCCEEDED) {
49572
+ // Atomically remove every committed member and clear the
49573
+ // journal. Never rejects — it protects members itself if
49574
+ // the durable write fails (see below).
49575
+ await this.completeBatchAtomically(batchId, completed);
49576
+ }
49577
+ return finalize(result, false);
49578
+ }, (e) => {
49579
+ batchLog$1(`dispatch rejected err=${errName(e)} size=${actions.length}`);
49580
+ return finalize(ProcessActionResult.ACTION_ERRORED, true);
49581
+ });
49011
49582
  }
49012
- completeAction(queueOperations) {
49013
- const action = () => {
49014
- const durableStoreOperations = [];
49015
- const { draftStore } = this;
49016
- for (let i = 0, len = queueOperations.length; i < len; i++) {
49017
- const operation = queueOperations[i];
49018
- if (operation.type === QueueOperationType.Delete) {
49019
- const action = draftStore[operation.id];
49020
- if (action !== undefined) {
49021
- delete draftStore[operation.id];
49022
- delete this.cleanSnapshot[operation.id];
49023
- const key = buildDraftDurableStoreKey(action.tag, action.id);
49024
- durableStoreOperations.push({
49025
- ids: [key],
49026
- type: 'evictEntries',
49027
- segment: DRAFT_SEGMENT,
49583
+ /**
49584
+ * Atomically completes every successfully-uploaded member of a batch (B2).
49585
+ *
49586
+ * The server commits an all-or-none batch exactly once; the old path removed
49587
+ * members one-at-a-time, so a crash — or a completion callback throwing
49588
+ * mid-loop — could strand already-committed members Pending, which restart
49589
+ * (or the handler's broad retry) would re-batch WITHOUT their idempotency
49590
+ * keys and replay. This removes ALL members in ONE durable batchOperations
49591
+ * (the base completeAction applies the whole QueueOperation[] in a single
49592
+ * transaction), so there is no partial-removal state, then clears the
49593
+ * pre-dispatch journal. If any step throws after the server committed, this
49594
+ * fails CLOSED (4065665227): it quarantines every still-Pending member as a
49595
+ * commit-ambiguous error and retains the journal unless every member is
49596
+ * durably resolved, so the trailing finalize's claim release cannot let
49597
+ * collectBatch re-batch a keyless, maybe-committed member (Option A — the
49598
+ * batch envelope drops the idempotency key, so a re-send could replay a
49599
+ * committed write). Whatever the journal does not resolve this session is
49600
+ * surfaced by the next startQueue reconcile. The per-member cache ingest and
49601
+ * listener notification run afterward: they are best-effort cache updates, so
49602
+ * a throw there cannot corrupt the already-correct durable state.
49603
+ *
49604
+ * Batched members are UPDATE/DELETE only (collectBatch excludes POSTs), so
49605
+ * none mint server ids — getRedirectMappings returns undefined and each
49606
+ * member's queue ops reduce to a single self-delete, independent of the
49607
+ * others. That is why one queue snapshot yields correct ops for every member
49608
+ * and why the cross-queue redirect machinery on the single-action completion
49609
+ * path is not needed here. Never rejects.
49610
+ */
49611
+ completeBatchAtomically(batchId, completed) {
49612
+ return this.workerPool.push({
49613
+ workFn: async () => {
49614
+ if (completed.length === 0) {
49615
+ await this.clearBatchJournalIfSupported(batchId);
49616
+ return;
49617
+ }
49618
+ const memberIds = completed.map((a) => a.id);
49619
+ let perMember;
49620
+ try {
49621
+ // The setup (snapshot, handler lookup, op construction) is
49622
+ // inside the try too (4065666785): it runs in the success
49623
+ // callback, so a throw here would otherwise reject this task,
49624
+ // bypass finalize, and strand every member marked Uploading for
49625
+ // the session. Every exit below either removes the members or
49626
+ // quarantines them, so this method still never rejects.
49627
+ const queue = await this.getQueueActions();
49628
+ perMember = [];
49629
+ const allOps = [];
49630
+ for (const action of completed) {
49631
+ const handler = this.getHandler(action.handler);
49632
+ const ops = handler.getQueueOperationsForCompletingDrafts(queue, action);
49633
+ perMember.push({ action, ops, handler });
49634
+ for (let i = 0, len = ops.length; i < len; i++) {
49635
+ allOps.push(ops[i]);
49636
+ }
49637
+ }
49638
+ // ONE transaction removes every committed member — no
49639
+ // partial-removal window (B2).
49640
+ await this.draftStore.completeAction(allOps);
49641
+ await this.clearBatchJournalIfSupported(batchId);
49642
+ }
49643
+ catch (e) {
49644
+ // The server committed the all-or-none request but the local
49645
+ // completion could not be applied (snapshot read, op
49646
+ // construction, or the durable write failed). Do NOT assume the
49647
+ // members were evicted from memory: completeAction is part of the
49648
+ // public DraftStore contract and an implementation may reject
49649
+ // without removing its Pending rows (4065665227). If we simply
49650
+ // returned, the trailing finalize would release the uploading
49651
+ // claims and re-arm dispatch, letting collectBatch re-batch a
49652
+ // keyless, maybe-committed member and replay it. Fail CLOSED:
49653
+ // quarantine every still-Pending member as commit-ambiguous
49654
+ // BEFORE finalize releases the claims. treatMissingAsResolved is
49655
+ // false because the base store evicts a member from memory before
49656
+ // its durable row settles, so an absent member's durable state is
49657
+ // unknown here — leave the journal so the next startQueue reconcile
49658
+ // (which reads freshly-synced durable state) is the authority
49659
+ // (4065666287). Only clear the journal once every member is durably
49660
+ // resolved.
49661
+ batchLog$1(`atomic completion failed batchId=${batchId} err=${errName(e)}`);
49662
+ let resolved = false;
49663
+ try {
49664
+ resolved = await this.errorAmbiguousActions(memberIds, {
49665
+ treatMissingAsResolved: false,
49028
49666
  });
49029
49667
  }
49668
+ catch (qe) {
49669
+ batchLog$1(`atomic completion quarantine failed batchId=${batchId} err=${errName(qe)}`);
49670
+ }
49671
+ if (resolved) {
49672
+ await this.clearBatchJournalIfSupported(batchId);
49673
+ }
49674
+ else {
49675
+ // Not resolved: errorAmbiguousActions either failed its
49676
+ // durable write (it already stopped the queue + quarantined)
49677
+ // or threw before quarantining (e.g. its snapshot read
49678
+ // failed). Handle both uniformly — force the queue
49679
+ // non-dispatchable and quarantine every member in-memory so
49680
+ // the trailing finalize's unconditional claim release cannot
49681
+ // let collectBatch re-batch a keyless, maybe-committed member
49682
+ // and replay it (Option A). The journal is deliberately NOT
49683
+ // cleared; the next process-restart reconcile is the backstop.
49684
+ this.state = DraftQueueState.Error;
49685
+ for (const id of memberIds) {
49686
+ this.commitAmbiguousUnresolvedIds.add(id);
49687
+ }
49688
+ // The server committed but the members could not be durably
49689
+ // quarantined this session — surface a bounded o11y signal.
49690
+ await this.signalBatchRecoveryFailed('quarantine-failed');
49691
+ }
49692
+ return;
49030
49693
  }
49031
- else {
49032
- const { action } = operation;
49033
- const key = buildDraftDurableStoreKey(action.tag, action.id);
49034
- draftStore[action.id] = action;
49035
- this.cleanSnapshot[action.id] = clone$1(action);
49036
- durableStoreOperations.push({
49037
- type: 'setEntries',
49038
- segment: DRAFT_SEGMENT,
49039
- entries: {
49040
- [key]: {
49041
- data: operation.action,
49042
- },
49043
- },
49694
+ // Durable state is now correct. Per-member in-memory
49695
+ // finalization (cache ingest, retry-state clear, listener
49696
+ // notify) is best-effort; isolate each so one failure can't skip
49697
+ // the others or reject this task (which would surface as an
49698
+ // unhandled dispatch rejection).
49699
+ let finalizeFailed = false;
49700
+ for (const { action, ops, handler } of perMember) {
49701
+ try {
49702
+ await this.finalizeCompletedAction(action, handler, ops);
49703
+ }
49704
+ catch (e) {
49705
+ // Still release the in-flight claim so the id isn't stuck
49706
+ // marked uploading.
49707
+ this.uploadingActionIds.delete(action.id);
49708
+ finalizeFailed = true;
49709
+ batchLog$1(`post-completion finalize failed id=${action.id} err=${errName(e)}`);
49710
+ }
49711
+ }
49712
+ if (finalizeFailed) {
49713
+ // The write committed and was durably removed exactly once — no
49714
+ // replay/duplication risk — but some member's local cache/notify
49715
+ // work was lost with no retry path. Emit one bounded structured
49716
+ // signal per batch (o11y counter) so this is monitorable beyond
49717
+ // the per-member device log (4077120522).
49718
+ await this.signalBatchRecoveryFailed('finalize-failed');
49719
+ }
49720
+ },
49721
+ });
49722
+ }
49723
+ /**
49724
+ * Surfaces the given still-Pending actions as commit-ambiguous errors (Option
49725
+ * A). A batch that MAY have committed cannot be safely re-sent because the batch
49726
+ * envelope drops the per-action idempotency keys, so instead of auto-replaying
49727
+ * (batch OR serial — both would present keys the server never saw on the
49728
+ * original batch) we move each member to Error for manual resolution. Used by
49729
+ * finalize on a commit-ambiguous dispatch outcome and by startQueue reconcile
49730
+ * for members a prior batch journaled but never completed (in-flight at a
49731
+ * crash). Reads the raw store status (not the uploading overlay); a member
49732
+ * already Error is left as-is (counts as resolved).
49733
+ *
49734
+ * All still-Pending members are moved to Error in ONE completeAction
49735
+ * transaction rather than one writeAction (and one getQueueActions snapshot)
49736
+ * per member (4065667688): a 25-member batch is a single durable
49737
+ * batchOperations, not 25 writes plus 25 snapshots. The in-memory effects
49738
+ * handleServerError applies per member (drop the uploading claim + retry
49739
+ * state, set queue Error state, notify listeners) are mirrored here once for
49740
+ * the whole set.
49741
+ *
49742
+ * A fresh draft snapshot (id → action) is read per call — never shared across
49743
+ * the reconcile loop — because each call awaits completeAction and listener
49744
+ * callbacks that can delete a member, and a stale shared snapshot would let a
49745
+ * later call resurrect a just-deleted member as an Error draft (4077120311).
49746
+ * `options.treatMissingAsResolved` controls how an id absent from the snapshot
49747
+ * is scored: true (default) when the snapshot authoritatively reflects durable
49748
+ * state (restart reconcile runs against a freshly-synced store, so absent means
49749
+ * genuinely gone); false in the live post-commit failure path, where the base
49750
+ * store evicts a member from memory BEFORE its durable row settles (4065666287),
49751
+ * so an absent member's durable state is unknown and must be treated as
49752
+ * unresolved to keep the journal. An id already in commitAmbiguousUnresolvedIds
49753
+ * is likewise treated as unresolved regardless of its (possibly stale) in-memory
49754
+ * status: its durable Error write failed earlier this session, so its durable
49755
+ * row is still Pending and the journal must be retained for the next
49756
+ * fresh-process reconcile (4077120459). Returns true only when every targeted id
49757
+ * is resolved.
49758
+ */
49759
+ async errorAmbiguousActions(ids, options) {
49760
+ if (ids.length === 0) {
49761
+ return true;
49762
+ }
49763
+ const treatMissingAsResolved = options?.treatMissingAsResolved ?? true;
49764
+ // Read a fresh snapshot and index it by id so each targeted id is an O(1)
49765
+ // lookup instead of a full scan. Deliberately NOT shared across the
49766
+ // reconcile loop: a snapshot held across this call's awaited completeAction
49767
+ // and listener callbacks could go stale and let a later call resurrect a
49768
+ // just-deleted member as an Error draft (4077120311).
49769
+ const draftsById = new Map((await this.draftStore.getAllDrafts()).map((d) => [d.id, d]));
49770
+ const ops = [];
49771
+ const erroredActions = [];
49772
+ const seen = new Set();
49773
+ // An id whose durable Error write already failed this session (still in the
49774
+ // quarantine set) is NOT resolved even if its in-memory status now reads
49775
+ // Error: that in-memory Error is the write-through cache mutation that was
49776
+ // never rolled back after its durable batchOperations rejected, so the
49777
+ // durable row is still Pending. Retain the journal (return false) so the
49778
+ // next fresh-process reconcile — running against a re-synced store — is the
49779
+ // authority, rather than clearing the journal on unpersisted state and
49780
+ // re-batching the still-Pending row keyless after a later restart
49781
+ // (4077120459).
49782
+ let quarantinedUnresolved = false;
49783
+ for (const id of ids) {
49784
+ if (seen.has(id)) {
49785
+ continue;
49786
+ }
49787
+ if (this.commitAmbiguousUnresolvedIds.has(id)) {
49788
+ quarantinedUnresolved = true;
49789
+ continue;
49790
+ }
49791
+ const action = draftsById.get(id);
49792
+ if (action === undefined) {
49793
+ continue;
49794
+ }
49795
+ seen.add(id);
49796
+ if (action.status !== DraftActionStatus.Pending) {
49797
+ continue;
49798
+ }
49799
+ const errored = {
49800
+ ...action,
49801
+ status: DraftActionStatus.Error,
49802
+ error: PerformanceDraftQueue.COMMIT_AMBIGUOUS_ERROR,
49803
+ metadata: action.metadata || {},
49804
+ };
49805
+ ops.push({ type: QueueOperationType.Update, id: action.id, action: errored });
49806
+ erroredActions.push(errored);
49807
+ }
49808
+ if (ops.length > 0) {
49809
+ try {
49810
+ // One durable transaction moves every still-Pending member to Error.
49811
+ await this.draftStore.completeAction(ops);
49812
+ }
49813
+ catch (e) {
49814
+ // Nothing was durably moved: the members are still Pending and MAY
49815
+ // have committed. Fail CLOSED — stop the queue and quarantine them
49816
+ // in-memory so no later dispatch (a re-arm, retryAllErrors, or a
49817
+ // reconnect-driven startQueue) re-batches them keyless and replays a
49818
+ // committed write (Option A). Report unresolved so the caller retains
49819
+ // the journal for the next reconcile.
49820
+ batchLog$1(`commit-ambiguous bulk error failed count=${ops.length} err=${errName(e)}`);
49821
+ this.state = DraftQueueState.Error;
49822
+ for (const errored of erroredActions) {
49823
+ this.commitAmbiguousUnresolvedIds.add(errored.id);
49824
+ }
49825
+ return false;
49826
+ }
49827
+ // Durable Error state is written; mirror handleServerError's in-memory
49828
+ // effects once for the whole set rather than per member. Batched members
49829
+ // are UPDATE/DELETE (never POST — collectBatch excludes POSTs), so no
49830
+ // peer deferred on a draft id these would have created; the peer-wake
49831
+ // that actionFailed does for permanently-failed POSTs is not needed.
49832
+ this.state = DraftQueueState.Error;
49833
+ for (const errored of erroredActions) {
49834
+ this.uploadingActionIds.delete(errored.id);
49835
+ // Durably Error now (already excluded from dispatch by status), so
49836
+ // drop any prior in-memory quarantine — otherwise a later
49837
+ // retryAllErrors that re-pends this action would find it wrongly
49838
+ // blocked from ever dispatching again.
49839
+ this.commitAmbiguousUnresolvedIds.delete(errored.id);
49840
+ this.clearActionRetry(errored.id);
49841
+ try {
49842
+ await this.notifyChangedListeners({
49843
+ type: DraftQueueEventType.ActionFailed,
49844
+ action: errored,
49845
+ draftCount: this.draftStore.getCount(),
49044
49846
  });
49045
49847
  }
49848
+ catch (e) {
49849
+ batchLog$1(`commit-ambiguous notify failed id=${errored.id} err=${errName(e)}`);
49850
+ }
49046
49851
  }
49047
- return this.timeDurableOp('batchOperations', DRAFT_SEGMENT, () => this.durableStore.batchOperations(durableStoreOperations));
49048
- };
49049
- return this.enqueueAction(action);
49050
- }
49051
- getCount() {
49052
- return keys$4(this.draftStore).length;
49852
+ }
49853
+ if (quarantinedUnresolved) {
49854
+ return false;
49855
+ }
49856
+ if (treatMissingAsResolved) {
49857
+ return true;
49858
+ }
49859
+ for (let i = 0, len = ids.length; i < len; i++) {
49860
+ if (!seen.has(ids[i])) {
49861
+ return false;
49862
+ }
49863
+ }
49864
+ return true;
49053
49865
  }
49054
49866
  /**
49055
- * Runs a write operation against the draft store, if the initial
49056
- * revive is still in progress, the action gets enqueued to run once the
49057
- * initial revive is complete
49058
- * @param action
49059
- * @returns a promise that is resolved once the action has run
49867
+ * Feature-detecting wrapper: journals a batch's members if the store supports
49868
+ * it. A store without journal support (never the gate-ON path) resolves as a
49869
+ * no-op. When supported, the write REJECTION propagates so handleBatch can fail
49870
+ * CLOSED (4048061181) — the journal is the only record that these members were
49871
+ * in-flight, so a failed write must abort the batch dispatch rather than send
49872
+ * unrecorded.
49060
49873
  */
49061
- enqueueAction(action) {
49062
- const { syncPromise, writeQueue: pendingMerges } = this;
49063
- // if the initial sync is done and existing operations have been run, no need to queue, just run
49064
- if (syncPromise === undefined && pendingMerges.length === 0) {
49065
- return action();
49874
+ writeBatchJournalIfSupported(batchId, memberIds) {
49875
+ const store = this.draftStore;
49876
+ if (typeof store.writeBatchJournal !== 'function') {
49877
+ return Promise.resolve();
49066
49878
  }
49067
- const deferred = new Promise((resolve, reject) => {
49068
- this.writeQueue.push(() => {
49069
- return action()
49070
- .then((x) => {
49071
- resolve(x);
49072
- })
49073
- .catch((err) => reject(err));
49879
+ return store.writeBatchJournal(batchId, memberIds);
49880
+ }
49881
+ /**
49882
+ * Surfaces a batch-journal crash-recovery failure (B2) to listeners as a
49883
+ * bounded structured signal, so instrumentDraftQueue can map it to o11y —
49884
+ * these paths are otherwise visible only in device logs. Carries ONLY the
49885
+ * reason code and the queue depth (never ids or messages — device-log PII
49886
+ * policy). Best-effort and never rejects: a listener throw must not break the
49887
+ * recovery path, so it is swallowed to the batch log.
49888
+ */
49889
+ async signalBatchRecoveryFailed(reason) {
49890
+ try {
49891
+ await this.notifyChangedListeners({
49892
+ type: DraftQueueEventType.BatchRecoveryFailed,
49893
+ reason,
49894
+ draftCount: this.draftStore.getCount(),
49074
49895
  });
49896
+ }
49897
+ catch (e) {
49898
+ batchLog$1(`batch recovery signal failed reason=${reason} err=${errName(e)}`);
49899
+ }
49900
+ }
49901
+ /**
49902
+ * Feature-detecting wrapper: clears a batch journal entry if the store
49903
+ * supports it. A resolved batch's journal is stale, but a swallowed clear
49904
+ * failure would leave a recovery row behind on EVERY dispatch, so restart
49905
+ * reconcile grows unbounded (4065667688). Retry the clear a bounded number of
49906
+ * times, and if it still fails escalate the log line (leftover=true) so a
49907
+ * persistently-failing clear is greppable rather than silent. Never rejects:
49908
+ * callers (finalize, completeBatchAtomically) treat the clear as best-effort
49909
+ * cleanup of an already-resolved journal — a leftover row is caught by the
49910
+ * next reconcile, it does not risk a replay.
49911
+ */
49912
+ async clearBatchJournalIfSupported(batchId) {
49913
+ const store = this.draftStore;
49914
+ if (typeof store.clearBatchJournal !== 'function') {
49915
+ return;
49916
+ }
49917
+ for (let attempt = 1; attempt <= CLEAR_JOURNAL_MAX_ATTEMPTS; attempt++) {
49918
+ try {
49919
+ await store.clearBatchJournal(batchId);
49920
+ return;
49921
+ }
49922
+ catch (e) {
49923
+ const leftover = attempt === CLEAR_JOURNAL_MAX_ATTEMPTS;
49924
+ batchLog$1(`journal clear failed batchId=${batchId} attempt=${attempt} leftover=${leftover} err=${errName(e)}`);
49925
+ }
49926
+ }
49927
+ // Every attempt failed (the loop returns on success): the resolved journal
49928
+ // row leaks, so the next reconcile keeps re-reading it (unbounded growth).
49929
+ // Surface a bounded o11y signal — the device log alone is not enough.
49930
+ // Still never rejects: signalBatchRecoveryFailed swallows its own errors.
49931
+ await this.signalBatchRecoveryFailed('clear-exhausted');
49932
+ }
49933
+ /**
49934
+ * A queued action is eligible to be selected or batched only when it is
49935
+ * Pending in the store and this queue is neither already uploading it nor
49936
+ * holding it in retry backoff. A retrying action stays Pending in the
49937
+ * store, so without the retryingActionIds guard collectBatch would pull a
49938
+ * parked action into a batch before its backoff timer fires (C5).
49939
+ */
49940
+ isDispatchable(action) {
49941
+ return (action.status === DraftActionStatus.Pending &&
49942
+ !this.uploadingActionIds.has(action.id) &&
49943
+ !this.retryingActionIds.has(action.id) &&
49944
+ // A member left Pending because its commit-ambiguous quarantine could
49945
+ // not be durably written must never be re-batched keyless (Option A).
49946
+ !this.commitAmbiguousUnresolvedIds.has(action.id));
49947
+ }
49948
+ /**
49949
+ * Walks the queue from `headIndex` and returns the longest contiguous prefix
49950
+ * of actions that are eligible to batch together. Eligibility:
49951
+ * - same handler id as the head action
49952
+ * - dispatchable (Pending, not in-flight, not parked in retry backoff)
49953
+ * - not a POST/CREATE (POSTs are never batched — see BatchCutReason.PostBoundary)
49954
+ * - tag does not collide with any tag already in the batch
49955
+ * (UI-API Batch rejects two operations on the same record per request)
49956
+ * - body/basePath does not reference an unresolved draft id from a peer
49957
+ * queue's (or this queue's) pending POST (would dispatch with a draft id)
49958
+ * - targetId is not already being written by an in-flight request
49959
+ * (per-record serialization; the head is pre-checked by the caller,
49960
+ * but later members must be checked here too — see BatchCutReason.TargetInFlight)
49961
+ * - batch size capped at MAX_BATCH_SIZE
49962
+ */
49963
+ collectBatch(queue, headIndex, peerPendingPostDraftIds, inFlightTargetIds) {
49964
+ if (queue.length === 0 || headIndex >= queue.length) {
49965
+ return [];
49966
+ }
49967
+ const head = queue[headIndex];
49968
+ const handler = this.handlers[head.handler];
49969
+ // CREATEs are never batched (C1): a POST head falls back to single-action
49970
+ // dispatch, which preserves its per-action idempotency key.
49971
+ const headData = head.data;
49972
+ if (headData !== undefined && headData.method === 'post') {
49973
+ batchLog$1('head is POST — not batchable, fall back to single-action');
49974
+ return [head];
49975
+ }
49976
+ // The head carries semantics the batch envelope cannot represent (B1):
49977
+ // fall back to single-action dispatch so those semantics (e.g. a
49978
+ // conditional-update header) are preserved.
49979
+ if (handler !== undefined &&
49980
+ typeof handler.isActionBatchable === 'function' &&
49981
+ !handler.isActionBatchable(head)) {
49982
+ batchLog$1('head not batchable — fall back to single-action');
49983
+ return [head];
49984
+ }
49985
+ const batch = [head];
49986
+ const seenTags = new Set();
49987
+ seenTags.add(head.tag);
49988
+ let reason = "END_OF_QUEUE" /* BatchCutReason.EndOfQueue */;
49989
+ let detail = '';
49990
+ for (let i = headIndex + 1; i < queue.length; i++) {
49991
+ if (batch.length >= MAX_BATCH_SIZE$1) {
49992
+ reason = "MAX_BATCH_SIZE" /* BatchCutReason.MaxBatchSize */;
49993
+ break;
49994
+ }
49995
+ const candidate = queue[i];
49996
+ if (candidate.handler !== head.handler) {
49997
+ reason = "HANDLER_CHANGE" /* BatchCutReason.HandlerChange */;
49998
+ detail = `nextHandler=${candidate.handler}`;
49999
+ break;
50000
+ }
50001
+ if (!this.isDispatchable(candidate)) {
50002
+ reason = "NOT_PENDING" /* BatchCutReason.NotPending */;
50003
+ detail = `nextStatus=${candidate.status}`;
50004
+ break;
50005
+ }
50006
+ // Stop at any POST: CREATEs are excluded from batching (C1). Only
50007
+ // naturally-idempotent UPDATE/DELETE actions batch, so there are no
50008
+ // in-batch POST draft ids to track — the blocking set below is the
50009
+ // caller's peer/owned pending-POST set alone.
50010
+ const candData = candidate.data;
50011
+ if (candData !== undefined && candData.method === 'post') {
50012
+ reason = "POST_BOUNDARY" /* BatchCutReason.PostBoundary */;
50013
+ break;
50014
+ }
50015
+ // Candidate carries semantics the batch envelope cannot represent
50016
+ // (B1): end the batch here so it is dispatched via single-action
50017
+ // handleAction on a later pass rather than folded in and stripped.
50018
+ if (handler !== undefined &&
50019
+ typeof handler.isActionBatchable === 'function' &&
50020
+ !handler.isActionBatchable(candidate)) {
50021
+ reason = "NOT_BATCHABLE" /* BatchCutReason.NotBatchable */;
50022
+ break;
50023
+ }
50024
+ // Per-record serialization (see BatchCutReason.TargetInFlight): the
50025
+ // caller pre-checks the head; later members must be checked here too.
50026
+ if (candidate.targetId && inFlightTargetIds.has(candidate.targetId)) {
50027
+ reason = "TARGET_IN_FLIGHT" /* BatchCutReason.TargetInFlight */;
50028
+ // Log the cut position, NOT the targetId: a targetId is a record id
50029
+ // and even a 24-char suffix preserves the full 18-char id, which the
50030
+ // device-log PII policy (metrics.ts) forbids (4065667273). The
50031
+ // BatchCutReason already says why; the offset says where.
50032
+ detail = `atOffset=${i - headIndex}`;
50033
+ break;
50034
+ }
50035
+ if (seenTags.has(candidate.tag)) {
50036
+ reason = "TAG_COLLISION" /* BatchCutReason.TagCollision */;
50037
+ // Log the cut position, NOT the tag: the tag embeds the record id
50038
+ // (same PII concern as targetId above).
50039
+ detail = `atOffset=${i - headIndex}`;
50040
+ break;
50041
+ }
50042
+ // Block a candidate that references a draft id still unresolved on a
50043
+ // peer queue's (or this queue's) in-flight POST, minus the
50044
+ // candidate's own draft ids (an action mustn't self-block on its own
50045
+ // targetId).
50046
+ const candidateOwnIds = new Set();
50047
+ this.collectDraftIds(candidate, candidateOwnIds);
50048
+ const blockingIds = new Set(peerPendingPostDraftIds);
50049
+ for (const id of candidateOwnIds) {
50050
+ blockingIds.delete(id);
50051
+ }
50052
+ if (this.actionReferencesAny(candidate, blockingIds)) {
50053
+ reason = "REF_BLOCKED" /* BatchCutReason.RefBlocked */;
50054
+ break;
50055
+ }
50056
+ batch.push(candidate);
50057
+ seenTags.add(candidate.tag);
50058
+ }
50059
+ const queuedAfterBatch = queue.length - headIndex - batch.length;
50060
+ batchLog$1(`collected size=${batch.length} reason=${reason}` +
50061
+ ` queueRemaining=${queuedAfterBatch}` +
50062
+ (detail.length > 0 ? ` ${detail}` : ''));
50063
+ return batch;
50064
+ }
50065
+ /**
50066
+ * Returns all draft ids "created" by this action (i.e., ids that don't yet
50067
+ * exist on the server). Defaults to `[targetId]`; handlers may override
50068
+ * `getDraftIdsFromAction` to return additional ids (e.g., the CD composite
50069
+ * handler returns CD + CV + CDL).
50070
+ */
50071
+ collectDraftIds(action, into) {
50072
+ const handler = this.handlers[action.handler];
50073
+ if (handler !== undefined && typeof handler.getDraftIdsFromAction === 'function') {
50074
+ for (const id of handler.getDraftIdsFromAction(action)) {
50075
+ if (id !== undefined && id !== null && id !== '') {
50076
+ into.add(id);
50077
+ }
50078
+ }
50079
+ return;
50080
+ }
50081
+ if (action.targetId) {
50082
+ into.add(action.targetId);
50083
+ }
50084
+ }
50085
+ /**
50086
+ * Returns true if any of the given draft ids appears in the action's
50087
+ * basePath or stringified body. Mirrors the substring scan used by
50088
+ * `getQueueOperationsForCompletingDrafts` so blocking and rewriting agree.
50089
+ *
50090
+ * TODO [W-23515682]: this re-scans basePath/body on every dispatch pass.
50091
+ * The referenced-id set could instead be computed once and cached on the
50092
+ * DraftAction, recomputed only when rewriteQueueActionsForRedirects mutates
50093
+ * basePath/body (the only place they change), so the hot path is a set
50094
+ * lookup rather than a stringify+substring scan.
50095
+ */
50096
+ actionReferencesAny(action, draftIds) {
50097
+ if (draftIds.size === 0) {
50098
+ return false;
50099
+ }
50100
+ const data = action.data;
50101
+ if (data === undefined) {
50102
+ return false;
50103
+ }
50104
+ const body = typeof data.body === 'string' ? data.body : JSON.stringify(data.body || '');
50105
+ const haystack = `${data.basePath || ''}${body}`;
50106
+ return [...draftIds].some((id) => haystack.includes(id));
50107
+ }
50108
+ async getInFlightRewritableDraftIds() {
50109
+ const allDrafts = await this.draftStore.getAllDrafts();
50110
+ // Seed with draft ids retained across the completeAction →
50111
+ // propagateRedirectsToPeers gap so a peer keeps deferring even though the
50112
+ // completing POST has already left the store but its redirects have not
50113
+ // yet reached peers.
50114
+ const ids = new Set(this.redirectingDraftIds);
50115
+ for (const action of allDrafts) {
50116
+ if (this.handlers[action.handler] !== undefined &&
50117
+ (action.status === DraftActionStatus.Pending ||
50118
+ action.status === DraftActionStatus.Uploading) &&
50119
+ action.data &&
50120
+ action.data.method === 'post') {
50121
+ this.collectDraftIds(action, ids);
50122
+ }
50123
+ }
50124
+ return Array.from(ids);
50125
+ }
50126
+ resumeDeferredActions() {
50127
+ if (this.state === DraftQueueState.Started) {
50128
+ this.processNextAction();
50129
+ }
50130
+ }
50131
+ // Applies a set of draft → canonical id rewrites to every action queued
50132
+ // on this queue. Called by a peer queue after one of its POSTs completes
50133
+ // and produced redirects. Walks this queue, rewrites any action whose
50134
+ // basePath/body/tag references a draft id, persists via the shared
50135
+ // store, and kicks processNextAction so newly-resolvable actions dispatch.
50136
+ async applyDraftIdRedirects(redirects) {
50137
+ if (!redirects || redirects.length === 0) {
50138
+ return;
50139
+ }
50140
+ return this.workerPool.push({
50141
+ workFn: async () => {
50142
+ const queue = await this.getQueueActions();
50143
+ const queueOperations = rewriteQueueActionsForRedirects(queue, redirects);
50144
+ if (queueOperations.length > 0) {
50145
+ await this.draftStore.completeAction(queueOperations);
50146
+ }
50147
+ if (this.state === DraftQueueState.Started) {
50148
+ this.processNextAction();
50149
+ }
50150
+ },
49075
50151
  });
49076
- return deferred;
49077
50152
  }
49078
50153
  /**
49079
- * Revives the draft store from the durable store. Once the draft store is
49080
- * revived, executes any queued up draft store operations that came in while
49081
- * reviving
50154
+ * Retains the draft ids resolved by `redirects` in `redirectingDraftIds` so
50155
+ * getInFlightRewritableDraftIds keeps reporting them across the
50156
+ * completeAction → propagateRedirectsToPeers fire-and-forget gap. Returns
50157
+ * only the ids this call newly added (so a concurrent completion touching
50158
+ * the same id doesn't release it early). Called before completeAction on the
50159
+ * single-action completion path (actionCompleted).
49082
50160
  */
49083
- resyncDraftStore() {
49084
- const sync = () => {
49085
- this.syncPromise = this.durableStore
49086
- .getAllEntries(DRAFT_SEGMENT)
49087
- .then((durableEntries) => {
49088
- if (durableEntries === undefined) {
49089
- this.draftStore = {};
49090
- this.cleanSnapshot = {};
49091
- return this.runQueuedOperations();
50161
+ retainRedirectingDraftIds(redirects) {
50162
+ const retainedDraftIds = [];
50163
+ if (redirects !== undefined) {
50164
+ for (const redirect of redirects) {
50165
+ if (redirect.draftId && !this.redirectingDraftIds.has(redirect.draftId)) {
50166
+ this.redirectingDraftIds.add(redirect.draftId);
50167
+ retainedDraftIds.push(redirect.draftId);
49092
50168
  }
49093
- const { draftStore } = this;
49094
- const keys$1 = keys$4(durableEntries);
49095
- for (let i = 0, len = keys$1.length; i < len; i++) {
49096
- const entry = durableEntries[keys$1[i]];
49097
- const action = entry.data;
49098
- draftStore[action.id] = action;
49099
- this.cleanSnapshot[action.id] = clone$1(action);
50169
+ }
50170
+ }
50171
+ return retainedDraftIds;
50172
+ }
50173
+ /** Releases the ids retained by a prior retainRedirectingDraftIds call. */
50174
+ releaseRedirectingDraftIds(retainedDraftIds) {
50175
+ for (const id of retainedDraftIds) {
50176
+ this.redirectingDraftIds.delete(id);
50177
+ }
50178
+ }
50179
+ /**
50180
+ * After an action on this queue completes, propagates the draft → canonical
50181
+ * id rewrites it produced to the peer queues so their queued actions that
50182
+ * reference the resolved id are rewritten before dispatch. When there are no
50183
+ * redirects (or none apply), falls back to a bare wake of each peer so their
50184
+ * previously-deferred actions re-evaluate.
50185
+ *
50186
+ * `redirects` is precomputed by the caller (before completeAction resolves
50187
+ * the POST out of the store) so the redirecting draft ids can be retained
50188
+ * across the fire-and-forget gap. `onSettled` runs once every peer's rewrite
50189
+ * has settled, so the caller can release those retained ids.
50190
+ */
50191
+ propagateRedirectsToPeers(redirects, onSettled) {
50192
+ // Peers are typed DraftQueue[]; only those that also implement
50193
+ // CrossQueueCoordination can participate. Narrow once up front so the
50194
+ // calls below need no per-method guards.
50195
+ const peers = this.getPeerQueues().filter(isCrossQueueCoordination);
50196
+ if (peers.length === 0) {
50197
+ onSettled();
50198
+ return;
50199
+ }
50200
+ if (redirects !== undefined && redirects.length > 0) {
50201
+ const settlements = peers.map((peer) => {
50202
+ // Fire-and-forget: peer.applyDraftIdRedirects schedules its own
50203
+ // work on the peer's worker pool. Don't await — that deadlocks
50204
+ // when the peer is propagating redirects back to this queue.
50205
+ const promise = peer.applyDraftIdRedirects(redirects);
50206
+ if (promise && typeof promise.catch === 'function') {
50207
+ return promise.catch((e) => {
50208
+ if (this.logger) {
50209
+ this.logger.logError(`PerformanceDraftQueue: cross-queue redirect propagation to peer failed: ${errName(e)}`);
50210
+ }
50211
+ });
49100
50212
  }
49101
- return this.runQueuedOperations();
49102
- })
49103
- .finally(() => {
49104
- this.syncPromise = undefined;
50213
+ return Promise.resolve();
49105
50214
  });
49106
- return this.syncPromise;
49107
- };
49108
- // if there's an ongoing sync populating the in memory store, wait for it to complete before re-syncing
49109
- const { syncPromise } = this;
49110
- if (syncPromise === undefined) {
49111
- return sync();
50215
+ // Release the retained draft ids only once every peer has applied
50216
+ // (or failed to apply) the rewrites, so getInFlightRewritableDraftIds
50217
+ // keeps reporting them until peers no longer reference the stale id.
50218
+ Promise.all(settlements).then(onSettled, onSettled);
50219
+ }
50220
+ else {
50221
+ peers.forEach((peer) => peer.resumeDeferredActions());
50222
+ onSettled();
50223
+ }
50224
+ }
50225
+ async processNextAction() {
50226
+ // Serialize dispatch passes. A pass awaits peer state before
50227
+ // claiming a slot, so overlapping passes could double-dispatch. If a
50228
+ // pass is already running, record the wakeup and let it re-run.
50229
+ if (this.isDispatching) {
50230
+ this.redispatchRequested = true;
50231
+ return ProcessActionResult.ACTION_ALREADY_PROCESSING;
50232
+ }
50233
+ this.isDispatching = true;
50234
+ try {
50235
+ let result = ProcessActionResult.NO_ACTION_TO_PROCESS;
50236
+ do {
50237
+ this.redispatchRequested = false;
50238
+ result = await this.dispatchFreeSlots();
50239
+ } while (this.redispatchRequested);
50240
+ return result;
50241
+ }
50242
+ finally {
50243
+ this.isDispatching = false;
50244
+ }
50245
+ }
50246
+ async dispatchFreeSlots() {
50247
+ if (this.uploadingActionIds.size >= this.concurrency) {
50248
+ return ProcessActionResult.ACTION_ALREADY_PROCESSING;
50249
+ }
50250
+ // Gather the set of pending POST draft ids whose creators are still in
50251
+ // flight on any peer queue. A candidate action that references one of
50252
+ // those ids would otherwise dispatch with an unresolved draft id
50253
+ // (ContentDocument 069... etc.) instead of waiting for the upstream
50254
+ // POST to rewrite it to canonical. These peer reads each await, so they
50255
+ // run BEFORE the owned-queue snapshot below — otherwise an await would
50256
+ // sit between snapshotting the queue and claiming from it, letting the
50257
+ // store change underneath the selection (the candidate we picked could
50258
+ // already be gone or completed by the time we claim it).
50259
+ const blockingDraftIds = new Set();
50260
+ const peers = this.getPeerQueues().filter(isCrossQueueCoordination);
50261
+ for (const peer of peers) {
50262
+ try {
50263
+ const ids = await peer.getInFlightRewritableDraftIds();
50264
+ for (const id of ids) {
50265
+ blockingDraftIds.add(id);
50266
+ }
50267
+ }
50268
+ catch (e) {
50269
+ // Peers are already narrowed to CrossQueueCoordination, so this
50270
+ // is a genuine failure to read a peer's in-flight ids, not a
50271
+ // missing capability. Log it: on failure we fall through with an
50272
+ // incomplete blocking set, which can let a dependent action
50273
+ // dispatch against an unresolved draft id.
50274
+ if (this.logger) {
50275
+ this.logger.logError(`PerformanceDraftQueue: failed to read peer in-flight rewritable draft ids: ${errName(e)}`);
50276
+ }
50277
+ }
50278
+ }
50279
+ // Snapshot the owned queue AFTER the peer reads. From here to the claims
50280
+ // below there is no await, so the snapshot and the slot claims see a
50281
+ // consistent view of the store.
50282
+ const queue = await this.getQueueActions();
50283
+ // The shared DurableDraftStore returns actions for every queue, so we
50284
+ // restrict candidate selection to actions whose handler is registered
50285
+ // on this queue. This keeps a queue from claiming or being
50286
+ // error-blocked by its peer's actions.
50287
+ const ownedQueue = queue.filter((a) => this.handlers[a.handler] !== undefined);
50288
+ // Head-of-queue error block: only applies when strictOrdering is enabled.
50289
+ // With strictOrdering=false the candidate loop will skip errored items.
50290
+ if (this.strictOrdering) {
50291
+ const head = ownedQueue[0];
50292
+ if (head !== undefined && head.status === DraftActionStatus.Error) {
50293
+ this.state = DraftQueueState.Error;
50294
+ await this.notifyChangedListeners({
50295
+ type: DraftQueueEventType.ActionFailed,
50296
+ action: head,
50297
+ draftCount: this.draftStore.getCount(),
50298
+ });
50299
+ return ProcessActionResult.BLOCKED_ON_ERROR;
50300
+ }
50301
+ }
50302
+ // Single pass over ownedQueue derives everything the selection loop
50303
+ // needs: candidates (actions neither already claimed in-flight nor
50304
+ // parked in retry backoff), the targetIds of in-flight actions
50305
+ // (per-record serialization), and every owned pending/uploading POST's
50306
+ // draft ids (this queue's contribution to the blocking set).
50307
+ const candidates = [];
50308
+ const inFlightTargetIds = new Set();
50309
+ for (const a of ownedQueue) {
50310
+ if (this.uploadingActionIds.has(a.id)) {
50311
+ if (a.targetId) {
50312
+ inFlightTargetIds.add(a.targetId);
50313
+ }
50314
+ }
50315
+ else if (!this.retryingActionIds.has(a.id) &&
50316
+ // Same guard as isDispatchable: a commit-ambiguous member whose
50317
+ // quarantine could not be durably written stays out of
50318
+ // single-action dispatch too, not just batching.
50319
+ !this.commitAmbiguousUnresolvedIds.has(a.id)) {
50320
+ candidates.push(a);
50321
+ }
50322
+ if ((a.status === DraftActionStatus.Pending ||
50323
+ a.status === DraftActionStatus.Uploading) &&
50324
+ a.data &&
50325
+ a.data.method === 'post') {
50326
+ this.collectDraftIds(a, blockingDraftIds);
50327
+ }
50328
+ }
50329
+ // Fill every free concurrency slot from this single snapshot rather than
50330
+ // claiming one and bouncing back through processNextAction — that would
50331
+ // re-run getQueueActions (and its deep clone) per slot. Claims are
50332
+ // synchronous (no await inside the loop), so inFlightTargetIds stays
50333
+ // authoritative for per-record serialization of later picks in the same
50334
+ // pass. Blocked candidates are skipped (strictOrdering=false) or halt
50335
+ // the scan (strictOrdering=true head-of-line semantics).
50336
+ const toDispatch = [];
50337
+ let blockedCount = 0;
50338
+ for (const candidate of candidates) {
50339
+ if (this.uploadingActionIds.size >= this.concurrency) {
50340
+ break;
50341
+ }
50342
+ if (candidate.status === DraftActionStatus.Error) {
50343
+ continue;
50344
+ }
50345
+ // Per-record serialization: skip if another action on the same record is in-flight.
50346
+ if (candidate.targetId && inFlightTargetIds.has(candidate.targetId)) {
50347
+ blockedCount++;
50348
+ if (this.strictOrdering) {
50349
+ break;
50350
+ }
50351
+ continue;
50352
+ }
50353
+ // Exclude the candidate's own draft IDs so a POST doesn't self-block
50354
+ // on its own targetId.
50355
+ const ownIds = new Set();
50356
+ this.collectDraftIds(candidate, ownIds);
50357
+ const effectiveBlockingIds = new Set(blockingDraftIds);
50358
+ for (const id of ownIds) {
50359
+ effectiveBlockingIds.delete(id);
50360
+ }
50361
+ if (this.actionReferencesAny(candidate, effectiveBlockingIds)) {
50362
+ blockedCount++;
50363
+ if (this.strictOrdering) {
50364
+ break;
50365
+ }
50366
+ continue;
50367
+ }
50368
+ // Batching path: only when enabled, on the first action dispatched
50369
+ // this pass, and only if its handler implements handleBatchActions
50370
+ // and the eligible batch is larger than 1. collectBatch enforces the
50371
+ // same ref-blocking rule applied above so a candidate referencing a
50372
+ // peer's pending POST id won't be batched alongside the head. When a
50373
+ // batch dispatches it consumes the pass (returns), matching the
50374
+ // pre-fill-all behavior. It is also passed inFlightTargetIds so a
50375
+ // batch member can't write a record that another in-flight request
50376
+ // is already writing (the head candidate is pre-checked above).
50377
+ if (this.queueBatching && toDispatch.length === 0) {
50378
+ const handler = this.getHandler(candidate.handler);
50379
+ if (handler.handleBatchActions !== undefined) {
50380
+ const actionIndex = ownedQueue.indexOf(candidate);
50381
+ const batch = this.collectBatch(ownedQueue, actionIndex, blockingDraftIds, inFlightTargetIds);
50382
+ if (batch.length > 1) {
50383
+ batchLog$1(`dispatching batch size=${batch.length}`);
50384
+ if (this.state === DraftQueueState.Waiting) {
50385
+ this.state = DraftQueueState.Started;
50386
+ }
50387
+ // Claim every batch member so a re-entrant
50388
+ // processNextAction (e.g. a peer kick during the await
50389
+ // below) doesn't re-claim any of them.
50390
+ for (const batched of batch) {
50391
+ this.uploadingActionIds.add(batched.id);
50392
+ }
50393
+ for (const batched of batch) {
50394
+ await this.notifyChangedListeners({
50395
+ type: DraftQueueEventType.ActionUploading,
50396
+ action: { ...batched, status: DraftActionStatus.Uploading },
50397
+ draftCount: this.draftStore.getCount(),
50398
+ });
50399
+ }
50400
+ return this.handleBatch(batch);
50401
+ }
50402
+ }
50403
+ }
50404
+ // Eligible — claim the slot now (synchronously) so the next
50405
+ // iteration serializes against it.
50406
+ this.uploadingActionIds.add(candidate.id);
50407
+ if (candidate.targetId) {
50408
+ inFlightTargetIds.add(candidate.targetId);
50409
+ }
50410
+ toDispatch.push(candidate);
50411
+ }
50412
+ if (toDispatch.length === 0) {
50413
+ if (blockedCount > 0 && this.logger) {
50414
+ this.logger.logInfo(`[DraftQueue c=${this.concurrency}] processNextAction: ${blockedCount} action(s) deferred waiting on upstream draft ids`);
50415
+ }
50416
+ if (this.uploadingActionIds.size === 0) {
50417
+ if (this.state === DraftQueueState.Waiting) {
50418
+ this.state = DraftQueueState.Started;
50419
+ }
50420
+ return ProcessActionResult.NO_ACTION_TO_PROCESS;
50421
+ }
50422
+ return ProcessActionResult.ACTION_ALREADY_PROCESSING;
50423
+ }
50424
+ if (this.state === DraftQueueState.Waiting) {
50425
+ this.state = DraftQueueState.Started;
50426
+ }
50427
+ for (const action of toDispatch) {
50428
+ await this.notifyChangedListeners({
50429
+ type: DraftQueueEventType.ActionUploading,
50430
+ action: { ...action, status: DraftActionStatus.Uploading },
50431
+ draftCount: this.draftStore.getCount(),
50432
+ });
50433
+ this.handle(action);
50434
+ }
50435
+ return ProcessActionResult.ACTION_ALREADY_PROCESSING;
50436
+ }
50437
+ // Overrides DurableDraftQueue.removeDraftAction rather than reusing it: the
50438
+ // uploading guard reads the concurrent `uploadingActionIds` set (the base
50439
+ // reads its private single `uploadingActionId`, which this path never sets,
50440
+ // so delegating would defeat the guard), and it additionally clears the
50441
+ // per-action retry timer — a concept that exists only on this subclass. The
50442
+ // rest of the body is kept identical to the base; keep them in sync.
50443
+ async removeDraftAction(actionId) {
50444
+ const queue = await this.getQueueActions();
50445
+ //Get the store key for the removed action
50446
+ const actions = queue.filter((action) => action.id === actionId);
50447
+ if (actions.length === 0) {
50448
+ // eslint-disable-next-line @salesforce/lds/no-error-in-production
50449
+ throw new Error(`No removable action with id ${actionId}`);
50450
+ }
50451
+ const action = actions[0];
50452
+ const { shouldDeleteRelated, removed } = this.resolveActionsToRemove(action, queue, (id) => this.uploadingActionIds.has(id));
50453
+ // A removed action must not leave a dangling backoff timer that would
50454
+ // later fire and re-drive the scheduler for an id that no longer exists.
50455
+ removed.forEach(({ action: a }) => this.clearActionRetry(a.id));
50456
+ if (shouldDeleteRelated) {
50457
+ await this.draftStore.deleteByTag(action.tag);
50458
+ }
50459
+ else {
50460
+ await this.draftStore.deleteDraft(action.id);
50461
+ }
50462
+ await this.notifyActionsRemoved(removed, queue);
50463
+ if (this.userState === DraftQueueState.Started &&
50464
+ this.state !== DraftQueueState.Started &&
50465
+ this.replacingAction === undefined) {
50466
+ await this.startQueue();
49112
50467
  }
49113
- return syncPromise.then(() => {
49114
- return sync();
49115
- });
49116
50468
  }
49117
50469
  /**
49118
- * Runs the operations that were queued up while reviving the
49119
- * draft store from the durable store
50470
+ * Concurrent-aware, per-action retry scheduler. The serial base parks the
50471
+ * whole queue under one shared timer + one shared exponential interval,
50472
+ * which conflate concurrent retries. This instead gives the failed action
50473
+ * its own timer and its own growing backoff, keyed by id, so it retries on
50474
+ * its own schedule without disturbing siblings still uploading in other
50475
+ * slots.
50476
+ *
50477
+ * Only broadcasts `Waiting` when the queue is genuinely idle (nothing in
50478
+ * flight): a concurrent queue with other slots live is still `Started`, and
50479
+ * on the serial path the failed action was already removed from
50480
+ * `uploadingActionIds` above (size 0), so the Waiting broadcast the regular
50481
+ * queue emits today is preserved.
49120
50482
  */
49121
- runQueuedOperations() {
49122
- const { writeQueue } = this;
49123
- if (writeQueue.length > 0) {
49124
- const queueItem = writeQueue.shift();
49125
- if (queueItem !== undefined) {
49126
- return queueItem().then(() => {
49127
- return this.runQueuedOperations();
49128
- });
50483
+ async scheduleActionRetry(actionId, retryDelayInMs) {
50484
+ const delay = retryDelayInMs !== undefined ? retryDelayInMs : this.nextBackoffInterval(actionId);
50485
+ // Only move to Waiting when the queue is genuinely idle (nothing in
50486
+ // flight). A concurrent queue with other slots still uploading must stay
50487
+ // Started — otherwise sibling completions (which gate their
50488
+ // processNextAction on state === Started) would stop advancing the queue
50489
+ // and free slots would sit idle until this action's retry timer fired.
50490
+ // Only dispatchFreeSlots flips Waiting back to Started, and nothing
50491
+ // reaches it while state is Waiting. On the serial path the failed action
50492
+ // was already removed above (size 0), so the Waiting transition the
50493
+ // regular queue emits today is preserved.
50494
+ if (this.uploadingActionIds.size === 0) {
50495
+ this.state = DraftQueueState.Waiting;
50496
+ await this.notifyChangedListeners({
50497
+ type: DraftQueueEventType.QueueStateChanged,
50498
+ state: DraftQueueState.Waiting,
50499
+ draftCount: this.draftStore.getCount(),
50500
+ });
50501
+ }
50502
+ // Replace any existing timer for this action so a fresh failure resets
50503
+ // its own backoff window without leaking the prior timer.
50504
+ const existing = this.retryTimers.get(actionId);
50505
+ if (existing !== undefined) {
50506
+ clearTimeout(existing);
50507
+ }
50508
+ this.retryTimers.set(actionId, setTimeout(() => {
50509
+ // Only this action becomes eligible again; siblings keep their
50510
+ // own backoff windows.
50511
+ this.retryTimers.delete(actionId);
50512
+ this.retryingActionIds.delete(actionId);
50513
+ if (this.state !== DraftQueueState.Stopped) {
50514
+ this.processNextAction();
49129
50515
  }
50516
+ }, delay));
50517
+ }
50518
+ /**
50519
+ * Per-action exponential backoff, mirroring the base's doubling within the
50520
+ * same [minimum, maximum] bounds but tracked per id so each retrying action
50521
+ * grows its own interval independently.
50522
+ */
50523
+ nextBackoffInterval(actionId) {
50524
+ const current = this.retryIntervals.get(actionId) ?? 0;
50525
+ const next = Math.min(Math.max(current * 2, this.minimumRetryInterval), this.maximumRetryInterval);
50526
+ this.retryIntervals.set(actionId, next);
50527
+ return next;
50528
+ }
50529
+ /**
50530
+ * Drops all retry bookkeeping for an action: cancels its pending timer,
50531
+ * un-parks it, and forgets its backoff interval. Called on completion,
50532
+ * permanent failure, and removal.
50533
+ */
50534
+ clearActionRetry(actionId) {
50535
+ const timer = this.retryTimers.get(actionId);
50536
+ if (timer !== undefined) {
50537
+ clearTimeout(timer);
50538
+ this.retryTimers.delete(actionId);
50539
+ }
50540
+ this.retryIntervals.delete(actionId);
50541
+ this.retryingActionIds.delete(actionId);
50542
+ }
50543
+ /**
50544
+ * Cancels every per-action retry timer. The base clears only its single
50545
+ * `timeoutHandler`; a concurrent queue may have many parked actions, so on
50546
+ * any full stop we must clear them all to avoid a dangling timer firing
50547
+ * against a stopped queue.
50548
+ */
50549
+ clearAllRetryTimers() {
50550
+ for (const timer of this.retryTimers.values()) {
50551
+ clearTimeout(timer);
50552
+ }
50553
+ this.retryTimers.clear();
50554
+ this.retryIntervals.clear();
50555
+ this.retryingActionIds.clear();
50556
+ }
50557
+ stopQueueManually(internalReason) {
50558
+ this.clearAllRetryTimers();
50559
+ super.stopQueueManually(internalReason);
50560
+ }
50561
+ /**
50562
+ * Clears every per-action retry timer on (re)start for the same reason the
50563
+ * base clears its single `timeoutHandler`: a dangling backoff timer must not
50564
+ * fire against a freshly-started queue. Guarded by the same conditions under
50565
+ * which the base actually restarts (not already Started, not mid-replace) so
50566
+ * a `startQueue()` that is a no-op — or a replace-driven restart — doesn't
50567
+ * wipe legitimately-parked backoff windows out from under in-flight retries.
50568
+ */
50569
+ async startQueue() {
50570
+ // Record the caller's intent up front, exactly as the base startQueue does
50571
+ // synchronously. The reconcile below awaits, so a stopQueue() can interleave
50572
+ // with it; capturing intent here lets that later stop flip userState and win
50573
+ // (see the revalidation after the await, R4-6).
50574
+ this.userState = DraftQueueState.Started;
50575
+ if (this.state !== DraftQueueState.Started && this.replacingAction === undefined) {
50576
+ this.clearAllRetryTimers();
50577
+ // Concurrent counterpart: the base scalar is never set here, so check the Set.
50578
+ // Stale ids in uploadingActionIds at restart strand the dispatcher's uploading guard.
50579
+ if (this.uploadingActionIds.size > 0 && this.logger) {
50580
+ this.logger.logWarning(`Draft queue restarting with ${this.uploadingActionIds.size} action(s) still marked uploading (stale uploadingActionIds=${[
50581
+ ...this.uploadingActionIds,
50582
+ ].join(',')}); possible abandoned-upload stuck queue`);
50583
+ }
50584
+ }
50585
+ // Recover from any batch journaled but never confirmed removed (B2)
50586
+ // before the base starts dispatching, so a committed-but-unremoved
50587
+ // member is surfaced as a commit-ambiguous error before collectBatch can
50588
+ // re-batch it (Option A). Guarded to run at most once per process; a no-op
50589
+ // when batching is disabled or the store keeps no journal.
50590
+ await this.reconcileBatchJournalsOnce();
50591
+ // The reconcile awaited above yields the event loop. If a stopQueue() ran
50592
+ // while it was pending, that stop already completed and set
50593
+ // userState=Stopped; honor it rather than clobbering the queue back to
50594
+ // Started via super.startQueue() and dispatching after the caller stopped
50595
+ // it (R4-6).
50596
+ if (this.userState !== DraftQueueState.Started) {
50597
+ return;
50598
+ }
50599
+ return super.startQueue();
50600
+ }
50601
+ /**
50602
+ * The head of the shared store may belong to the other queue, so targets are resolved
50603
+ * against the actions this queue owns. With strict ordering only the owned head can be
50604
+ * retried (it is what blocks dispatch); without it an errored action blocks nothing, so
50605
+ * any errored action this queue owns can be retried.
50606
+ */
50607
+ async getRetryTarget(actionId) {
50608
+ const owned = await this.getOwnedQueueActions();
50609
+ if (this.strictOrdering) {
50610
+ const head = owned[0];
50611
+ if (!head || head.id !== actionId) {
50612
+ throw Error(`Action ${actionId} not found at the head of the draft queue`);
50613
+ }
50614
+ return head;
50615
+ }
50616
+ const target = owned.find((action) => action.id === actionId);
50617
+ if (target === undefined) {
50618
+ throw Error(`Action ${actionId} not found in the draft queue`);
50619
+ }
50620
+ return target;
50621
+ }
50622
+ async retryAllErrors() {
50623
+ const actions = await this.getOwnedQueueActions();
50624
+ const errored = actions.filter((a) => a.status === DraftActionStatus.Error);
50625
+ if (errored.length === 0) {
50626
+ return;
50627
+ }
50628
+ // Stop the queue before rewriting errored actions to Pending. With
50629
+ // strictOrdering=false the queue keeps running while errored items are
50630
+ // skipped, so a live pass could observe the store mid-rewrite. Stop,
50631
+ // rewrite, then restart safely.
50632
+ this.stopQueueManually(true);
50633
+ for (const action of errored) {
50634
+ // Drop the `error` field that's only present on ErrorDraftAction so
50635
+ // the resulting object structurally matches PendingDraftAction and
50636
+ // satisfies the discriminated-union narrower.
50637
+ const { error: _droppedError, ...rest } = action;
50638
+ const pendingAction = {
50639
+ ...rest,
50640
+ status: DraftActionStatus.Pending,
50641
+ };
50642
+ await this.draftStore.writeAction(pendingAction);
50643
+ await this.notifyChangedListeners({
50644
+ type: DraftQueueEventType.ActionUpdated,
50645
+ action: pendingAction,
50646
+ draftCount: this.draftStore.getCount(),
50647
+ });
50648
+ }
50649
+ await this.startQueueSafe();
50650
+ }
50651
+ }
50652
+ /**
50653
+ * Error surfaced on a batch member whose commit status could not be confirmed:
50654
+ * the batch response was lost (network throw, empty/misaligned 2xx, a retryable
50655
+ * gateway envelope) or the process died mid-flight. Because the batch envelope
50656
+ * carries no server-visible idempotency key, such a write cannot be safely
50657
+ * re-sent without risking a duplicate, so the member is surfaced as an error for
50658
+ * manual resolution rather than auto-replayed (Option A). Shaped like a
50659
+ * FetchResponse so downstream consumers that read action.error.status/.body
50660
+ * behave consistently with a server error.
50661
+ */
50662
+ PerformanceDraftQueue.COMMIT_AMBIGUOUS_ERROR = {
50663
+ status: 0,
50664
+ statusText: 'Batch commit ambiguous',
50665
+ ok: false,
50666
+ headers: {},
50667
+ body: {
50668
+ errorCode: 'BATCH_COMMIT_AMBIGUOUS',
50669
+ message: 'The batched change may or may not have been saved (the server response ' +
50670
+ 'was not received). It was not retried automatically to avoid saving it ' +
50671
+ 'twice; please verify the record and retry or discard the draft.',
50672
+ },
50673
+ };
50674
+
50675
+ class RoutingDraftQueue {
50676
+ /**
50677
+ * @param primaryLane the serial/fallback lane: it owns unregistered handlers and backs
50678
+ * every store read. Passed explicitly so routing never depends on array ordering.
50679
+ * @param secondaryLanes the remaining routed lanes (e.g. the concurrent binary lane).
50680
+ */
50681
+ constructor(primaryLane, secondaryLanes = []) {
50682
+ this.primaryLane = primaryLane;
50683
+ this.lanes = [primaryLane, ...secondaryLanes];
50684
+ }
50685
+ get primary() {
50686
+ return this.primaryLane.queue;
50687
+ }
50688
+ /** Resolve the lane that owns `handlerId`, falling back to the primary lane. */
50689
+ laneForHandler(handlerId) {
50690
+ return (this.lanes.find(({ queue }) => queue.isHandlerRegistered(handlerId)) ?? this.primaryLane);
50691
+ }
50692
+ /** Resolve the sub-queue that owns `handlerId`, falling back to the primary queue. */
50693
+ routeByHandler(handlerId) {
50694
+ return this.laneForHandler(handlerId).queue;
50695
+ }
50696
+ /**
50697
+ * Resolve the sub-queue that owns the action identified by `actionId` by
50698
+ * reading its stamped handler from the shared store (visible via the primary
50699
+ * queue), then routing by handler ownership. Falls back to the primary queue
50700
+ * when the action can't be found.
50701
+ */
50702
+ async routeByActionId(actionId) {
50703
+ const actions = await this.primary.getQueueActions();
50704
+ const action = actions.find((candidate) => candidate.id === actionId);
50705
+ return action !== undefined ? this.routeByHandler(action.handler) : this.primary;
50706
+ }
50707
+ enqueue(handlerId, data, observabilityContext) {
50708
+ return this.routeByHandler(handlerId).enqueue(handlerId, data, observabilityContext);
50709
+ }
50710
+ async getQueueActions() {
50711
+ const actions = await this.primary.getQueueActions();
50712
+ return actions.map((action) => ({
50713
+ ...action,
50714
+ lane: this.laneForHandler(action.handler).lane,
50715
+ }));
50716
+ }
50717
+ addHandler(handler) {
50718
+ return this.primary.addHandler(handler);
50719
+ }
50720
+ addCustomHandler(id, executor) {
50721
+ return this.primary.addCustomHandler(id, executor);
50722
+ }
50723
+ removeHandler(id) {
50724
+ return this.primary.removeHandler(id);
50725
+ }
50726
+ // Removes only the action itself. Removing a binary action through RoutingDraftManager also
50727
+ // removes the serial actions that depend on it; this façade does not.
50728
+ async removeDraftAction(actionId) {
50729
+ const owner = await this.routeByActionId(actionId);
50730
+ return owner.removeDraftAction(actionId);
50731
+ }
50732
+ updateDraftAction(action) {
50733
+ return this.routeByHandler(action.handler).updateDraftAction(action);
50734
+ }
50735
+ async retryAction(actionId) {
50736
+ const owner = await this.routeByActionId(actionId);
50737
+ return owner.retryAction(actionId);
50738
+ }
50739
+ async setMetadata(actionId, metadata) {
50740
+ const owner = await this.routeByActionId(actionId);
50741
+ return owner.setMetadata(actionId, metadata);
50742
+ }
50743
+ async replaceAction(targetActionId, sourceActionId) {
50744
+ const owner = await this.routeByActionId(targetActionId);
50745
+ return owner.replaceAction(targetActionId, sourceActionId);
50746
+ }
50747
+ async mergeActions(targetActionId, sourceActionId) {
50748
+ const owner = await this.routeByActionId(targetActionId);
50749
+ return owner.mergeActions(targetActionId, sourceActionId);
50750
+ }
50751
+ registerOnChangedListener(listener) {
50752
+ const unsubscribes = this.lanes.map(({ queue }) => queue.registerOnChangedListener(listener));
50753
+ return () => Promise.all(unsubscribes.map((unsubscribe) => unsubscribe())).then(() => undefined);
50754
+ }
50755
+ async processNextAction() {
50756
+ const [primaryResult] = await Promise.all(this.lanes.map(({ queue }) => queue.processNextAction()));
50757
+ return primaryResult;
50758
+ }
50759
+ /**
50760
+ * Conservative aggregate of the sub-queue states, most-attention-needed first:
50761
+ * Error (blocked, needs intervention) > Stopped (explicitly halted) > Waiting
50762
+ * (transient network backoff) > Started. Not bridged — DraftManager reports
50763
+ * per-queue state separately; this exists only to satisfy the interface.
50764
+ */
50765
+ getQueueState() {
50766
+ const states = this.lanes.map(({ queue }) => queue.getQueueState());
50767
+ if (states.includes(DraftQueueState.Error)) {
50768
+ return DraftQueueState.Error;
50769
+ }
50770
+ if (states.includes(DraftQueueState.Stopped)) {
50771
+ return DraftQueueState.Stopped;
50772
+ }
50773
+ if (states.includes(DraftQueueState.Waiting)) {
50774
+ return DraftQueueState.Waiting;
50775
+ }
50776
+ return DraftQueueState.Started;
50777
+ }
50778
+ async startQueue() {
50779
+ await Promise.all(this.lanes.map(({ queue }) => queue.startQueue()));
50780
+ }
50781
+ async stopQueue() {
50782
+ await Promise.all(this.lanes.map(({ queue }) => queue.stopQueue()));
50783
+ }
50784
+ /**
50785
+ * Stops every sub-queue, runs `action` once, then restarts every sub-queue.
50786
+ * Not bridged; the per-queue variant preserves userState via an internal stop,
50787
+ * which isn't reachable from here, so this uses the public stop/start pair.
50788
+ */
50789
+ async stopQueueWhileRunning(action) {
50790
+ await Promise.all(this.lanes.map(({ queue }) => queue.stopQueue()));
50791
+ try {
50792
+ await action();
50793
+ }
50794
+ finally {
50795
+ await Promise.all(this.lanes.map(({ queue }) => queue.startQueue()));
49130
50796
  }
49131
- return Promise.resolve();
49132
50797
  }
49133
50798
  }
49134
50799
 
@@ -49196,6 +50861,16 @@ class DraftManager {
49196
50861
  shouldEmitEvent(event) {
49197
50862
  return this.draftEventsShouldBeEmitted.includes(event.type);
49198
50863
  }
50864
+ /**
50865
+ * Routes a sub-queue change event into the listener pipeline. Exposed to subclasses so they
50866
+ * can register it on an additional queue without duplicating the emit-gating logic.
50867
+ */
50868
+ handleQueueChange(event) {
50869
+ if (this.shouldEmitEvent(event)) {
50870
+ return this.callListeners(event);
50871
+ }
50872
+ return Promise.resolve();
50873
+ }
49199
50874
  constructor(draftQueue) {
49200
50875
  this.listeners = [];
49201
50876
  this.draftEventsShouldBeEmitted = [
@@ -49208,12 +50883,7 @@ class DraftManager {
49208
50883
  DraftQueueEventType.QueueStateChanged,
49209
50884
  ];
49210
50885
  this.draftQueue = draftQueue;
49211
- draftQueue.registerOnChangedListener((event) => {
49212
- if (this.shouldEmitEvent(event)) {
49213
- return this.callListeners(event);
49214
- }
49215
- return Promise.resolve();
49216
- });
50886
+ draftQueue.registerOnChangedListener((event) => this.handleQueueChange(event));
49217
50887
  }
49218
50888
  draftQueueEventTypeToOperationType(type) {
49219
50889
  switch (type) {
@@ -49413,6 +51083,11 @@ class DraftManager {
49413
51083
  if (isDraftQueueStateChangeEvent(event)) {
49414
51084
  operationType = DraftQueueOperationType.QueueStateChanged;
49415
51085
  }
51086
+ else if (event.type === DraftQueueEventType.BatchRecoveryFailed) {
51087
+ // Telemetry-only signal (batch-recovery failure): it carries no
51088
+ // draft item, so DraftManager has nothing to surface to its listeners.
51089
+ return;
51090
+ }
49416
51091
  else {
49417
51092
  const { action, type } = event;
49418
51093
  item = this.buildDraftQueueItem(action);
@@ -49491,6 +51166,537 @@ function isResourceRequestAction$1(action) {
49491
51166
  return (dataAsAny !== undefined && dataAsAny.method !== undefined && dataAsAny.body !== undefined);
49492
51167
  }
49493
51168
 
51169
+ /**
51170
+ * Gate-ON DraftManager: the concurrent-binary upload variant, instantiated only when the
51171
+ * `concurrentBinaryQueue` feature flag is on. It wraps a strict-ordering serial draft queue
51172
+ * plus a parallel binary queue over one shared store, stamps each draft's routing `lane`, and
51173
+ * routes lifecycle, remove, and retry calls to the queue that owns each action. Current
51174
+ * (non-SCE) users never instantiate this class — they run the unchanged base.
51175
+ */
51176
+ class RoutingDraftManager extends DraftManager {
51177
+ constructor(draftQueue, binaryQueue, laneLabels) {
51178
+ super(draftQueue);
51179
+ this.logger = typeof __nimbus !== 'undefined' &&
51180
+ __nimbus.plugins !== undefined &&
51181
+ __nimbus.plugins.JSLoggerPlugin !== undefined
51182
+ ? __nimbus.plugins.JSLoggerPlugin
51183
+ : undefined;
51184
+ this.serialQueue = draftQueue;
51185
+ this.binaryQueue = binaryQueue;
51186
+ this.laneLabels = laneLabels;
51187
+ // Surface binary-queue change events through the same listener pipeline as the serial
51188
+ // queue (super already registered the serial queue; reuse the inherited handler here).
51189
+ this.binaryQueue.registerOnChangedListener((event) => this.handleQueueChange(event));
51190
+ }
51191
+ getQueue() {
51192
+ return Promise.all([this.draftQueue.getQueueActions(), this.binaryActionsById()])
51193
+ .then(([queueActions, binaryById]) => queueActions.map((action) => {
51194
+ const binaryAction = binaryById.get(action.id);
51195
+ return this.buildDraftQueueItem(this.stampLane(binaryAction ?? action, binaryAction !== undefined));
51196
+ }))
51197
+ .then(toQueueState(this.draftQueue));
51198
+ }
51199
+ /**
51200
+ * Starts both the serial and binary draft queues and begins processing their items.
51201
+ */
51202
+ startQueue() {
51203
+ return Promise.all([this.draftQueue.startQueue(), this.binaryQueue.startQueue()]).then(() => undefined);
51204
+ }
51205
+ /**
51206
+ * Stops both the serial and binary draft queues from processing more draft items after
51207
+ * any in-progress items are finished.
51208
+ */
51209
+ stopQueue() {
51210
+ return Promise.all([this.draftQueue.stopQueue(), this.binaryQueue.stopQueue()]).then(() => undefined);
51211
+ }
51212
+ /**
51213
+ * Pauses both queues while `action` runs. Nests each queue's own stopQueueWhileRunning so
51214
+ * each restarts afterwards only if it was user-started before.
51215
+ */
51216
+ stopQueueWhileRunning(action) {
51217
+ return this.draftQueue.stopQueueWhileRunning(() => this.binaryQueue.stopQueueWhileRunning(action));
51218
+ }
51219
+ async retryAllErrors() {
51220
+ await this.serialQueue.retryAllErrors();
51221
+ await this.binaryQueue.retryAllErrors();
51222
+ }
51223
+ /**
51224
+ * Routes the remove to the queue that owns the action. The serial queue reads the shared
51225
+ * store, so it sees binary-owned actions too, but has no handler registered for them and
51226
+ * would throw.
51227
+ *
51228
+ * A binary remove pauses the serial queue so a dependent can't start uploading between the
51229
+ * uploading check and its removal. Only the serial queue: stopping the binary queue would
51230
+ * clear every binary action's retry backoff. The serial queue's own removeDraftAction
51231
+ * restarts it, so the pause covers the first dependent only.
51232
+ */
51233
+ removeDraftAction(actionId) {
51234
+ return this.binaryActionsById()
51235
+ .then((binaryById) => {
51236
+ const binaryAction = binaryById.get(actionId);
51237
+ return binaryAction !== undefined
51238
+ ? this.serialQueue.stopQueueWhileRunning(() => this.removeBinaryActionAndDependents(binaryAction))
51239
+ : this.draftQueue.removeDraftAction(actionId);
51240
+ })
51241
+ .then(() => this.getQueue());
51242
+ }
51243
+ /**
51244
+ * Routes to the queue that owns the action, for the same reason as removeDraftAction.
51245
+ * Native sets metadata on every image upload right after enqueueing it, so a serial-only
51246
+ * setMetadata fails the upload call and native never links the image to its record.
51247
+ */
51248
+ async setMetadata(actionId, metadata) {
51249
+ const binaryById = await this.binaryActionsById();
51250
+ if (!binaryById.has(actionId)) {
51251
+ return super.setMetadata(actionId, metadata);
51252
+ }
51253
+ const updated = await this.binaryQueue.setMetadata(actionId, metadata);
51254
+ return this.buildDraftQueueItem(this.stampLane(updated, true));
51255
+ }
51256
+ /** Routes to the queue that owns the action, for the same reason as removeDraftAction. */
51257
+ async retryAction(actionId) {
51258
+ const binaryById = await this.binaryActionsById();
51259
+ if (!binaryById.has(actionId)) {
51260
+ return super.retryAction(actionId);
51261
+ }
51262
+ const retried = await this.binaryQueue.retryAction(actionId);
51263
+ return this.buildDraftQueueItem(this.stampLane(retried, true));
51264
+ }
51265
+ /**
51266
+ * Removes a binary action plus the serial actions that reference a draft id it creates
51267
+ * (e.g. the ContentDocumentLink native enqueues to link an uploaded image to a record).
51268
+ * Nothing resolves those ids once the binary action is gone, so a dependent left behind
51269
+ * would dispatch with an unresolved draft id, fail, and block the serial queue.
51270
+ *
51271
+ * Rejects up front when a dependent is uploading: an errored binary action no longer holds
51272
+ * its dependents back, so one can be mid-dispatch. Otherwise the binary action goes first
51273
+ * so a rejected remove (e.g. it is uploading) leaves its dependents untouched.
51274
+ */
51275
+ async removeBinaryActionAndDependents(action) {
51276
+ const dependents = await this.serialActionsDependingOn(action);
51277
+ const uploading = dependents.find((d) => d.status === DraftActionStatus.Uploading);
51278
+ if (uploading !== undefined) {
51279
+ // eslint-disable-next-line @salesforce/lds/no-error-in-production
51280
+ throw new Error(`Cannot remove draft action ${action.id} while its dependent draft action ${uploading.id} is uploading`);
51281
+ }
51282
+ await this.binaryQueue.removeDraftAction(action.id);
51283
+ for (const dependent of dependents) {
51284
+ const current = (await this.draftQueue.getQueueActions()).find((a) => a.id === dependent.id);
51285
+ // Removing an earlier dependent may already have deleted this one by shared tag.
51286
+ if (current === undefined) {
51287
+ continue;
51288
+ }
51289
+ // It started uploading after the check above. The binary action is already gone,
51290
+ // so keep going rather than report a failed remove; this one will error and can be
51291
+ // removed on its own.
51292
+ if (current.status === DraftActionStatus.Uploading) {
51293
+ if (this.logger) {
51294
+ this.logger.logWarning(`RoutingDraftManager: dependent draft action ${dependent.id} of removed draft action ${action.id} started uploading and was not removed`);
51295
+ }
51296
+ continue;
51297
+ }
51298
+ await this.draftQueue.removeDraftAction(dependent.id);
51299
+ }
51300
+ }
51301
+ serialActionsDependingOn(action) {
51302
+ const draftIds = this.binaryQueue.getCreatedDraftIds(action);
51303
+ return draftIds.length === 0
51304
+ ? Promise.resolve([])
51305
+ : this.serialQueue.getOwnedActionsReferencing(draftIds);
51306
+ }
51307
+ /**
51308
+ * Binary-lane actions keyed by id, carrying the lane's transient `Uploading` status.
51309
+ * `getQueue` uses it to stamp the `concurrentBinary` lane and source the correct status.
51310
+ */
51311
+ binaryActionsById() {
51312
+ return this.binaryQueue
51313
+ .getOwnedQueueActions()
51314
+ .then((actions) => new Map(actions.map((action) => [action.id, action])));
51315
+ }
51316
+ /** Stamp `action`'s routing `lane` from lane membership. */
51317
+ stampLane(action, isBinaryOwned) {
51318
+ const lane = isBinaryOwned ? this.laneLabels.concurrentBinary : this.laneLabels.serial;
51319
+ return { ...action, lane };
51320
+ }
51321
+ /**
51322
+ * Extends the base projection with the routing `lane` stamped onto the action. Only the
51323
+ * gate-ON path carries a lane; the base DraftManager never emits the field, so the gate-OFF
51324
+ * projection stays identical to before the concurrent-binary queue existed.
51325
+ */
51326
+ buildDraftQueueItem(action) {
51327
+ const item = super.buildDraftQueueItem(action);
51328
+ if (action.lane !== undefined) {
51329
+ item.lane = action.lane;
51330
+ }
51331
+ return item;
51332
+ }
51333
+ }
51334
+
51335
+ function clone$1(obj) {
51336
+ return parse$6(stringify$6(obj));
51337
+ }
51338
+
51339
+ const DRAFT_ACTION_KEY_JUNCTION = '__DraftAction__';
51340
+ function buildDraftDurableStoreKey(recordKey, draftActionId) {
51341
+ return `${recordKey}${DRAFT_ACTION_KEY_JUNCTION}${draftActionId}`;
51342
+ }
51343
+ /**
51344
+ * Implements a write-through InMemoryStore for Drafts, storing all drafts in a
51345
+ * in-memory store with a write through to the DurableStore.
51346
+ *
51347
+ * Before any reads or writes come in from the draft queue, we need to revive the draft
51348
+ * queue into memory. During this initial revive, any writes are queued up and operated on the
51349
+ * queue once it's in memory. Similarly any reads are delayed until the queue is in memory.
51350
+ *
51351
+ */
51352
+ class DurableDraftStore {
51353
+ constructor(durableStore, instrumentation, useSnapshot = true) {
51354
+ this.draftStore = {};
51355
+ // Snapshot: holds deep clones of each draft, refreshed at write time.
51356
+ // getAllDrafts serves from this snapshot with zero deep clones per read.
51357
+ this.cleanSnapshot = {};
51358
+ // queue of writes that were made during the initial sync
51359
+ this.writeQueue = [];
51360
+ this.durableStore = durableStore;
51361
+ this.instrumentation = instrumentation;
51362
+ this.useSnapshot = useSnapshot;
51363
+ this.resyncDraftStore();
51364
+ }
51365
+ // When no instrumentation is injected the promise is returned as-is (zero overhead).
51366
+ timeDurableOp(op, segment, run) {
51367
+ const onDurableOperation = this.instrumentation?.onDurableOperation;
51368
+ if (onDurableOperation === undefined) {
51369
+ return run();
51370
+ }
51371
+ const start = Date.now();
51372
+ return run().finally(() => {
51373
+ onDurableOperation(op, segment, Date.now() - start);
51374
+ });
51375
+ }
51376
+ writeAction(action) {
51377
+ const addAction = () => {
51378
+ const { id, tag } = action;
51379
+ this.draftStore[id] = action;
51380
+ this.cleanSnapshot[id] = clone$1(action);
51381
+ const durableEntryKey = buildDraftDurableStoreKey(tag, id);
51382
+ const entry = {
51383
+ data: action,
51384
+ };
51385
+ const entries = { [durableEntryKey]: entry };
51386
+ return this.timeDurableOp('setEntries', DRAFT_SEGMENT, () => this.durableStore.setEntries(entries, DRAFT_SEGMENT));
51387
+ };
51388
+ return this.enqueueAction(addAction);
51389
+ }
51390
+ getAllDrafts() {
51391
+ const waitForOngoingSync = this.syncPromise || Promise.resolve();
51392
+ return waitForOngoingSync.then(() => {
51393
+ const actionArray = [];
51394
+ if (this.useSnapshot) {
51395
+ const { cleanSnapshot } = this;
51396
+ const keys$1 = keys$4(cleanSnapshot);
51397
+ for (let i = 0, len = keys$1.length; i < len; i++) {
51398
+ const key = keys$1[i];
51399
+ // Return shallow spread of snapshot entries so top-level caller mutations
51400
+ // (e.g., .status =) don't persist into the snapshot. The snapshot itself
51401
+ // is a deep clone of draftStore, so no nested mutation reaches draftStore.
51402
+ actionArray.push({ ...cleanSnapshot[key] });
51403
+ }
51404
+ }
51405
+ else {
51406
+ // Killswitch (W-23515682): original pre-fix behavior — deep-clone every draft on
51407
+ // every read so the internal draft store is never exposed. O(N) clones per read.
51408
+ const { draftStore } = this;
51409
+ const keys$1 = keys$4(draftStore);
51410
+ for (let i = 0, len = keys$1.length; i < len; i++) {
51411
+ actionArray.push(clone$1(draftStore[keys$1[i]]));
51412
+ }
51413
+ }
51414
+ return actionArray;
51415
+ });
51416
+ }
51417
+ deleteDraft(id) {
51418
+ const deleteAction = () => {
51419
+ const draft = this.draftStore[id];
51420
+ if (draft !== undefined) {
51421
+ delete this.draftStore[id];
51422
+ delete this.cleanSnapshot[id];
51423
+ const durableKey = buildDraftDurableStoreKey(draft.tag, draft.id);
51424
+ return this.timeDurableOp('evictEntries', DRAFT_SEGMENT, () => this.durableStore.evictEntries([durableKey], DRAFT_SEGMENT));
51425
+ }
51426
+ return Promise.resolve();
51427
+ };
51428
+ return this.enqueueAction(deleteAction);
51429
+ }
51430
+ deleteByTag(tag) {
51431
+ const deleteAction = () => {
51432
+ const { draftStore } = this;
51433
+ const keys$1 = keys$4(draftStore);
51434
+ const durableKeys = [];
51435
+ for (let i = 0, len = keys$1.length; i < len; i++) {
51436
+ const key = keys$1[i];
51437
+ const action = draftStore[key];
51438
+ if (action.tag === tag) {
51439
+ delete draftStore[action.id];
51440
+ delete this.cleanSnapshot[action.id];
51441
+ durableKeys.push(buildDraftDurableStoreKey(action.tag, action.id));
51442
+ }
51443
+ }
51444
+ return this.timeDurableOp('evictEntries', DRAFT_SEGMENT, () => this.durableStore.evictEntries(durableKeys, DRAFT_SEGMENT));
51445
+ };
51446
+ return this.enqueueAction(deleteAction);
51447
+ }
51448
+ completeAction(queueOperations) {
51449
+ const action = () => {
51450
+ const durableStoreOperations = [];
51451
+ const { draftStore } = this;
51452
+ for (let i = 0, len = queueOperations.length; i < len; i++) {
51453
+ const operation = queueOperations[i];
51454
+ if (operation.type === QueueOperationType.Delete) {
51455
+ const action = draftStore[operation.id];
51456
+ if (action !== undefined) {
51457
+ delete draftStore[operation.id];
51458
+ delete this.cleanSnapshot[operation.id];
51459
+ const key = buildDraftDurableStoreKey(action.tag, action.id);
51460
+ durableStoreOperations.push({
51461
+ ids: [key],
51462
+ type: 'evictEntries',
51463
+ segment: DRAFT_SEGMENT,
51464
+ });
51465
+ }
51466
+ }
51467
+ else {
51468
+ const { action } = operation;
51469
+ const key = buildDraftDurableStoreKey(action.tag, action.id);
51470
+ draftStore[action.id] = action;
51471
+ this.cleanSnapshot[action.id] = clone$1(action);
51472
+ durableStoreOperations.push({
51473
+ type: 'setEntries',
51474
+ segment: DRAFT_SEGMENT,
51475
+ entries: {
51476
+ [key]: {
51477
+ data: operation.action,
51478
+ },
51479
+ },
51480
+ });
51481
+ }
51482
+ }
51483
+ return this.timeDurableOp('batchOperations', DRAFT_SEGMENT, () => this.durableStore.batchOperations(durableStoreOperations));
51484
+ };
51485
+ return this.enqueueAction(action);
51486
+ }
51487
+ getCount() {
51488
+ return keys$4(this.draftStore).length;
51489
+ }
51490
+ /**
51491
+ * Runs a write operation against the draft store, if the initial
51492
+ * revive is still in progress, the action gets enqueued to run once the
51493
+ * initial revive is complete
51494
+ * @param action
51495
+ * @returns a promise that is resolved once the action has run
51496
+ */
51497
+ enqueueAction(action) {
51498
+ const { syncPromise, writeQueue: pendingMerges } = this;
51499
+ // if the initial sync is done and existing operations have been run, no need to queue, just run
51500
+ if (syncPromise === undefined && pendingMerges.length === 0) {
51501
+ return action();
51502
+ }
51503
+ const deferred = new Promise((resolve, reject) => {
51504
+ this.writeQueue.push(() => {
51505
+ return action()
51506
+ .then((x) => {
51507
+ resolve(x);
51508
+ })
51509
+ .catch((err) => reject(err));
51510
+ });
51511
+ });
51512
+ return deferred;
51513
+ }
51514
+ /**
51515
+ * Revives the draft store from the durable store. Once the draft store is
51516
+ * revived, executes any queued up draft store operations that came in while
51517
+ * reviving
51518
+ */
51519
+ resyncDraftStore() {
51520
+ const sync = () => {
51521
+ this.syncPromise = this.durableStore
51522
+ .getAllEntries(DRAFT_SEGMENT)
51523
+ .then((durableEntries) => {
51524
+ if (durableEntries === undefined) {
51525
+ this.draftStore = {};
51526
+ this.cleanSnapshot = {};
51527
+ return this.runQueuedOperations();
51528
+ }
51529
+ const { draftStore } = this;
51530
+ const keys$1 = keys$4(durableEntries);
51531
+ for (let i = 0, len = keys$1.length; i < len; i++) {
51532
+ const entry = durableEntries[keys$1[i]];
51533
+ const action = entry.data;
51534
+ draftStore[action.id] = action;
51535
+ this.cleanSnapshot[action.id] = clone$1(action);
51536
+ }
51537
+ return this.runQueuedOperations();
51538
+ })
51539
+ .finally(() => {
51540
+ this.syncPromise = undefined;
51541
+ });
51542
+ return this.syncPromise;
51543
+ };
51544
+ // if there's an ongoing sync populating the in memory store, wait for it to complete before re-syncing
51545
+ const { syncPromise } = this;
51546
+ if (syncPromise === undefined) {
51547
+ return sync();
51548
+ }
51549
+ return syncPromise.then(() => {
51550
+ return sync();
51551
+ });
51552
+ }
51553
+ /**
51554
+ * Runs the operations that were queued up while reviving the
51555
+ * draft store from the durable store
51556
+ */
51557
+ runQueuedOperations() {
51558
+ const { writeQueue } = this;
51559
+ if (writeQueue.length > 0) {
51560
+ const queueItem = writeQueue.shift();
51561
+ if (queueItem !== undefined) {
51562
+ return queueItem().then(() => {
51563
+ return this.runQueuedOperations();
51564
+ });
51565
+ }
51566
+ }
51567
+ return Promise.resolve();
51568
+ }
51569
+ }
51570
+
51571
+ /**
51572
+ * Durable-store segment holding batch-completion journal entries (B2). Kept
51573
+ * separate from DRAFT_SEGMENT so journal rows are never mistaken for draft
51574
+ * actions by DurableDraftStore's resync (which parses every DRAFT_SEGMENT entry
51575
+ * as a DraftAction).
51576
+ */
51577
+ const DRAFT_BATCH_JOURNAL_SEGMENT = 'DRAFT_BATCH_JOURNAL';
51578
+ /**
51579
+ * The Performance Upload draft store. Extends {@link DurableDraftStore} — whose
51580
+ * body is byte-identical to today's shipped store — and serializes concurrent
51581
+ * writes through a `writeChain` so that when two queues share a single store
51582
+ * their snapshot-then-mutate sequences cannot interleave (which would otherwise
51583
+ * let one queue's queue-operation rewrites clobber another's). Only constructed
51584
+ * on the gate-ON code path; gate-off customers use a plain DurableDraftStore.
51585
+ */
51586
+ class PerformanceDraftStore extends DurableDraftStore {
51587
+ constructor(durableStore) {
51588
+ super(durableStore);
51589
+ // Serializes writes so two queues sharing this store can't interleave; see
51590
+ // class doc.
51591
+ this.writeChain = Promise.resolve();
51592
+ // Identifies THIS store session (process launch). Minted fresh per
51593
+ // construction and never persisted, so a journal entry stamped with a
51594
+ // different token is crash residue from a dead process (recoverable), while
51595
+ // one stamped with this token is a batch still in flight this session. Sibling
51596
+ // queues share one store instance (the Performance Upload model) and therefore
51597
+ // one token; see readRecoverableBatchJournals.
51598
+ this.sessionToken = uuidv4();
51599
+ this.durableStoreRef = durableStore;
51600
+ }
51601
+ /**
51602
+ * Records, BEFORE a batch is dispatched, the ids of the members about to be
51603
+ * sent as one all-or-none request (B2). If the app dies — or the durable
51604
+ * removal fails — between the server commit and the client removing the
51605
+ * committed members, the next startQueue reconcile reads this journal and
51606
+ * pins any still-Pending member to the key-preserving serial path instead
51607
+ * of re-batching it (which would drop its idempotency key and replay a
51608
+ * committed write). Journal rows live in their own segment and never flow
51609
+ * through the draft writeChain — they are keyed by batchId, independent of
51610
+ * the draft snapshot-then-mutate sequence the chain serializes.
51611
+ */
51612
+ writeBatchJournal(batchId, memberIds) {
51613
+ const entry = {
51614
+ // Stamp the live session token so a later reconcile can tell this
51615
+ // in-flight batch apart from crash residue (see sessionToken).
51616
+ data: { batchId, memberIds, owner: this.sessionToken },
51617
+ };
51618
+ return this.durableStoreRef.setEntries({ [batchId]: entry }, DRAFT_BATCH_JOURNAL_SEGMENT);
51619
+ }
51620
+ /** Removes a batch-completion journal entry once its members are durably resolved. */
51621
+ clearBatchJournal(batchId) {
51622
+ return this.durableStoreRef.evictEntries([batchId], DRAFT_BATCH_JOURNAL_SEGMENT);
51623
+ }
51624
+ /**
51625
+ * Returns only crash-residue journals — those written by a PRIOR store session
51626
+ * (owner token != this session's). Entries stamped with the live session's
51627
+ * token are batches still in flight this launch (possibly on a sibling queue
51628
+ * sharing this store) and are deliberately excluded, so a startup reconcile can
51629
+ * never quarantine a live peer batch's members out from under it. This is the
51630
+ * only read the queue reconcile uses; readBatchJournals (all entries) is kept
51631
+ * for inspection/tests.
51632
+ *
51633
+ * Relies on sibling queues sharing ONE store INSTANCE (the documented
51634
+ * Performance Upload model), so they share one session token. If two queues
51635
+ * were ever given separate store instances over the same durable store, the
51636
+ * token would have to move down to the durable store.
51637
+ */
51638
+ readRecoverableBatchJournals() {
51639
+ return this.readBatchJournals().then((journals) => journals.filter((journal) => journal.owner !== this.sessionToken));
51640
+ }
51641
+ /** Returns every persisted batch-completion journal entry (inspection/tests). */
51642
+ readBatchJournals() {
51643
+ return this.durableStoreRef
51644
+ .getAllEntries(DRAFT_BATCH_JOURNAL_SEGMENT)
51645
+ .then((entries) => {
51646
+ if (entries === undefined) {
51647
+ return [];
51648
+ }
51649
+ const keys$1 = keys$4(entries);
51650
+ const journals = [];
51651
+ for (let i = 0, len = keys$1.length; i < len; i++) {
51652
+ journals.push(entries[keys$1[i]].data);
51653
+ }
51654
+ return journals;
51655
+ });
51656
+ }
51657
+ getAllDrafts() {
51658
+ const waitForOngoingSync = this.syncPromise || Promise.resolve();
51659
+ // Also wait for any outstanding writes serialized through the
51660
+ // write chain so callers observe writes initiated before this read.
51661
+ const waitForOutstandingWrites = this.writeChain;
51662
+ return Promise.all([waitForOngoingSync, waitForOutstandingWrites]).then(() => {
51663
+ const { draftStore } = this;
51664
+ const keys$1 = keys$4(draftStore);
51665
+ const actionArray = [];
51666
+ for (let i = 0, len = keys$1.length; i < len; i++) {
51667
+ const key = keys$1[i];
51668
+ // clone draft so we don't expose the internal draft store
51669
+ actionArray.push(clone$1(draftStore[key]));
51670
+ }
51671
+ return actionArray;
51672
+ });
51673
+ }
51674
+ enqueueAction(action) {
51675
+ const { syncPromise, writeQueue: pendingMerges } = this;
51676
+ // During initial sync, queue the write to run after sync completes.
51677
+ if (syncPromise !== undefined || pendingMerges.length > 0) {
51678
+ const deferred = new Promise((resolve, reject) => {
51679
+ this.writeQueue.push(() => {
51680
+ return action()
51681
+ .then((x) => {
51682
+ resolve(x);
51683
+ })
51684
+ .catch((err) => reject(err));
51685
+ });
51686
+ });
51687
+ return deferred;
51688
+ }
51689
+ // Sync done. Serialize concurrent writes through writeChain so that
51690
+ // when two queues share this store, their snapshot-then-mutate
51691
+ // sequences cannot interleave.
51692
+ const next = this.writeChain.then(action, action);
51693
+ // Don't propagate failures into the chain — every caller awaits its own
51694
+ // result and a single rejection must not poison subsequent writes.
51695
+ this.writeChain = next.catch(() => undefined);
51696
+ return next;
51697
+ }
51698
+ }
51699
+
49494
51700
  const { hasOwnProperty: ObjectPrototypeHasOwnProperty$1$1 } = Object.prototype;
49495
51701
  const { keys: ObjectKeys$1$2, create: ObjectCreate$1$2 } = Object;
49496
51702
  const { stringify: JSONStringify$1 } = JSON;
@@ -80316,6 +82522,7 @@ const DRAFT_QUEUE_ACTION_DELETED = 'draft-queue-action-deleted';
80316
82522
  const DRAFT_QUEUE_ACTION_UPDATED = 'draft-queue-action-updated';
80317
82523
  const DRAFT_QUEUE_ACTION_FAILED = 'draft-queue-action-failed';
80318
82524
  const DRAFT_QUEUE_ACTION_UPLOAD_ERROR = 'draft-queue-action-upload-error';
82525
+ const DRAFT_QUEUE_BATCH_RECOVERY_FAILED = 'draft-queue-batch-recovery-failed';
80319
82526
  const DRAFT_QUEUE_TOTAL_MERGE_ACTIONS_CALLS = 'draft-queue-total-mergeActions-calls';
80320
82527
  // Emitted every time a draft's Idempotency-Key is rotated (replaced with a fresh uuid).
80321
82528
  // Used to triage the WOLI duplicate-record investigation in Splunk — correlate by draftId
@@ -80366,6 +82573,11 @@ const nimbusLogger = typeof __nimbus !== 'undefined' &&
80366
82573
  __nimbus.plugins.JSLoggerPlugin !== undefined
80367
82574
  ? __nimbus.plugins.JSLoggerPlugin
80368
82575
  : undefined;
82576
+ function logInfo(message) {
82577
+ if (nimbusLogger) {
82578
+ nimbusLogger.logInfo(message);
82579
+ }
82580
+ }
80369
82581
  function reportGraphqlQueryParseError(err) {
80370
82582
  const error = normalizeError$2(err);
80371
82583
  const errorCode = GRAPHQL_QUERY_PARSE_ERROR;
@@ -80411,9 +82623,7 @@ function incrementGraphQLRefreshUndfined() {
80411
82623
  ldsMobileInstrumentation$2.incrementCounter(GRAPHQL_SNAPSHOT_REFRESH_UNDEFINED);
80412
82624
  }
80413
82625
  function reportDraftActionEvent(state, draftCount, message) {
80414
- if (nimbusLogger) {
80415
- nimbusLogger.logInfo(`Draft action event: ${state}, depth: ${draftCount}${message ? `, message: ${message}` : ''}`);
80416
- }
82626
+ logInfo(`Draft action event: ${state}, depth: ${draftCount}${message ? `, message: ${message}` : ''}`);
80417
82627
  switch (state) {
80418
82628
  case 'added':
80419
82629
  ldsMobileInstrumentation$2.incrementCounter(DRAFT_QUEUE_ACTION_ADDED);
@@ -80613,6 +82823,26 @@ function reportDraftActionUploadError(error, handlerId, attempt) {
80613
82823
  });
80614
82824
  ldsMobileInstrumentation$2.error(normalized, DRAFT_QUEUE_ACTION_UPLOAD_ERROR);
80615
82825
  }
82826
+ /**
82827
+ * Reports a batch-journal crash-recovery failure on the Performance Upload
82828
+ * batching path (B2, currently feature-gated and dormant): a maybe-committed
82829
+ * all-or-none batch's members could not be durably quarantined this session, a
82830
+ * startup reconcile could not surface a prior crash's batch, or a resolved journal
82831
+ * row could not be cleared. Each is otherwise visible only in device logs; this
82832
+ * surfaces it to o11y (greppable log line + reason-tagged counter) so the batching
82833
+ * path can be monitored when it ships.
82834
+ *
82835
+ * PII: carries ONLY the bounded reason code and the queue depth — never a record
82836
+ * id, draft id, tag, or free-text error message (device-log PII policy).
82837
+ */
82838
+ function reportBatchRecoveryFailure(reason, draftCount) {
82839
+ if (nimbusLogger) {
82840
+ nimbusLogger.logInfo(`Batch recovery failed: reason=${reason}, depth=${draftCount}`);
82841
+ }
82842
+ ldsMobileInstrumentation$2.incrementCounter(DRAFT_QUEUE_BATCH_RECOVERY_FAILED, 1, true, {
82843
+ reason,
82844
+ });
82845
+ }
80616
82846
  /**
80617
82847
  * Emits a single greppable Splunk log line (and an o11y counter) every time a draft's
80618
82848
  * Idempotency-Key is rotated — i.e. replaced with a fresh uuid rather than reused on
@@ -80642,9 +82872,7 @@ function reportIdempotencyKeyRotated(draftId, reason) {
80642
82872
  });
80643
82873
  }
80644
82874
  function reportDraftQueueState(state, draftCount) {
80645
- if (nimbusLogger) {
80646
- nimbusLogger.logInfo(`Draft state changed: ${state}, depth: ${draftCount}`);
80647
- }
82875
+ logInfo(`Draft state changed: ${state}, depth: ${draftCount}`);
80648
82876
  switch (state) {
80649
82877
  case 'started':
80650
82878
  ldsMobileInstrumentation$2.incrementCounter(DRAFT_QUEUE_STATE_STARTED);
@@ -80757,6 +82985,26 @@ function getRetryAfterInMs(headers) {
80757
82985
 
80758
82986
  const DEFAULT_FIELD_LAST_MODIFIED_DATE$1 = 'LastModifiedDate';
80759
82987
  const DEFAULT_FIELD_CREATED_DATE$1 = 'CreatedDate';
82988
+ // UI-API Records Batch endpoint. Reuses the same baseUri pattern as the
82989
+ // generated record adapters (see e.g. getUiApiRecordsByRecordId.ts).
82990
+ const BATCH_BASE_URI = '/services/data/v68.0';
82991
+ const BATCH_BASE_PATH = '/ui-api/records/batch';
82992
+ // Non-attributive error codes returned by an allOrNone=true batch. When one
82993
+ // operation fails, UI-API rolls the whole batch back and marks the *innocent*
82994
+ // siblings with one of these codes — they are not themselves the offender:
82995
+ // - ALL_OR_NONE_OPERATION_ROLLED_BACK: the canonical all-or-none rollback
82996
+ // marker applied to every sibling that would otherwise have succeeded.
82997
+ // - PROCESSING_HALTED: applied to operations skipped because processing was
82998
+ // halted (e.g. a dependency/composite-ordering stop) before they ran.
82999
+ // The true offender is any member whose errorCode is neither of these.
83000
+ const PROCESSING_HALTED_ERROR_CODE = 'PROCESSING_HALTED';
83001
+ const ALL_OR_NONE_ROLLED_BACK_ERROR_CODE = 'ALL_OR_NONE_OPERATION_ROLLED_BACK';
83002
+ // Codes that mark a batch member as a rolled-back innocent sibling rather than
83003
+ // the operation that actually caused the batch to fail.
83004
+ const ROLLBACK_SIBLING_ERROR_CODES = new Set([
83005
+ PROCESSING_HALTED_ERROR_CODE,
83006
+ ALL_OR_NONE_ROLLED_BACK_ERROR_CODE,
83007
+ ]);
80760
83008
  class AbstractResourceRequestActionHandler {
80761
83009
  constructor(draftQueue, networkAdapter, getLuvio, recordService) {
80762
83010
  this.draftQueue = draftQueue;
@@ -80919,23 +83167,7 @@ class AbstractResourceRequestActionHandler {
80919
83167
  actionDataChanged = true;
80920
83168
  }
80921
83169
  }
80922
- if (response.status === HttpStatusCode$2.BadRequest &&
80923
- this.isBackdatingError(response.body, action)) {
80924
- updatedAction.timestamp = Date.now();
80925
- updatedAction.data.body.fields = {
80926
- ...updatedAction.data.body.fields,
80927
- LastModifiedDate: new Date(updatedAction.timestamp).toISOString(),
80928
- };
80929
- // The record content changed (LastModifiedDate is bumped), so this is a NEW
80930
- // logical write. Rotate the idempotency key so the server treats it as such
80931
- // rather than deduping it against the stale, pre-backdate request. This is a
80932
- // legitimate rotation — the request body is modified — unlike the generic-400
80933
- // fallback above which must preserve the key. @W-23445154
80934
- if (this.hasIdempotencySupport() &&
80935
- updatedAction.data.headers[HTTP_HEADER_IDEMPOTENCY_KEY] !== undefined) {
80936
- updatedAction.data.headers[HTTP_HEADER_IDEMPOTENCY_KEY] = uuidv4();
80937
- reportIdempotencyKeyRotated(updatedAction.targetId, 'backdating-collision');
80938
- }
83170
+ if (this.applyBackdatingRetryIfNeeded(response, updatedAction)) {
80939
83171
  shouldRetry = true;
80940
83172
  actionDataChanged = true;
80941
83173
  }
@@ -81033,6 +83265,113 @@ class AbstractResourceRequestActionHandler {
81033
83265
  };
81034
83266
  await actionErrored(retryingAction, true, retryDelayInMs, true);
81035
83267
  }
83268
+ /**
83269
+ * If `response` is a LastModifiedDate backdating collision (@W-23445154) for
83270
+ * `action`, mutate the action so it is treated as a NEW logical write — bump
83271
+ * its timestamp + LastModifiedDate, and rotate the idempotency key when one
83272
+ * is present (the body changed, so the server must not dedupe it against the
83273
+ * stale pre-backdate request) — and return true. Otherwise return false and
83274
+ * leave the action untouched.
83275
+ *
83276
+ * Extracted from the single-action handleAction path so the batch upload
83277
+ * path (UiApiActionHandler.classifyBatchFailure) treats a backdating
83278
+ * collision identically instead of diverging (B4).
83279
+ */
83280
+ applyBackdatingRetryIfNeeded(response, action) {
83281
+ if (response.status !== HttpStatusCode$2.BadRequest ||
83282
+ !this.isBackdatingError(response.body, action)) {
83283
+ return false;
83284
+ }
83285
+ action.timestamp = Date.now();
83286
+ action.data.body.fields = {
83287
+ ...action.data.body.fields,
83288
+ LastModifiedDate: new Date(action.timestamp).toISOString(),
83289
+ };
83290
+ if (this.hasIdempotencySupport() &&
83291
+ action.data.headers[HTTP_HEADER_IDEMPOTENCY_KEY] !== undefined) {
83292
+ action.data.headers[HTTP_HEADER_IDEMPOTENCY_KEY] = uuidv4();
83293
+ reportIdempotencyKeyRotated(action.targetId, 'backdating-collision');
83294
+ }
83295
+ return true;
83296
+ }
83297
+ /**
83298
+ * Classifies a failure surfaced by the batch upload path — either a non-ok
83299
+ * batch envelope (whole request failed) or a single offending operation's
83300
+ * error result — into a retry-or-terminal decision, reusing the SAME policy
83301
+ * the single-action handleAction path applies to the same failure shapes so
83302
+ * the two do not diverge (B4). The caller routes the decision: a retry goes
83303
+ * through retryOrCap (so the durable attempt cap and backoff apply), a
83304
+ * terminal result errors the action with no retry.
83305
+ *
83306
+ * Note the idempotency-key state machine in handleAction (key rotation on
83307
+ * recognized idempotency error codes, isIdempotencySupported toggling) is
83308
+ * intentionally NOT shared here: those branches are keyed on the request's
83309
+ * per-action Idempotency-Key, which the batch envelope does not carry.
83310
+ *
83311
+ * commitAmbiguous distinguishes retryable failures that MAY have committed the
83312
+ * all-or-none batch at the origin (a 5xx/408/dropped-connection — or a generic
83313
+ * gateway 400 — returned after the write landed, with the success response lost
83314
+ * in transit) from retryable failures that definitively rolled back or never
83315
+ * reached the origin (a 429 rate-limit, a backdating collision the server
83316
+ * evaluated and rejected). The batch envelope carries no server-visible
83317
+ * idempotency key, so a commit-ambiguous failure cannot be safely re-sent; the
83318
+ * caller returns NETWORK_ERROR and the queue surfaces the members for manual
83319
+ * resolution instead of auto-replaying them (Option A). A definitively
83320
+ * rolled-back failure committed nothing, so a keyless re-batch is replay-safe.
83321
+ */
83322
+ classifyBatchFailure(response, action) {
83323
+ const status = response.status ?? 0;
83324
+ // Transient / infrastructure failures — retry, honoring any server-sent
83325
+ // Retry-After (mirrors handleAction's 408/503 handling). 0 = no response
83326
+ // (connection dropped / suspended in flight); 408 Request Timeout; 429
83327
+ // Too Many Requests; 5xx server errors.
83328
+ if (status === 0 ||
83329
+ status === 408 ||
83330
+ status === 429 ||
83331
+ status >= HttpStatusCode$2.ServerError) {
83332
+ return {
83333
+ shouldRetry: true,
83334
+ retryDelayInMs: getRetryAfterInMs(response.headers),
83335
+ actionDataChanged: false,
83336
+ // 0 (dropped connection), 408 (timeout), and 5xx (gateway/LB/origin
83337
+ // server error) can each be returned AFTER the all-or-none batch
83338
+ // committed at the origin with the 2xx lost — commit-ambiguous, like
83339
+ // handleAction's NoResponse/ServiceUnavailable path. A 429 is a rate
83340
+ // limiter rejecting the request before it is processed, so nothing
83341
+ // committed and a keyless re-batch is replay-safe.
83342
+ commitAmbiguous: status !== 429,
83343
+ };
83344
+ }
83345
+ // LastModifiedDate backdating collision — bump the record + retry, same
83346
+ // as the single-action path. The server evaluated and rejected the write,
83347
+ // so the batch rolled back — not commit-ambiguous.
83348
+ if (this.applyBackdatingRetryIfNeeded(response, action)) {
83349
+ return { shouldRetry: true, actionDataChanged: true, commitAmbiguous: false };
83350
+ }
83351
+ // A 400 with no recognizable server-generated error body is an edge- or
83352
+ // gateway-injected rejection whose commit status is genuinely ambiguous: an
83353
+ // edge proxy or load balancer can return a bare 400 AFTER the origin has
83354
+ // already committed the all-or-none batch. The envelope carries no
83355
+ // server-visible idempotency key, so a keyless re-send could double-apply.
83356
+ // Treat it as commit-ambiguous (like status 0/408/5xx) so the queue does NOT
83357
+ // auto-replay it and surfaces the members for manual resolution instead.
83358
+ if (status === HttpStatusCode$2.BadRequest && !this.isServerGeneratedError(response.body)) {
83359
+ return { shouldRetry: true, actionDataChanged: false, commitAmbiguous: true };
83360
+ }
83361
+ // A real, server-generated client error (validation, permissions, a
83362
+ // deterministic 4xx) — terminal. Retrying it unchanged would only be
83363
+ // rejected again.
83364
+ return { shouldRetry: false, actionDataChanged: false, commitAmbiguous: false };
83365
+ }
83366
+ /**
83367
+ * Reports an upload failure to o11y using the same channel and attempt
83368
+ * numbering as the single-action handleAction catch. Exposed so the batch
83369
+ * upload path surfaces a thrown/deterministic failure to telemetry instead
83370
+ * of swallowing it (B4).
83371
+ */
83372
+ reportUploadFailure(error, action) {
83373
+ reportDraftActionUploadError(error, this.handlerId, this.getUploadRetryCount(action) + 1);
83374
+ }
81036
83375
  async handleActionEnqueued(action) {
81037
83376
  const impactedKeys = await this.recordService.setSideEffectsForActions([action], 'enqueued');
81038
83377
  await this.recordService.reapplyRecordSideEffects(impactedKeys, 'enqueued');
@@ -81495,6 +83834,29 @@ class MemoizingRecordReader {
81495
83834
 
81496
83835
  const LDS_ACTION_HANDLER_ID = 'LDS_ACTION_HANDLER';
81497
83836
  const LDS_ACTION_METADATA_API_NAME = 'LDS_ACTION_METADATA_API_NAME';
83837
+ // Request headers/query params that the UI-API records/batch envelope can omit
83838
+ // without changing the write, so their presence must not disqualify an action
83839
+ // from batching (see isActionBatchable, B1). Everything not listed here does
83840
+ // change server behavior and forces single-action dispatch.
83841
+ const BATCH_OMITTABLE_HEADERS = new Set([HTTP_HEADER_IDEMPOTENCY_KEY]);
83842
+ const BATCH_OMITTABLE_QUERY_PARAMS = new Set(['includeFieldsInBody']);
83843
+ // Diagnostic log line for the local-experimental batching path. Mirrors the
83844
+ // helper in PerformanceDraftQueue so handler-side events show up in the same
83845
+ // device-log stream with the same `Batch ` prefix.
83846
+ function batchLog(line) {
83847
+ try {
83848
+ globalThis.__nimbus?.plugins?.JSLoggerPlugin?.logInfo(`Batch ${line}`);
83849
+ }
83850
+ catch {
83851
+ // debug-only
83852
+ }
83853
+ }
83854
+ // Ingestion timestamp that makes the uploaded record stale for the next read. Ingested at
83855
+ // epoch+1 the record is expired under any ttl: the record ttl, a record ttl override, the
83856
+ // default ttl override or a host metadata ttl. (Not 0, which falsy checks treat as missing.)
83857
+ // Expiring it with luvio.storeExpirePossibleStaleRecords or a 0 ttl does not work when a ttl
83858
+ // override is set, since the override replaces the ttl.
83859
+ const STALE_INGESTION_TIMESTAMP = 1;
81498
83860
  class UiApiActionHandler extends AbstractResourceRequestActionHandler {
81499
83861
  constructor(getLuvio, networkAdapter, draftQueue, getRecordAdapter, objectInfoService, isDraftId, recordService, sideEffectService, durableRecordStore) {
81500
83862
  super(draftQueue, networkAdapter, getLuvio, recordService);
@@ -81509,6 +83871,8 @@ class UiApiActionHandler extends AbstractResourceRequestActionHandler {
81509
83871
  this.durableRecordStore = durableRecordStore;
81510
83872
  this.handlerId = LDS_ACTION_HANDLER_ID;
81511
83873
  this.sideEffectHooks = [];
83874
+ // side effect field values of the create actions being completed, by action id
83875
+ this.injectedFieldsByActionId = new Map();
81512
83876
  recordService.registerRecordHandler(this);
81513
83877
  }
81514
83878
  setSideEffectHooks(hooks) {
@@ -81619,7 +83983,40 @@ class UiApiActionHandler extends AbstractResourceRequestActionHandler {
81619
83983
  await this.evictKey(completedAction.tag);
81620
83984
  return;
81621
83985
  }
81622
- return super.handleActionCompleted(completedAction, queueOperations);
83986
+ if (method !== 'post') {
83987
+ return super.handleActionCompleted(completedAction, queueOperations);
83988
+ }
83989
+ // super removes the action's side effects before it ingests the response, so what the
83990
+ // create's side effects set has to be captured first
83991
+ const injectedFields = this.getFieldsToInject(completedAction);
83992
+ if (injectedFields !== undefined) {
83993
+ this.injectedFieldsByActionId.set(completedAction.id, injectedFields);
83994
+ }
83995
+ try {
83996
+ await super.handleActionCompleted(completedAction, queueOperations);
83997
+ }
83998
+ finally {
83999
+ this.injectedFieldsByActionId.delete(completedAction.id);
84000
+ }
84001
+ }
84002
+ // The create response only has the fields that were requested, so a field that only a side
84003
+ // effect sets (e.g. ProductConsumed.IsProduct2Serialized) is missing from the cached
84004
+ // server record once the draft's effects are gone. Returns the values of those fields.
84005
+ // Only called for a POST: handleActionCompleted hands every other method straight to super.
84006
+ getFieldsToInject(completedAction) {
84007
+ if (sideEffectRefreshKillSwitch.isOpen({ fallback: false })) {
84008
+ return undefined;
84009
+ }
84010
+ const fields = {};
84011
+ this.recordService.getEffectsForAction(completedAction).forEach((effect) => {
84012
+ if (effect.type === 'record-field-set' && effect.key === completedAction.tag) {
84013
+ fields[effect.fieldName] = { value: effect.value, displayValue: null };
84014
+ }
84015
+ });
84016
+ if (keys$3(fields).length === 0) {
84017
+ return undefined;
84018
+ }
84019
+ return fields;
81623
84020
  }
81624
84021
  async fetchReferenceRecord(referenceFields) {
81625
84022
  const promises = referenceFields.map(async (referenceFieldInfo) => {
@@ -81677,6 +84074,500 @@ class UiApiActionHandler extends AbstractResourceRequestActionHandler {
81677
84074
  getIdFromResponseBody(responseBody) {
81678
84075
  return responseBody.id;
81679
84076
  }
84077
+ /**
84078
+ * The UI-API records/batch envelope carries only { type, records:[{ apiName?,
84079
+ * fields }] } per operation — it has no place for per-operation headers or
84080
+ * query params (see buildBatchOperations / executeBatch, which send empty
84081
+ * headers and queryParams). An action whose request relies on a header or
84082
+ * query param that changes the *write* would silently lose that semantic if
84083
+ * folded into a batch, so it must be dispatched through single-action
84084
+ * handleAction (which sends the request verbatim) instead (B1).
84085
+ *
84086
+ * Two request members are present on essentially every update/delete draft
84087
+ * and are safe for the batch to omit, so they must NOT disqualify batching
84088
+ * (doing so would disable it wholesale):
84089
+ * - Idempotency-Key header: dropped from the batch envelope by design (the
84090
+ * batch has no per-op key). Its absence weakens replay protection, which
84091
+ * is addressed by journaled batch completion (B2) — it is not a per-op
84092
+ * write-semantic change, so it does not block batching here.
84093
+ * - includeFieldsInBody query param: only shapes the response body, not the
84094
+ * write; updateRecord adds it unconditionally.
84095
+ * Any OTHER header (e.g. If-Unmodified-Since — a conditional update that would
84096
+ * become an unconditional lost-update) or query param (useDefaultRule,
84097
+ * triggerUserEmail, triggerOtherEmail — assignment-rule / email side effects)
84098
+ * does change server behavior and makes the action non-batchable.
84099
+ *
84100
+ * The request BODY is checked too: buildBatchOperations copies ONLY `fields`
84101
+ * into the batch UPDATE/DELETE operation, so any other body member — e.g.
84102
+ * `allowSaveOnDuplicate`, which changes duplicate-rule handling — would be
84103
+ * silently dropped if the action were batched. An action whose body carries
84104
+ * anything beyond `fields` is therefore dispatched single-action (handleAction
84105
+ * sends the body verbatim), preserving those semantics.
84106
+ *
84107
+ * resolveResourceRequest only rewrites basePath/body/urlParams (never headers
84108
+ * or queryParams), and a body option like allowSaveOnDuplicate survives that
84109
+ * rewrite, so the raw action.data is authoritative for this check and we avoid
84110
+ * the cost of resolving here.
84111
+ */
84112
+ isActionBatchable(action) {
84113
+ const request = action.data;
84114
+ if (request.headers !== undefined) {
84115
+ const headerNames = keys$3(request.headers);
84116
+ for (let i = 0; i < headerNames.length; i++) {
84117
+ if (!BATCH_OMITTABLE_HEADERS.has(headerNames[i])) {
84118
+ return false;
84119
+ }
84120
+ }
84121
+ }
84122
+ if (request.queryParams !== undefined) {
84123
+ const queryNames = keys$3(request.queryParams);
84124
+ for (let i = 0; i < queryNames.length; i++) {
84125
+ if (!BATCH_OMITTABLE_QUERY_PARAMS.has(queryNames[i])) {
84126
+ return false;
84127
+ }
84128
+ }
84129
+ }
84130
+ // Only `fields` is carried into the batch op (buildBatchOperations); any
84131
+ // other body member (e.g. allowSaveOnDuplicate) would be dropped, so it
84132
+ // disqualifies the action from batching.
84133
+ const body = request.body;
84134
+ if (body !== undefined && body !== null) {
84135
+ const bodyKeys = keys$3(body);
84136
+ for (let i = 0; i < bodyKeys.length; i++) {
84137
+ if (bodyKeys[i] !== 'fields') {
84138
+ return false;
84139
+ }
84140
+ }
84141
+ }
84142
+ return true;
84143
+ }
84144
+ /**
84145
+ * Sends a single UI-API records/batch request that combines all given actions.
84146
+ * Uses allOrNone=true so any failure rolls back the whole batch on the server,
84147
+ * preserving today's "one bad mutation blocks the queue" UX.
84148
+ *
84149
+ * Per-action result routing pairs each result with its action by index. The
84150
+ * endpoint exposes no per-result correlation key (no client-supplied
84151
+ * referenceId; DELETE/error results carry no id) and does not contractually
84152
+ * guarantee results order matches operations order, so index pairing is
84153
+ * guarded: the result count must equal the action count, and each UPDATE
84154
+ * result's returned record id must match the action's resolved recordId.
84155
+ * - Success (hasErrors === false): each result is paired with its action by
84156
+ * index and forwarded to actionCompleted in order.
84157
+ * - Server-reported failure (hasErrors === true): exactly one result carries
84158
+ * the real errorCode; the innocent siblings carry a non-attributive
84159
+ * rollback code (ALL_OR_NONE_OPERATION_ROLLED_BACK or PROCESSING_HALTED).
84160
+ * The batch rolled back, so a retryable offender parks EVERY member
84161
+ * together (C4/R4-3 — no self-driven prefix/suffix retry) charging a retry
84162
+ * attempt only to the offender, and a deterministic offender is errored
84163
+ * terminally while innocent siblings are left Pending (B5); returns
84164
+ * ACTION_ERRORED (journal cleared, replay-safe).
84165
+ * - Commit-ambiguous transport failure (network throw, empty-body or
84166
+ * misaligned 2xx, result count/id mismatch, or a retryable 5xx/408/dropped-
84167
+ * connection or generic-400 envelope that may have committed with the 2xx
84168
+ * lost): return NETWORK_ERROR WITHOUT retrying. The batch envelope carries
84169
+ * no server-visible idempotency key, so it cannot be safely re-sent; the
84170
+ * queue surfaces the members for manual resolution and retains/clears the
84171
+ * B2 journal instead of auto-replaying them (Option A / R4-4).
84172
+ */
84173
+ async handleBatchActions(actions, actionCompleted, actionErrored) {
84174
+ return this.executeBatch(actions, actionCompleted, actionErrored);
84175
+ }
84176
+ async executeBatch(actions, actionCompleted, actionErrored) {
84177
+ if (actions.length === 0) {
84178
+ return ProcessActionResult.NO_ACTION_TO_PROCESS;
84179
+ }
84180
+ try {
84181
+ const operations = this.buildBatchOperations(actions);
84182
+ batchLog(`sending size=${actions.length}`);
84183
+ const batchRequest = {
84184
+ baseUri: BATCH_BASE_URI,
84185
+ basePath: BATCH_BASE_PATH,
84186
+ method: 'post',
84187
+ body: { allOrNone: true, operations },
84188
+ urlParams: {},
84189
+ queryParams: {},
84190
+ headers: {},
84191
+ priority: 'normal',
84192
+ };
84193
+ // Attribute the batch request to the head action's observability
84194
+ // context. A batch collapses N actions into one request, so there is
84195
+ // no per-member correlation; the head action's context is used as a
84196
+ // representative, mirroring the single-action dispatch path in
84197
+ // handleAction. (If members carried differing contexts, only the
84198
+ // head's is reported.)
84199
+ const dispatchOptions = actions[0].observabilityContext !== undefined
84200
+ ? {
84201
+ requestCorrelator: {
84202
+ observabilityContext: actions[0].observabilityContext,
84203
+ },
84204
+ }
84205
+ : {};
84206
+ const response = await this.networkAdapter(batchRequest, dispatchOptions);
84207
+ if (!response.ok) {
84208
+ // Classify the envelope with the SAME body-aware policy the
84209
+ // single-action path uses (classifyBatchFailure) instead of a
84210
+ // status-only check. Three outcomes, routing EVERY member (not just
84211
+ // the head — C4):
84212
+ // - Commit-AMBIGUOUS (5xx/408/dropped connection, or a generic
84213
+ // gateway 400): may have committed at the origin with the 2xx
84214
+ // lost, and the batch carries no server-visible idempotency key,
84215
+ // so a keyless re-send could double-apply. Return NETWORK_ERROR
84216
+ // WITHOUT retrying; the queue surfaces the members for manual
84217
+ // resolution and retains/clears the journal instead of
84218
+ // auto-replaying them (Option A / R4-4).
84219
+ // - Definitively rolled back but retryable (429 rate-limit,
84220
+ // backdating collision the server evaluated): committed nothing,
84221
+ // so a keyless re-batch is replay-safe — park every member for a
84222
+ // bounded, backed-off retry (capped, honoring Retry-After).
84223
+ // - Deterministic server-generated client error: terminal, so a
84224
+ // rejected batch is not retried forever (C3/B4).
84225
+ const decision = this.classifyBatchFailure(response, actions[0]);
84226
+ batchLog(`http error status=${response.status ?? 0} retry=${decision.shouldRetry}` +
84227
+ ` ambiguous=${decision.commitAmbiguous} size=${actions.length}`);
84228
+ if (decision.shouldRetry) {
84229
+ if (decision.commitAmbiguous) {
84230
+ return ProcessActionResult.NETWORK_ERROR;
84231
+ }
84232
+ await this.retryBatchMembers(actions, response, actionErrored, decision.retryDelayInMs);
84233
+ }
84234
+ else {
84235
+ await this.terminalBatchMembers(actions, response, actionErrored);
84236
+ }
84237
+ return ProcessActionResult.ACTION_ERRORED;
84238
+ }
84239
+ if (response.body === undefined || response.body === null) {
84240
+ // 2xx with no body — a misaligned/empty response. We got a success
84241
+ // status but cannot confirm what committed, so this is
84242
+ // commit-AMBIGUOUS: return NETWORK_ERROR so the queue surfaces the
84243
+ // members for manual resolution and retains/clears the journal,
84244
+ // rather than auto-replaying a keyless batch that may have committed
84245
+ // (Option A / R4-4).
84246
+ batchLog(`empty body ambiguous size=${actions.length}`);
84247
+ return ProcessActionResult.NETWORK_ERROR;
84248
+ }
84249
+ const body = response.body;
84250
+ // Both branches below pair body.results to actions positionally.
84251
+ // The UI-API batch endpoint supplies no per-result correlation key
84252
+ // (no client referenceId, and DELETE/error results carry no id) and
84253
+ // does not contractually guarantee results order matches operations
84254
+ // order, so a short/over-length response would silently mis-pair
84255
+ // completions with the wrong drafts. Refuse to pair positionally
84256
+ // unless the counts match exactly; on mismatch complete nothing. This is
84257
+ // commit-AMBIGUOUS (2xx received, response untrustworthy): return
84258
+ // NETWORK_ERROR so the queue surfaces the members for manual resolution
84259
+ // and retains/clears the journal (Option A / R4-4).
84260
+ if (!isArray$3$1(body.results) || body.results.length !== actions.length) {
84261
+ batchLog(`result count mismatch expected=${actions.length}` +
84262
+ ` got=${isArray$3$1(body.results) ? body.results.length : 'none'}` +
84263
+ ` ambiguous`);
84264
+ return ProcessActionResult.NETWORK_ERROR;
84265
+ }
84266
+ if (body.hasErrors === false) {
84267
+ // Strict success envelope (R4-4): the endpoint claims the batch
84268
+ // succeeded, but we only complete (and remove) a draft when the
84269
+ // member's OWN result confirms it committed — otherwise it is
84270
+ // commit-AMBIGUOUS: return NETWORK_ERROR so the queue surfaces the
84271
+ // members for manual resolution and retains/clears the journal,
84272
+ // rather than silently dropping a write that may never have landed.
84273
+ // (CREATE is never batched; DELETE carries no body, so only its 2xx
84274
+ // status is required — id echo is UPDATE-only.)
84275
+ for (let i = 0; i < actions.length; i++) {
84276
+ const action = actions[i];
84277
+ const method = action.data.method;
84278
+ const result = body.results[i];
84279
+ const childStatus = result?.statusCode;
84280
+ if (childStatus === undefined ||
84281
+ childStatus < HttpStatusCode$2.Ok ||
84282
+ childStatus >= 300) {
84283
+ batchLog(`success envelope unconfirmed child index=${i}` +
84284
+ ` status=${childStatus ?? 'none'} ambiguous`);
84285
+ return ProcessActionResult.NETWORK_ERROR;
84286
+ }
84287
+ if (method === 'patch') {
84288
+ const resolved = this.resolveResourceRequest(action.data);
84289
+ const expectedId = resolved.urlParams['recordId'];
84290
+ const returnedId = !isArray$3$1(result.result)
84291
+ ? result.result.id
84292
+ : undefined;
84293
+ // UPDATE must echo a matching record id; a missing or
84294
+ // mismatched id is commit-AMBIGUOUS.
84295
+ if (returnedId === undefined || returnedId !== expectedId) {
84296
+ // Log the reason code + whether an id came back, NOT the
84297
+ // ids themselves: expectedId/returnedId are record ids,
84298
+ // and the device-log PII policy (metrics.ts) forbids
84299
+ // record ids in device logs (4065667273).
84300
+ batchLog(`success envelope bad update id index=${i}` +
84301
+ ` returned=${returnedId === undefined ? 'none' : 'mismatch'} ambiguous`);
84302
+ return ProcessActionResult.NETWORK_ERROR;
84303
+ }
84304
+ }
84305
+ }
84306
+ batchLog(`completed size=${actions.length}`);
84307
+ for (let i = 0; i < actions.length; i++) {
84308
+ const action = actions[i];
84309
+ const result = body.results[i];
84310
+ const recordBody = result !== undefined && !isArray$3$1(result.result)
84311
+ ? result.result
84312
+ : undefined;
84313
+ const perActionResponse = {
84314
+ status: (result?.statusCode ?? HttpStatusCode$2.Ok),
84315
+ statusText: 'OK',
84316
+ ok: true,
84317
+ headers: {},
84318
+ body: recordBody,
84319
+ };
84320
+ await actionCompleted({
84321
+ ...action,
84322
+ response: perActionResponse,
84323
+ status: DraftActionStatus.Completed,
84324
+ });
84325
+ }
84326
+ return ProcessActionResult.ACTION_SUCCEEDED;
84327
+ }
84328
+ // hasErrors is neither false (clean success, handled above) nor true
84329
+ // (attributable failure, handled below): the envelope is incomplete
84330
+ // or malformed — we can neither confirm a commit nor attribute a
84331
+ // failure, so treat it as commit-AMBIGUOUS (return NETWORK_ERROR, as
84332
+ // above).
84333
+ if (body.hasErrors !== true) {
84334
+ batchLog(`success envelope missing hasErrors flag ambiguous`);
84335
+ return ProcessActionResult.NETWORK_ERROR;
84336
+ }
84337
+ // Locate the action whose result is the real failure. Under
84338
+ // allOrNone=true the server rolls the whole batch back and marks the
84339
+ // innocent siblings with a non-attributive code (B3): the canonical
84340
+ // all-or-none marker ALL_OR_NONE_OPERATION_ROLLED_BACK, or
84341
+ // PROCESSING_HALTED for operations skipped before they ran. The true
84342
+ // offender is the member whose errorCode is neither — matching only
84343
+ // PROCESSING_HALTED would mis-pick an innocent rolled-back sibling as
84344
+ // the offender and leave the real invalid action Pending forever.
84345
+ let offendingIndex = -1;
84346
+ let offendingResult;
84347
+ for (let i = 0; i < body.results.length; i++) {
84348
+ const r = body.results[i];
84349
+ if (r === undefined)
84350
+ continue;
84351
+ if (!isArray$3$1(r.result))
84352
+ continue;
84353
+ const code = r.result[0]?.errorCode;
84354
+ if (code !== undefined && !ROLLBACK_SIBLING_ERROR_CODES.has(code)) {
84355
+ offendingIndex = i;
84356
+ offendingResult = r;
84357
+ break;
84358
+ }
84359
+ }
84360
+ // Unattributed: every failing result carries only a non-attributive
84361
+ // rollback-sibling code with no clear offender. Treat as a transient
84362
+ // server error — retry the whole batch.
84363
+ if (offendingIndex < 0 || offendingResult === undefined) {
84364
+ batchLog(`hasErrors size=${actions.length} unattributed retry=true`);
84365
+ await this.retryBatchMembers(actions, response, actionErrored);
84366
+ return ProcessActionResult.ACTION_ERRORED;
84367
+ }
84368
+ const offendingAction = actions[offendingIndex];
84369
+ const errorBody = offendingResult?.result ?? [];
84370
+ const errorResponse = {
84371
+ status: (offendingResult?.statusCode ??
84372
+ HttpStatusCode$2.BadRequest),
84373
+ statusText: 'Bad Request',
84374
+ ok: false,
84375
+ headers: {},
84376
+ body: errorBody,
84377
+ };
84378
+ const offendingErrorCode = isArray$3$1(offendingResult?.result)
84379
+ ? offendingResult.result[0]?.errorCode ?? 'unknown'
84380
+ : 'unknown';
84381
+ // Classify the offender, reusing the single-action retry/terminal
84382
+ // policy (B4): a transient/ambiguous failure retries through the
84383
+ // durable cap, a deterministic server-generated client error is
84384
+ // terminal so the user can fix or remove it. classifyBatchFailure may
84385
+ // mutate offendingAction (e.g. a backdating bump) and reports
84386
+ // actionDataChanged so the mutation is persisted before the retry.
84387
+ // The batch endpoint returns each member's failure as a UiApiError[]
84388
+ // (the offender loop above keys on r.result[0].errorCode). The shared
84389
+ // classifiers reused by classifyBatchFailure — isBackdatingError /
84390
+ // isServerGeneratedError — and the single-action handleAction path
84391
+ // expect the origin's structured enhancedErrorType envelope, where a
84392
+ // backdating collision surfaces as
84393
+ // { enhancedErrorType: 'RecordError', output: { errors: [...] } }.
84394
+ // Left as a bare array, isBackdatingError never matches, so a batched
84395
+ // LastModifiedDate CollisionDetectedException would be errored
84396
+ // terminally instead of getting the single-action backdating bump +
84397
+ // key-rotate + retry (B4). Wrap the member's error array in that
84398
+ // envelope for CLASSIFICATION ONLY. This is behavior-preserving for
84399
+ // every non-collision offender: isServerGeneratedError is already
84400
+ // true for a UiApiError[] offender, so the terminal decision is
84401
+ // unchanged; only a genuine collision (which also requires the action
84402
+ // to carry a LastModifiedDate field) now recovers. The error surfaced
84403
+ // to the draft (errorResponse) keeps the raw array — unchanged.
84404
+ const classificationResponse = {
84405
+ ...errorResponse,
84406
+ body: {
84407
+ enhancedErrorType: 'RecordError',
84408
+ output: {
84409
+ errors: isArray$3$1(offendingResult?.result) ? offendingResult.result : [],
84410
+ },
84411
+ },
84412
+ };
84413
+ const decision = this.classifyBatchFailure(classificationResponse, offendingAction);
84414
+ batchLog(`failed size=${actions.length} offendingIndex=${offendingIndex}` +
84415
+ ` errorCode=${offendingErrorCode} retry=${decision.shouldRetry}`);
84416
+ if (decision.shouldRetry) {
84417
+ // Transient/ambiguous offender: the whole batch rolled back and
84418
+ // NONE committed. Parking only the offender and leaving the innocent
84419
+ // siblings Pending would let the queue immediately re-collect them
84420
+ // into a fresh, backoff-free batch — and if that batch's new head
84421
+ // draws the same transient response, [A,B,C] retries as [B,C] then
84422
+ // [C], up to 25 + 24 + ... + 1 = 325 operations before the first
84423
+ // backoff expires (R4-3). Park EVERY member together so the whole
84424
+ // batch backs off — but charge a durable retry attempt ONLY to the
84425
+ // offender (via retryOrCap). The innocent rolled-back siblings
84426
+ // produced no error of their own, so they are parked for the same
84427
+ // backoff WITHOUT consuming their retry budget; charging them could
84428
+ // push an innocent later action to its cap for another action's
84429
+ // transient failure (#9, matching the terminal branch's B5
84430
+ // treatment). The offender's own cap still bounds the shared-batch
84431
+ // retry loop. Only the offender carries any classifier mutation (a
84432
+ // backdating bump), so it alone reports actionDataChanged to persist
84433
+ // that mutation before its retry.
84434
+ for (const member of actions) {
84435
+ if (member === offendingAction) {
84436
+ await this.retryOrCap(member, errorResponse, actionErrored, decision.retryDelayInMs, decision.actionDataChanged);
84437
+ }
84438
+ else {
84439
+ await actionErrored(member, true, decision.retryDelayInMs);
84440
+ }
84441
+ }
84442
+ }
84443
+ else {
84444
+ // Deterministic, server-generated offender: terminal. Route ONLY the
84445
+ // offender and leave the innocent rolled-back siblings Pending and
84446
+ // unparked — reporting nothing for them (B5). Charging a sibling a
84447
+ // durable retry attempt for another action's terminal failure could
84448
+ // push an innocent later action to its cap without it ever producing
84449
+ // its own error. The queue re-collects the siblings on the next pass
84450
+ // (bounded: each pass terminally resolves one offender, so there is
84451
+ // no zero-delay storm), and it owns stop state / backoff / retry
84452
+ // budgets — the handler no longer self-drives prefix/suffix retries
84453
+ // (that recursion bypassed stopQueue and could launch a new request
84454
+ // while the original was in flight).
84455
+ await actionErrored({
84456
+ ...offendingAction,
84457
+ error: errorResponse,
84458
+ status: DraftActionStatus.Error,
84459
+ }, false);
84460
+ }
84461
+ return ProcessActionResult.ACTION_ERRORED;
84462
+ }
84463
+ catch (e) {
84464
+ batchLog(`network error ambiguous size=${actions.length}`);
84465
+ const failure = normalizeError$1(e);
84466
+ // A throw here means the request was dispatched but no response was
84467
+ // received — the all-or-none batch may have committed at the origin
84468
+ // with the response lost, and the envelope carries no server-visible
84469
+ // idempotency key. This is commit-AMBIGUOUS: surface the failure to o11y
84470
+ // (the single-action handleAction catch reports every upload error, and
84471
+ // the batch path swallowed them until now — B4; attributed to the head
84472
+ // as a representative, matching the dispatch correlation above), then
84473
+ // return NETWORK_ERROR so the queue surfaces the members for manual
84474
+ // resolution instead of auto-replaying them (Option A).
84475
+ this.reportUploadFailure(failure, actions[0]);
84476
+ return ProcessActionResult.NETWORK_ERROR;
84477
+ }
84478
+ }
84479
+ /**
84480
+ * Retries every given batch member through the durable retry cap
84481
+ * (retryOrCap). Used for whole-batch transient failures (network throw,
84482
+ * misaligned/short/empty response, unattributed rollback, retryable envelope
84483
+ * status) and for the surviving members of a partial failure, so that (a)
84484
+ * every member is parked with backoff before the queue re-arms — not just
84485
+ * the head (C4) — and (b) the durable attempt cap bounds the retries instead
84486
+ * of looping forever (C3). When the cap gate is closed, retryOrCap is a
84487
+ * passthrough that parks each member exactly as before (retry=true).
84488
+ */
84489
+ async retryBatchMembers(members, lastFailure, actionErrored, retryDelayInMs) {
84490
+ for (const member of members) {
84491
+ await this.retryOrCap(member, lastFailure, actionErrored, retryDelayInMs);
84492
+ }
84493
+ }
84494
+ /**
84495
+ * Terminally errors every given batch member (no retry). Used when the batch
84496
+ * envelope is rejected with a deterministic client error (4xx other than
84497
+ * 408/429): retrying an unchanged, server-rejected batch would only be
84498
+ * rejected again, so the drafts are errored for the user to fix or discard —
84499
+ * consistent with the single-action response policy — instead of being
84500
+ * retried forever (C3).
84501
+ */
84502
+ async terminalBatchMembers(members, error, actionErrored) {
84503
+ for (const member of members) {
84504
+ await actionErrored({ ...member, error, status: DraftActionStatus.Error }, false);
84505
+ }
84506
+ }
84507
+ /**
84508
+ * Maps each draft action to a UI-API records/batch operation. Each op has
84509
+ * exactly one record entry; the order of returned ops matches the order of
84510
+ * input actions so per-action result routing works on index alone.
84511
+ *
84512
+ * - method='post' -> CREATE with apiName + fields
84513
+ * - method='patch' -> UPDATE with fields including Id (Id pulled from
84514
+ * urlParams.recordId)
84515
+ * - method='delete' -> DELETE with fields containing only Id
84516
+ *
84517
+ * Each request is re-resolved through resolveResourceRequest first. The
84518
+ * draft-id redirect rewrite that runs when an upstream POST completes only
84519
+ * updates a queued action's basePath/body — NOT its urlParams — so a stored
84520
+ * PATCH/DELETE can still carry a stale draft id in urlParams.recordId at
84521
+ * drain time. resolveResourceRequest re-canonicalizes urlParams.recordId
84522
+ * (and body fields) against the store, and is idempotent when the id is
84523
+ * already canonical, so it is safe to call unconditionally here.
84524
+ */
84525
+ buildBatchOperations(actions) {
84526
+ const operations = [];
84527
+ for (const action of actions) {
84528
+ const request = this.resolveResourceRequest(action.data);
84529
+ const method = request.method;
84530
+ if (method === 'post') {
84531
+ const body = request.body;
84532
+ operations.push({
84533
+ type: 'CREATE',
84534
+ records: [
84535
+ {
84536
+ apiName: body.apiName,
84537
+ fields: { ...body.fields },
84538
+ },
84539
+ ],
84540
+ });
84541
+ }
84542
+ else if (method === 'patch') {
84543
+ const recordId = request.urlParams['recordId'];
84544
+ const body = request.body;
84545
+ operations.push({
84546
+ type: 'UPDATE',
84547
+ records: [
84548
+ {
84549
+ fields: { Id: recordId, ...body.fields },
84550
+ },
84551
+ ],
84552
+ });
84553
+ }
84554
+ else if (method === 'delete') {
84555
+ const recordId = request.urlParams['recordId'];
84556
+ operations.push({
84557
+ type: 'DELETE',
84558
+ records: [
84559
+ {
84560
+ fields: { Id: recordId },
84561
+ },
84562
+ ],
84563
+ });
84564
+ }
84565
+ else {
84566
+ throw Error(`Unsupported method '${method}' in UiApiActionHandler.buildBatchOperations`);
84567
+ }
84568
+ }
84569
+ return operations;
84570
+ }
81680
84571
  async draftActionToSideEffects(action) {
81681
84572
  const method = action.data.method;
81682
84573
  const apiName = action.metadata[LDS_ACTION_METADATA_API_NAME];
@@ -81788,10 +84679,35 @@ class UiApiActionHandler extends AbstractResourceRequestActionHandler {
81788
84679
  getTypeCacheKeys$1u$1(cacheKeys, this.getLuvio(), record);
81789
84680
  return cacheKeys;
81790
84681
  }
81791
- synchronousIngest(response) {
84682
+ synchronousIngest(response, action) {
81792
84683
  const luvio = this.getLuvio();
84684
+ const injected = this.injectedFieldsByActionId.get(action.id);
84685
+ if (injected === undefined) {
84686
+ const key = keyBuilderFromType$g(luvio, response);
84687
+ luvio.storeIngest(key, ingest$1o$1, response);
84688
+ return;
84689
+ }
84690
+ // never replace what the server sent
84691
+ const fields = { ...response.fields };
84692
+ let injectedAny = false;
84693
+ keys$3(injected).forEach((fieldName) => {
84694
+ if (fields[fieldName] === undefined) {
84695
+ fields[fieldName] = injected[fieldName];
84696
+ injectedAny = true;
84697
+ }
84698
+ });
81793
84699
  const key = keyBuilderFromType$g(luvio, response);
81794
- luvio.storeIngest(key, ingest$1o$1, response);
84700
+ luvio.storeIngest(key, ingest$1o$1, { ...response, fields });
84701
+ if (injectedAny) {
84702
+ // after ingestRecord, which publishes the metadata with a fresh ttl
84703
+ luvio.publishStoreMetadata(key, {
84704
+ ttl: TTL$k,
84705
+ representationName: 'RecordRepresentation',
84706
+ namespace: 'UiApi',
84707
+ version: VERSION$1v$1,
84708
+ ingestionTimestamp: STALE_INGESTION_TIMESTAMP,
84709
+ });
84710
+ }
81795
84711
  }
81796
84712
  mergeRequestBody(targetBody, sourceBody) {
81797
84713
  // IMPORTANT: spread operator isn't deep clone so we have to individually
@@ -81861,6 +84777,10 @@ class UiApiDraftRecordService {
81861
84777
  await this.sideEffectStore.removeEffects(operations);
81862
84778
  return new Set(operations.map((o) => o.key));
81863
84779
  }
84780
+ // returns the side effects queued for the given action, oldest first
84781
+ getEffectsForAction(action) {
84782
+ return this.sideEffectStore.getEffectsByTag(action.id);
84783
+ }
81864
84784
  // reads a set of records out of the durable store, re-applies current side
81865
84785
  // effects and persists the changed record
81866
84786
  async reapplyRecordSideEffects(recordKeys, lifecycle) {
@@ -82159,6 +85079,12 @@ class UpdateRecordQuickActionExecutionRepresentationHandler extends AbstractQuic
82159
85079
  const CONTENT_DOCUMENT_DRAFT_ID_KEY = 'CONTENT_DOCUMENT_DRAFT_ID';
82160
85080
  const CONTENT_VERSION_DRAFT_ID_KEY = 'CONTENT_VERSION_DRAFT_ID';
82161
85081
  const CONTENT_DOCUMENT_LINK_DRAFT_ID_KEY = 'CONTENT_DOCUMENT_LINK_DRAFT_ID';
85082
+ /**
85083
+ * Metadata key carrying the LDS binary store URI for the upload's local file.
85084
+ * Set in `buildPendingAction` so the Swift "Pending Images" UI can render
85085
+ * a thumbnail from the local bytes without a JSC round-trip.
85086
+ */
85087
+ const LDS_BINARY_HANDLE_KEY = 'LDS_BINARY_HANDLE';
82162
85088
  const RESPONSE_CONTENT_DOCUMENT_FIELDS = [
82163
85089
  'ContentSize',
82164
85090
  'CreatedDate',
@@ -82410,6 +85336,15 @@ class ContentDocumentCompositeRepresentationActionHandler extends AbstractResour
82410
85336
  return Promise.reject(new DraftSynthesisError('Cannot generate local ContentDocumentLink synthetic id', 'CANNOT_GENERATE_ID'));
82411
85337
  });
82412
85338
  pendingAction.metadata[CONTENT_DOCUMENT_LINK_DRAFT_ID_KEY] = contentDocumentLinkId;
85339
+ // Stash the LDS binary store URI so the Swift "Pending Images" section
85340
+ // can render a thumbnail directly from the local file bytes.
85341
+ try {
85342
+ const fileRef = getFileReferenceFromResourceRequest(request);
85343
+ pendingAction.metadata[LDS_BINARY_HANDLE_KEY] = fileRef.handle;
85344
+ }
85345
+ catch {
85346
+ // No file data — leave metadata unset; UI falls back to placeholder.
85347
+ }
82413
85348
  const objectInfo = await this.objectInfoService.getObjectInfo(CONTENT_VERSION_API_NAME);
82414
85349
  if (objectInfo === undefined) {
82415
85350
  throw Error(`Could not generate draft. Object info is missing`);
@@ -83012,7 +85947,18 @@ function recordLoaderFactory(query) {
83012
85947
  return ids.map((id) => recordMap.get(id) || null);
83013
85948
  }
83014
85949
  }
83015
- return new DataLoader(batchRecordQuery);
85950
+ const loader = new DataLoader(batchRecordQuery);
85951
+ const loads = new Map();
85952
+ return Object.assign(loader, {
85953
+ prefetch(id) {
85954
+ const existing = loads.get(id);
85955
+ if (existing)
85956
+ return existing;
85957
+ const load = loader.load(id).then((record) => ({ record }), (error) => ({ error }));
85958
+ loads.set(id, load);
85959
+ return load;
85960
+ },
85961
+ });
83016
85962
  }
83017
85963
 
83018
85964
  function createContext(store, objectInfos, eventEmitter, settings, snapshot, mappedCursors = new Map(), draftFunctions) {
@@ -83046,6 +85992,8 @@ function createContext(store, objectInfos, eventEmitter, settings, snapshot, map
83046
85992
  draftFunctions,
83047
85993
  mappedCursors,
83048
85994
  missingFieldsMap: new Map(),
85995
+ parentPrefetchEnabled: !parentRelationshipPrefetchKillSwitch.isOpen({ fallback: false }),
85996
+ parentResults: new Map(),
83049
85997
  };
83050
85998
  }
83051
85999
 
@@ -86193,6 +89141,207 @@ async function readPaginationMetadataForKeys(keys, query) {
86193
89141
  return metadataMap;
86194
89142
  }
86195
89143
 
89144
+ function getDirectiveBooleanValue(argument, variables) {
89145
+ if (!argument)
89146
+ return undefined;
89147
+ if (argument.value.kind === Kind$2.BOOLEAN)
89148
+ return argument.value.value;
89149
+ if (argument.value.kind === Kind$2.VARIABLE) {
89150
+ const value = variables[argument.value.name.value];
89151
+ return typeof value === 'boolean' ? value : undefined;
89152
+ }
89153
+ return undefined;
89154
+ }
89155
+ function isSelectionIncluded(selection, variables) {
89156
+ for (const directive of selection.directives || []) {
89157
+ if (directive.name.value !== 'skip' && directive.name.value !== 'include')
89158
+ continue;
89159
+ const argument = (directive.arguments || []).find((argument) => argument.name.value === 'if');
89160
+ const value = getDirectiveBooleanValue(argument, variables);
89161
+ if (value === undefined)
89162
+ return false;
89163
+ if (directive.name.value === 'skip' && value === true)
89164
+ return false;
89165
+ if (directive.name.value === 'include' && value === false)
89166
+ return false;
89167
+ }
89168
+ return true;
89169
+ }
89170
+ function matchesTypeCondition(typeCondition, runtimeType, schema) {
89171
+ if (!typeCondition)
89172
+ return true;
89173
+ const conditionType = schema.getType(typeCondition.name.value);
89174
+ if (!conditionType || conditionType === runtimeType)
89175
+ return conditionType === runtimeType;
89176
+ const kind = conditionType.astNode?.kind;
89177
+ if (kind !== Kind$2.INTERFACE_TYPE_DEFINITION && kind !== Kind$2.UNION_TYPE_DEFINITION)
89178
+ return false;
89179
+ return schema.isSubType(conditionType, runtimeType);
89180
+ }
89181
+ function relationshipId(fieldName, record) {
89182
+ let id;
89183
+ if (fieldName === 'RecordType')
89184
+ id = record.recordTypeId;
89185
+ else if (fieldName.endsWith('__r')) {
89186
+ const relationshipField = record.fields[fieldName.replace('__r', '__c')];
89187
+ id = relationshipField && relationshipField.value;
89188
+ }
89189
+ else {
89190
+ const relationshipField = record.fields[fieldName + 'Id'];
89191
+ id = relationshipField && relationshipField.value;
89192
+ }
89193
+ return typeof id === 'string' && id ? id : null;
89194
+ }
89195
+ function getObjectFields(type) {
89196
+ return isObjectType(type) ? type.getFields() : {};
89197
+ }
89198
+ function getSelectedParentFields(selectionSet, runtimeType, info, visitedFragments) {
89199
+ if (!selectionSet)
89200
+ return [];
89201
+ const selectedParentFields = [];
89202
+ const variables = info.variableValues;
89203
+ for (const selection of selectionSet.selections) {
89204
+ if (!isSelectionIncluded(selection, variables))
89205
+ continue;
89206
+ if (selection.kind === Kind$2.FIELD) {
89207
+ const field = getObjectFields(runtimeType)[selection.name.value];
89208
+ const fieldType = field && getNamedType(field.type);
89209
+ const isParent = fieldType &&
89210
+ ((isObjectType(fieldType) &&
89211
+ fieldType.getInterfaces().some((iface) => iface.name === 'Record')) ||
89212
+ fieldType.name === 'Record');
89213
+ if (isParent &&
89214
+ selection.selectionSet &&
89215
+ !(isObjectType(fieldType) && fieldType.name.endsWith('Connection'))) {
89216
+ selectedParentFields.push({
89217
+ name: selection.name.value,
89218
+ selectionSet: selection.selectionSet,
89219
+ });
89220
+ }
89221
+ }
89222
+ else if (selection.kind === Kind$2.INLINE_FRAGMENT &&
89223
+ matchesTypeCondition(selection.typeCondition, runtimeType, info.schema)) {
89224
+ selectedParentFields.push(...getSelectedParentFields(selection.selectionSet, runtimeType, info, visitedFragments));
89225
+ }
89226
+ else if (selection.kind === Kind$2.FRAGMENT_SPREAD) {
89227
+ const fragment = info.fragments[selection.name.value];
89228
+ if (!fragment ||
89229
+ visitedFragments.has(fragment.name.value) ||
89230
+ !matchesTypeCondition(fragment.typeCondition, runtimeType, info.schema))
89231
+ continue;
89232
+ visitedFragments.add(fragment.name.value);
89233
+ selectedParentFields.push(...getSelectedParentFields(fragment.selectionSet, runtimeType, info, visitedFragments));
89234
+ }
89235
+ }
89236
+ return selectedParentFields;
89237
+ }
89238
+ function getActiveFieldSelectionSets(selectionSet, fieldName, runtimeType, info, visitedFragments) {
89239
+ if (!selectionSet)
89240
+ return [];
89241
+ const fieldSelectionSets = [];
89242
+ const variables = info.variableValues;
89243
+ for (const selection of selectionSet.selections) {
89244
+ if (!isSelectionIncluded(selection, variables))
89245
+ continue;
89246
+ if (selection.kind === Kind$2.FIELD &&
89247
+ selection.name.value === fieldName &&
89248
+ selection.selectionSet)
89249
+ fieldSelectionSets.push(selection.selectionSet);
89250
+ else if (selection.kind === Kind$2.INLINE_FRAGMENT &&
89251
+ matchesTypeCondition(selection.typeCondition, runtimeType, info.schema)) {
89252
+ fieldSelectionSets.push(...getActiveFieldSelectionSets(selection.selectionSet, fieldName, runtimeType, info, visitedFragments));
89253
+ }
89254
+ else if (selection.kind === Kind$2.FRAGMENT_SPREAD) {
89255
+ const fragment = info.fragments[selection.name.value];
89256
+ if (!fragment ||
89257
+ visitedFragments.has(fragment.name.value) ||
89258
+ !matchesTypeCondition(fragment.typeCondition, runtimeType, info.schema))
89259
+ continue;
89260
+ visitedFragments.add(fragment.name.value);
89261
+ fieldSelectionSets.push(...getActiveFieldSelectionSets(fragment.selectionSet, fieldName, runtimeType, info, visitedFragments));
89262
+ }
89263
+ }
89264
+ return fieldSelectionSets;
89265
+ }
89266
+ async function prefetchParentBatch(parents, state) {
89267
+ const { info, context, parentOutcomes, visitedSelections } = state;
89268
+ const nextParents = [];
89269
+ const parentLoads = [];
89270
+ for (const parentWork of parents) {
89271
+ let selectionsByType = visitedSelections.get(parentWork.record.recordRepresentation.id);
89272
+ if (!selectionsByType)
89273
+ visitedSelections.set(parentWork.record.recordRepresentation.id, (selectionsByType = new Map()));
89274
+ let visitedSelectionSets = selectionsByType.get(parentWork.runtimeType);
89275
+ if (!visitedSelectionSets)
89276
+ selectionsByType.set(parentWork.runtimeType, (visitedSelectionSets = new Set()));
89277
+ if (visitedSelectionSets.has(parentWork.selectionSet))
89278
+ continue;
89279
+ visitedSelectionSets.add(parentWork.selectionSet);
89280
+ for (const parentField of getSelectedParentFields(parentWork.selectionSet, parentWork.runtimeType, info, new Set())) {
89281
+ const parentId = relationshipId(parentField.name, parentWork.record.recordRepresentation);
89282
+ if (!parentId)
89283
+ continue;
89284
+ const knownOutcome = parentOutcomes.get(parentId) || context.parentResults.get(parentId);
89285
+ if (knownOutcome && 'error' in knownOutcome)
89286
+ continue;
89287
+ const parentLoad = context.Record.prefetch(parentId);
89288
+ parentLoads.push(parentLoad.then((outcome) => {
89289
+ parentOutcomes.set(parentId, outcome);
89290
+ if ('error' in outcome || outcome.record === null)
89291
+ return;
89292
+ const parentRecord = outcome.record;
89293
+ const declaredParentType = getNamedType(getObjectFields(parentWork.runtimeType)[parentField.name].type);
89294
+ const recordInterface = declaredParentType.name === 'Record' &&
89295
+ declaredParentType.astNode?.kind === Kind$2.INTERFACE_TYPE_DEFINITION
89296
+ ? declaredParentType
89297
+ : undefined;
89298
+ const resolveRecordType = recordInterface?.resolveType;
89299
+ const resolvedParentType = resolveRecordType
89300
+ ? resolveRecordType({
89301
+ recordRepresentation: parentRecord,
89302
+ ingestionTimestamp: parentWork.record.ingestionTimestamp,
89303
+ }, context, info)
89304
+ : declaredParentType;
89305
+ const parentRuntimeType = typeof resolvedParentType === 'string'
89306
+ ? info.schema.getType(resolvedParentType)
89307
+ : resolvedParentType;
89308
+ if (!isObjectType(parentRuntimeType))
89309
+ return;
89310
+ nextParents.push({
89311
+ record: {
89312
+ recordRepresentation: parentRecord,
89313
+ ingestionTimestamp: parentWork.record.ingestionTimestamp,
89314
+ },
89315
+ selectionSet: parentField.selectionSet,
89316
+ runtimeType: parentRuntimeType,
89317
+ });
89318
+ }));
89319
+ }
89320
+ }
89321
+ await Promise.all(parentLoads);
89322
+ if (nextParents.length > 0)
89323
+ await prefetchParentBatch(nextParents, state);
89324
+ }
89325
+ async function prefetchSelectedParents(records, info, context) {
89326
+ const parentOutcomes = new Map();
89327
+ const connectionType = getNamedType(info.returnType);
89328
+ const edgeType = getNamedType(getObjectFields(connectionType).edges?.type);
89329
+ const nodeType = getNamedType(getObjectFields(edgeType).node?.type);
89330
+ if (!isObjectType(connectionType) || !isObjectType(edgeType) || !isObjectType(nodeType)) {
89331
+ return parentOutcomes;
89332
+ }
89333
+ const edgeSelectionSets = info.fieldNodes.flatMap((node) => getActiveFieldSelectionSets(node.selectionSet, 'edges', connectionType, info, new Set()));
89334
+ const nodeSelectionSets = edgeSelectionSets.flatMap((selectionSet) => getActiveFieldSelectionSets(selectionSet, 'node', edgeType, info, new Set()));
89335
+ if (records.length > 0 && nodeSelectionSets.length > 0) {
89336
+ await prefetchParentBatch(records.flatMap((record) => nodeSelectionSets.map((selectionSet) => ({
89337
+ record,
89338
+ selectionSet,
89339
+ runtimeType: nodeType,
89340
+ }))), { info, context, parentOutcomes, visitedSelections: new Map() });
89341
+ }
89342
+ return parentOutcomes;
89343
+ }
89344
+
86196
89345
  /*
86197
89346
  resolves connections...
86198
89347
  */
@@ -86316,6 +89465,11 @@ async function connectionResolver(obj, args, context, info) {
86316
89465
  earlyEnd,
86317
89466
  };
86318
89467
  });
89468
+ if (context.parentPrefetchEnabled) {
89469
+ const results = await prefetchSelectedParents(records, info, context);
89470
+ for (const [id, outcome] of results)
89471
+ context.parentResults.set(id, outcome);
89472
+ }
86319
89473
  return {
86320
89474
  records,
86321
89475
  hasNextPage,
@@ -86588,7 +89742,8 @@ function addResolversToSchema(schema, polyFields) {
86588
89742
  .getInterfaces()
86589
89743
  .find((iface) => iface.name === 'Record')) ||
86590
89744
  (recordFieldType && recordFieldType.name === 'Record')) {
86591
- field.resolve = async function relationResolver(obj, _args, { Record, seenRecordIds }) {
89745
+ field.resolve = function relationResolver(obj, _args, context) {
89746
+ const { Record, seenRecordIds, parentPrefetchEnabled } = context;
86592
89747
  const fetchRecordOrNull = async (key) => {
86593
89748
  const recordRepresentation = await Record.load(key);
86594
89749
  return recordRepresentation !== null
@@ -86599,29 +89754,22 @@ function addResolversToSchema(schema, polyFields) {
86599
89754
  : null;
86600
89755
  };
86601
89756
  const { recordRepresentation: record, ingestionTimestamp } = obj;
86602
- let id = undefined;
86603
- if (field.name === 'RecordType') {
86604
- // RecordTypeId has special handling during ingest and is
86605
- // not in record.fields, so check for it at the UIAPI root property location
86606
- id = record.recordTypeId;
86607
- }
86608
- else if (field.name.endsWith('__r')) {
86609
- // Custom relationships end in `__r` and the corresponding ID field should be `__c`
86610
- let fieldName = field.name.replace('__r', '__c');
86611
- id = record.fields[fieldName] && record.fields[fieldName].value;
86612
- }
86613
- else {
86614
- // Standard relationships are just FieldNameId
86615
- let fieldName = field.name + 'Id';
86616
- id = record.fields[fieldName] && record.fields[fieldName].value;
86617
- }
86618
- if (!id || typeof id !== 'string') {
86619
- // possibly field injection did not inject the necessary Id field
86620
- // for the relationship, or we found a non-string value.
86621
- return null;
89757
+ const id = relationshipId(field.name, record);
89758
+ if (!id) {
89759
+ return parentPrefetchEnabled ? null : Promise.resolve(null);
86622
89760
  }
86623
89761
  seenRecordIds.add(id);
86624
- return fetchRecordOrNull(id);
89762
+ if (!parentPrefetchEnabled)
89763
+ return fetchRecordOrNull(id);
89764
+ const prefetched = context.parentResults.get(id);
89765
+ if (!prefetched)
89766
+ return fetchRecordOrNull(id);
89767
+ if ('error' in prefetched)
89768
+ return Promise.reject(prefetched.error);
89769
+ const recordRepresentation = prefetched.record;
89770
+ return recordRepresentation === null
89771
+ ? null
89772
+ : { recordRepresentation, ingestionTimestamp };
86625
89773
  };
86626
89774
  }
86627
89775
  else if (isObjectType(recordFieldType) &&
@@ -90753,6 +93901,12 @@ function instrumentDraftQueue(queue) {
90753
93901
  case DraftQueueEventType.ActionUpdated:
90754
93902
  reportDraftActionEvent('updated', draftQueueEvent.draftCount);
90755
93903
  break;
93904
+ case DraftQueueEventType.BatchRecoveryFailed:
93905
+ {
93906
+ const recoveryEvent = draftQueueEvent;
93907
+ reportBatchRecoveryFailure(recoveryEvent.reason, recoveryEvent.draftCount);
93908
+ }
93909
+ break;
90756
93910
  }
90757
93911
  return Promise.resolve();
90758
93912
  });
@@ -90801,14 +93955,38 @@ const draftQueueInstrumentation = {
90801
93955
 
90802
93956
  // so eslint doesn't complain about nimbus
90803
93957
  /* global __nimbus */
90804
- function buildLdsDraftQueue(durableStore) {
93958
+ /**
93959
+ * Builds the regular draft queue.
93960
+ *
93961
+ * One-arg call = legacy plain DurableDraftQueue (unchanged shipped behavior);
93962
+ * the 3-arg call builds a PerformanceDraftQueue (serial, concurrency=1, cross-queue
93963
+ * coordination + optional batching) over the shared store for the gate-ON path.
93964
+ */
93965
+ function buildLdsDraftQueue(durableStore, draftStore, options) {
90805
93966
  if (typeof __nimbus !== 'undefined' &&
90806
93967
  __nimbus.plugins !== undefined &&
90807
93968
  __nimbus.plugins.LdsDraftQueue !== undefined) {
90808
93969
  return new NimbusDraftQueue();
90809
93970
  }
90810
- const useSnapshot = draftQueueStoreSnapshotGate.isOpen({ fallback: true });
90811
- const draftQueue = new DurableDraftQueue(new DurableDraftStore(durableStore, draftQueueInstrumentation, useSnapshot), draftQueueInstrumentation);
93971
+ // Legacy gate-OFF path: no shared store / options → today's plain queue.
93972
+ if (draftStore === undefined && options === undefined) {
93973
+ const useSnapshot = draftQueueStoreSnapshotGate.isOpen({ fallback: true });
93974
+ const draftQueue = new DurableDraftQueue(new DurableDraftStore(durableStore, draftQueueInstrumentation, useSnapshot), draftQueueInstrumentation);
93975
+ return instrumentDraftQueue(draftQueue);
93976
+ }
93977
+ // Performance Upload path: serial queue participating in cross-queue coordination.
93978
+ const store = draftStore ?? new DurableDraftStore(durableStore);
93979
+ const draftQueue = new PerformanceDraftQueue(store, options);
93980
+ return instrumentDraftQueue(draftQueue);
93981
+ }
93982
+ /**
93983
+ * Builds the Performance Upload draft queue (ContentDocument/Version/Link
93984
+ * multipart uploads, concurrency > 1). Only constructed on the gate-ON path, in the
93985
+ * worker realm.
93986
+ */
93987
+ function buildLdsPerformanceDraftQueue(durableStore, draftStore, options) {
93988
+ const store = draftStore ?? new DurableDraftStore(durableStore);
93989
+ const draftQueue = new PerformanceDraftQueue(store, options);
90812
93990
  return instrumentDraftQueue(draftQueue);
90813
93991
  }
90814
93992
 
@@ -95734,7 +98912,10 @@ let NetworkCommand$1 = class NetworkCommand extends BaseCommand {
95734
98912
  if (isSubscribableResult(newResult)) {
95735
98913
  const value = newResult.isOk() ? ok$1(newResult.value.data) : err$1(newResult.error.failure);
95736
98914
  this.subscriptions.forEach((cb) => {
95737
- cb(value);
98915
+ try {
98916
+ cb(value);
98917
+ } catch {
98918
+ }
95738
98919
  });
95739
98920
  }
95740
98921
  return ok$1(void 0);
@@ -96676,7 +99857,10 @@ class NetworkCommand extends BaseCommand {
96676
99857
  if (isSubscribableResult(newResult)) {
96677
99858
  const value = newResult.isOk() ? ok$1(newResult.value.data) : err$1(newResult.error.failure);
96678
99859
  this.subscriptions.forEach((cb) => {
96679
- cb(value);
99860
+ try {
99861
+ cb(value);
99862
+ } catch {
99863
+ }
96680
99864
  });
96681
99865
  }
96682
99866
  return ok$1(void 0);
@@ -96698,6 +99882,79 @@ function applyOperationRequestOptions$1(init, operationType, requestOptions) {
96698
99882
  }
96699
99883
  };
96700
99884
  }
99885
+ function fetchFromNetwork(getRequest, hooks, contextSeed) {
99886
+ try {
99887
+ const {
99888
+ services,
99889
+ params: [input, init],
99890
+ operationType,
99891
+ requestOptions
99892
+ } = getRequest();
99893
+ const initWithOptions = applyOperationRequestOptions$1(init, operationType, requestOptions);
99894
+ const seed = operationType === void 0 ? contextSeed : { operationType, ...contextSeed };
99895
+ const initWithSeed = seed === void 0 ? initWithOptions : { ...initWithOptions, __contextSeed: seed };
99896
+ const fetchCall = initWithSeed === void 0 ? services.fetch(input) : services.fetch(input, initWithSeed);
99897
+ return convertFetchResponseToData(fetchCall, hooks);
99898
+ } catch (reason) {
99899
+ return resolvedPromiseLike$2(err$1(toError(reason)));
99900
+ }
99901
+ }
99902
+ function convertFetchResponseToData(response, hooks) {
99903
+ return response.then(
99904
+ (response2) => {
99905
+ if (response2.ok) {
99906
+ let resultPromise;
99907
+ if (hooks.isSemanticNullResponse(response2)) {
99908
+ resultPromise = Promise.resolve(ok$1(null));
99909
+ } else if (hooks.isProtocolNoBodyStatus(response2.status) && !hooks.isSemanticNullResponse(response2)) {
99910
+ resultPromise = Promise.resolve(
99911
+ err$1(
99912
+ toError(
99913
+ `Unexpected ${response2.status} response: no-content status was not declared in the API specification. Declare this response in your OAS without a content property.`
99914
+ )
99915
+ )
99916
+ );
99917
+ } else {
99918
+ resultPromise = response2.json().then(
99919
+ (json) => ok$1(json),
99920
+ (reason) => err$1(toError(reason))
99921
+ );
99922
+ }
99923
+ return resultPromise.finally(() => {
99924
+ try {
99925
+ hooks.afterRequestHooks({ statusCode: response2.status });
99926
+ } catch {
99927
+ }
99928
+ });
99929
+ } else {
99930
+ return hooks.coerceError(response2).then((coercedError) => {
99931
+ return err$1(coercedError);
99932
+ }).finally(() => {
99933
+ try {
99934
+ hooks.afterRequestHooks({ statusCode: response2.status });
99935
+ } catch {
99936
+ }
99937
+ });
99938
+ }
99939
+ },
99940
+ (reason) => err$1(toError(reason))
99941
+ );
99942
+ }
99943
+ function completeRefresh(result, getSubscriptions, options = {}) {
99944
+ const { previousResult } = options;
99945
+ if (!options.suppressEqualSuccesses || result.isErr() || !previousResult || previousResult.isErr() || !deepEquals$1(previousResult.value, result.value)) {
99946
+ const subscriptions = getSubscriptions();
99947
+ const callbacks = options.skipRemovedSubscriptions ? [...subscriptions] : subscriptions;
99948
+ callbacks.forEach((callback) => {
99949
+ if (options.skipRemovedSubscriptions && !getSubscriptions().includes(callback)) return;
99950
+ try {
99951
+ callback(result);
99952
+ } catch {
99953
+ }
99954
+ });
99955
+ }
99956
+ return options.reportFailure && result.isErr() ? result : ok$1(void 0);
99957
+ }
96701
99958
  function isAbortableCommand(command) {
96702
99959
  return !!command && typeof command === "object" && "isAbortableCommand" in command && command.isAbortableCommand === true;
96703
99960
  }
@@ -96726,18 +99983,18 @@ function createAbortableDecorator(command, options) {
96726
99983
  }
96727
99984
  if (prop === "fetchParams") {
96728
99985
  const originalFetchParams = Reflect.get(target, prop, receiver);
96729
- const isInternal = Reflect.get(target, "isInternalExecution", receiver) === true;
99986
+ const suppressAbortSignal = Reflect.get(target, "isInternalExecution", receiver) === true;
96730
99987
  if (typeof originalFetchParams === "function") {
96731
99988
  return function(...args) {
96732
99989
  const originalReturnValue = originalFetchParams.apply(this, args);
96733
- if (!isInternal) {
99990
+ if (!suppressAbortSignal) {
96734
99991
  const [url, init = {}] = originalReturnValue;
96735
99992
  return [url, { signal, ...init }];
96736
99993
  }
96737
99994
  return originalReturnValue;
96738
99995
  };
96739
99996
  } else {
96740
- if (!isInternal && originalFetchParams && Array.isArray(originalFetchParams)) {
99997
+ if (!suppressAbortSignal && originalFetchParams && Array.isArray(originalFetchParams)) {
96741
99998
  const [url, init = {}] = originalFetchParams;
96742
99999
  return [url, { signal, ...init }];
96743
100000
  }
@@ -96764,38 +100021,35 @@ const _FetchNetworkCommand = class _FetchNetworkCommand extends NetworkCommand {
96764
100021
  this.services = services;
96765
100022
  this.additionalNullResponses = [];
96766
100023
  }
96767
- /**
96768
- * The command's operation type. Generated command subclasses override this
96769
- * getter with their concrete operation (see the generator's
96770
- * `generateOperationType`); the base returns `undefined` for commands that
96771
- * don't declare one (e.g. hand-written commands). Transport-layer
96772
- * interceptors use it to tell a read (`query`) apart from a write — see
96773
- * `fetch`, which folds it into the context seed and derives
96774
- * `onestore.retrySafe` from it.
96775
- */
96776
100024
  get operationType() {
96777
100025
  return void 0;
96778
100026
  }
96779
- /** Declared `onestore` options (`handleServerError`, `compression`); generated overrides (`generateRequestOptionsGetter`). */
96780
100027
  get requestOptions() {
96781
100028
  return void 0;
96782
100029
  }
96783
100030
  fetch(contextSeed) {
96784
- try {
96785
- const [input, init] = this.fetchParams;
96786
- const operationType = this.operationType;
96787
- const initWithOptions = applyOperationRequestOptions$1(
96788
- init,
96789
- operationType,
96790
- this.requestOptions
96791
- );
96792
- const seed = operationType === void 0 ? contextSeed : { operationType, ...contextSeed };
96793
- const initWithSeed = seed === void 0 ? initWithOptions : { ...initWithOptions, __contextSeed: seed };
96794
- const fetchCall = initWithSeed === void 0 ? this.services.fetch(input) : this.services.fetch(input, initWithSeed);
96795
- return this.convertFetchResponseToData(fetchCall);
96796
- } catch (reason) {
96797
- return resolvedPromiseLike$2(err$1(toError(reason)));
96798
- }
100031
+ return fetchFromNetwork(
100032
+ () => ({
100033
+ services: this.services,
100034
+ params: this.fetchParams,
100035
+ operationType: this.operationType,
100036
+ requestOptions: this.requestOptions
100037
+ }),
100038
+ {
100039
+ isSemanticNullResponse: (response) => this.isSemanticNullResponse(response),
100040
+ isProtocolNoBodyStatus: (status) => this.isProtocolNoBodyStatus(status),
100041
+ coerceError: (response) => this.coerceError(response),
100042
+ afterRequestHooks: (options) => this.afterRequestHooks(options)
100043
+ },
100044
+ contextSeed
100045
+ );
100046
+ }
100047
+ refresh() {
100048
+ return this.execute().then((newResult) => {
100049
+ if (!isSubscribableResult(newResult)) return ok$1(void 0);
100050
+ const result = newResult.isOk() ? ok$1(newResult.value.data) : err$1(newResult.error.failure);
100051
+ return completeRefresh(result, () => this.subscriptions);
100052
+ });
96799
100053
  }
96800
100054
  isSemanticNullResponse(response) {
96801
100055
  return this.additionalNullResponses.includes(response.status);
@@ -96803,53 +100057,9 @@ const _FetchNetworkCommand = class _FetchNetworkCommand extends NetworkCommand {
96803
100057
  isProtocolNoBodyStatus(status) {
96804
100058
  return status === 204 || status === 205;
96805
100059
  }
96806
- isUndeclaredNoBodyResponse(response) {
96807
- return this.isProtocolNoBodyStatus(response.status) && !this.isSemanticNullResponse(response);
96808
- }
96809
100060
  async coerceError(errorResponse) {
96810
100061
  return toError(errorResponse.statusText);
96811
100062
  }
96812
- convertFetchResponseToData(response) {
96813
- return response.then(
96814
- (response2) => {
96815
- if (response2.ok) {
96816
- let resultPromise;
96817
- if (this.isSemanticNullResponse(response2)) {
96818
- resultPromise = Promise.resolve(ok$1(null));
96819
- } else if (this.isUndeclaredNoBodyResponse(response2)) {
96820
- resultPromise = Promise.resolve(
96821
- err$1(
96822
- toError(
96823
- `Unexpected ${response2.status} response: no-content status was not declared in the API specification. Declare this response in your OAS without a content property.`
96824
- )
96825
- )
96826
- );
96827
- } else {
96828
- resultPromise = response2.json().then(
96829
- (json) => ok$1(json),
96830
- (reason) => err$1(toError(reason))
96831
- );
96832
- }
96833
- return resultPromise.finally(() => {
96834
- try {
96835
- this.afterRequestHooks({ statusCode: response2.status });
96836
- } catch {
96837
- }
96838
- });
96839
- } else {
96840
- return this.coerceError(response2).then((coercedError) => {
96841
- return err$1(coercedError);
96842
- }).finally(() => {
96843
- try {
96844
- this.afterRequestHooks({ statusCode: response2.status });
96845
- } catch {
96846
- }
96847
- });
96848
- }
96849
- },
96850
- (reason) => err$1(toError(reason))
96851
- );
96852
- }
96853
100063
  };
96854
100064
  _FetchNetworkCommand.availableDecorators = {
96855
100065
  abortable: createAbortableDecorator
@@ -97913,7 +101123,7 @@ function buildServiceDescriptor$b(luvio) {
97913
101123
  },
97914
101124
  };
97915
101125
  }
97916
- // version: 1.466.0-b7b08a8e3c
101126
+ // version: 1.468.0-9ef0fbdaee
97917
101127
 
97918
101128
  /**
97919
101129
  * Copyright (c) 2022, Salesforce, Inc.,
@@ -97939,7 +101149,7 @@ function buildServiceDescriptor$a(notifyRecordUpdateAvailable, getNormalizedLuvi
97939
101149
  },
97940
101150
  };
97941
101151
  }
97942
- // version: 1.466.0-b7b08a8e3c
101152
+ // version: 1.468.0-9ef0fbdaee
97943
101153
 
97944
101154
  function findExecutableOperation$1(input) {
97945
101155
  const operations = input.query.definitions.filter(
@@ -98802,6 +102012,53 @@ function buildBaseImperativeInvoker(getCommand, transformResult) {
98802
102012
  }
98803
102013
  };
98804
102014
  }
102015
+ function assertSubscribable(result, report) {
102016
+ if (!isSubscribableResult(result)) {
102017
+ report("Non-subscribable result encountered - please use correct operation type");
102018
+ throw new Error("Internal error in Lightning Data Service adapter occurred");
102019
+ }
102020
+ }
102021
+ function toQueryUpdate(result) {
102022
+ return result.isOk() ? { data: result.value, error: void 0 } : { data: void 0, error: unwrapUserlandError(result.error) };
102023
+ }
102024
+ class SubscribableImperativeBindingsService {
102025
+ bind(getCommand, exposeRefresh = false) {
102026
+ return buildBaseImperativeInvoker(
102027
+ getCommand,
102028
+ (result) => this.transformResult(result, exposeRefresh)
102029
+ );
102030
+ }
102031
+ transformResult(result, exposeRefresh = false) {
102032
+ assertSubscribable(result, (message) => console.error(message));
102033
+ if (result.isErr()) throwUnwrappedUserlandError(result.error.failure);
102034
+ deepFreeze(result.value.data);
102035
+ const api = {
102036
+ data: result.value.data,
102037
+ subscribe: (callback) => result.value.subscribe((update) => {
102038
+ const { data, error } = toQueryUpdate(update);
102039
+ return callback({ data, error });
102040
+ })
102041
+ };
102042
+ if (exposeRefresh) {
102043
+ return {
102044
+ ...api,
102045
+ refresh: () => result.value.refresh().then((refreshed) => {
102046
+ if (refreshed.isOk()) return void 0;
102047
+ throw refreshed.error;
102048
+ })
102049
+ };
102050
+ }
102051
+ return api;
102052
+ }
102053
+ }
102054
+ function buildServiceDescriptor$7() {
102055
+ return {
102056
+ type: "subscribableImperativeBindings",
102057
+ version: "1.0",
102058
+ service: new SubscribableImperativeBindingsService()
102059
+ };
102060
+ }
102061
+
98805
102062
  const supportedCachePolicyTypes = ["no-cache", "only-if-cached"];
98806
102063
  function requestContextIsSupportedCachePolicy(requestContext) {
98807
102064
  return typeof requestContext === "object" && requestContext !== null && "cachePolicy" in requestContext && typeof requestContext.cachePolicy === "object" && requestContext.cachePolicy !== null && "type" in requestContext.cachePolicy && typeof requestContext.cachePolicy.type === "string" && supportedCachePolicyTypes.includes(
@@ -98854,7 +102111,7 @@ class DefaultImperativeBindingsService {
98854
102111
  });
98855
102112
  }
98856
102113
  }
98857
- function buildServiceDescriptor$6() {
102114
+ function buildServiceDescriptor$5() {
98858
102115
  return {
98859
102116
  type: "defaultImperativeBindings",
98860
102117
  version: "1.0",
@@ -98874,70 +102131,13 @@ class QueryImperativeBindingsService {
98874
102131
  });
98875
102132
  }
98876
102133
  }
98877
- function buildServiceDescriptor$5() {
102134
+ function buildServiceDescriptor$4() {
98878
102135
  return {
98879
102136
  type: "queryImperativeBindings",
98880
102137
  version: "1.0",
98881
102138
  service: new QueryImperativeBindingsService()
98882
102139
  };
98883
102140
  }
98884
- class SubscribableImperativeBindingsService {
98885
- bind(getCommand, exposeRefresh = false) {
98886
- return buildBaseImperativeInvoker(
98887
- getCommand,
98888
- (result) => this.transformResult(result, exposeRefresh)
98889
- );
98890
- }
98891
- transformResult(result, exposeRefresh = false) {
98892
- if (!isSubscribableResult(result)) {
98893
- console.error(
98894
- "Non-subscribable result encountered - please use correct operation type"
98895
- );
98896
- throw new Error("Internal error in Lightning Data Service adapter occurred");
98897
- }
98898
- if (result.isOk()) {
98899
- deepFreeze(result.value.data);
98900
- const api = {
98901
- data: result.value.data,
98902
- subscribe: (cb) => {
98903
- return result.value.subscribe((result2) => {
98904
- if (result2.isErr()) {
98905
- return cb({
98906
- data: void 0,
98907
- error: unwrapUserlandError(result2.error)
98908
- });
98909
- }
98910
- return cb({ data: result2.value, error: void 0 });
98911
- });
98912
- }
98913
- };
98914
- if (exposeRefresh) {
98915
- return {
98916
- ...api,
98917
- refresh: () => {
98918
- return result.value.refresh().then((res) => {
98919
- if (res.isOk()) {
98920
- return void 0;
98921
- }
98922
- throw res.error;
98923
- });
98924
- }
98925
- };
98926
- } else {
98927
- return api;
98928
- }
98929
- } else {
98930
- throwUnwrappedUserlandError(result.error.failure);
98931
- }
98932
- }
98933
- }
98934
- function buildServiceDescriptor$4() {
98935
- return {
98936
- type: "subscribableImperativeBindings",
98937
- version: "1.0",
98938
- service: new SubscribableImperativeBindingsService()
98939
- };
98940
- }
98941
102141
  class LegacyImperativeBindingsService {
98942
102142
  bind(getCommand) {
98943
102143
  const invoke = async (config, requestContext, callback) => {
@@ -99239,7 +102439,7 @@ class GraphQLMutationBindingsService {
99239
102439
  };
99240
102440
  }
99241
102441
  }
99242
- function buildServiceDescriptor$7() {
102442
+ function buildServiceDescriptor$6() {
99243
102443
  return {
99244
102444
  type: "graphQLMutationBindings",
99245
102445
  version: "1.0",
@@ -100387,13 +103587,13 @@ function initializeOneStore(sqliteStore, luvio) {
100387
103587
  featureFlagsServiceDescriptor,
100388
103588
  buildServiceDescriptor$b(luvio),
100389
103589
  luvioUiapiRecordsServiceDescriptor,
100390
- buildServiceDescriptor$6(),
100391
103590
  buildServiceDescriptor$5(),
100392
103591
  buildServiceDescriptor$4(),
103592
+ buildServiceDescriptor$7(),
100393
103593
  buildServiceDescriptor$3(),
100394
103594
  buildServiceDescriptor$2$1(),
100395
103595
  buildServiceDescriptor$1$2(),
100396
- buildServiceDescriptor$7(),
103596
+ buildServiceDescriptor$6(),
100397
103597
  buildServiceDescriptor$1$1(),
100398
103598
  buildServiceDescriptor$2(),
100399
103599
  // TODO [W-18753648]: See note above.
@@ -100449,7 +103649,13 @@ function queryAsync(sqliteStore, sql, params) {
100449
103649
  // so eslint doesn't complain about nimbus
100450
103650
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
100451
103651
  /* global __nimbus */
103652
+ const DRAFT_QUEUE_LANE = {
103653
+ serial: 'serial',
103654
+ concurrentBinary: 'concurrentBinary',
103655
+ };
100452
103656
  let lazyDraftQueue;
103657
+ let lazyBinaryDraftQueue;
103658
+ let lazyRoutingDraftQueue;
100453
103659
  let lazyDraftManager;
100454
103660
  let lazyLuvio;
100455
103661
  let lazyInternalLuvio;
@@ -100538,8 +103744,44 @@ function getRuntime({ primeUser = false } = {}) {
100538
103744
  return false;
100539
103745
  },
100540
103746
  });
100541
- // draft queue
100542
- lazyDraftQueue = buildLdsDraftQueue(lazyDurableStore);
103747
+ // Concurrent binary draft queue configuration is supplied by the native
103748
+ // host on globalThis.LDS, injected before the lds bundle loads so the
103749
+ // runtime sees the Performance Upload Queue levers at startup.
103750
+ const concurrentBinaryQueueConfig = (typeof globalThis !== 'undefined' &&
103751
+ globalThis.LDS) ||
103752
+ {};
103753
+ const useConcurrentBinaryQueue = concurrentBinaryQueueConfig.enableConcurrentBinaryQueue === true;
103754
+ const useQueueBatching = concurrentBinaryQueueConfig.enableDraftBatching === true;
103755
+ const usePerformanceDraftQueue = useConcurrentBinaryQueue || useQueueBatching;
103756
+ if (usePerformanceDraftQueue) {
103757
+ const sharedDraftStore = new PerformanceDraftStore(lazyDurableStore);
103758
+ logInfo(`Serial draft queue created with batching=${useQueueBatching}, strictOrdering=true`);
103759
+ lazyDraftQueue = buildLdsDraftQueue(lazyDurableStore, sharedDraftStore, {
103760
+ // No peer queues when there's no binary lane (batching-only).
103761
+ getPeerQueues: () => lazyBinaryDraftQueue !== undefined ? [lazyBinaryDraftQueue] : [],
103762
+ concurrency: 1,
103763
+ strictOrdering: true,
103764
+ queueBatching: useQueueBatching,
103765
+ });
103766
+ // The binary lane lives only in the worker realm. In the WebView realm the serial
103767
+ // queue is a Nimbus proxy to the worker's routing façade, which already routes
103768
+ // binary uploads to the worker's binary lane; a local binary queue there would
103769
+ // take uploads the worker never sees. Only worker-realm queues expose
103770
+ // isHandlerRegistered.
103771
+ if (useConcurrentBinaryQueue &&
103772
+ typeof lazyDraftQueue.isHandlerRegistered ===
103773
+ 'function') {
103774
+ lazyBinaryDraftQueue = buildLdsPerformanceDraftQueue(lazyDurableStore, sharedDraftStore, {
103775
+ getPeerQueues: () => [lazyDraftQueue],
103776
+ concurrency: 4,
103777
+ strictOrdering: false,
103778
+ });
103779
+ logInfo('Concurrent draft queue created with concurrency=4, strictOrdering=false');
103780
+ }
103781
+ }
103782
+ else {
103783
+ lazyDraftQueue = buildLdsDraftQueue(lazyDurableStore);
103784
+ }
100543
103785
  lazySideEffectStore = new SideEffectStore(lazyDurableStore);
100544
103786
  lazySideEffectService = new SideEffectService(lazyDurableRecordStore, lazyObjectInfoService, userId, formatDisplayValue);
100545
103787
  const draftService = new UiApiDraftRecordService(() => lazyLuvio, lazyDurableStore, lazyObjectInfoService, newRecordId, lazySideEffectStore, lazyDurableRecordStore);
@@ -100547,13 +103789,40 @@ function getRuntime({ primeUser = false } = {}) {
100547
103789
  uiApiRecordHandler.setSideEffectHooks(createSfsSideEffectHooks(lazyDurableRecordStore));
100548
103790
  const quickActionHandler = new QuickActionExecutionRepresentationHandler(() => lazyLuvio, draftService, lazyDraftQueue, lazyNetworkAdapter, isGenerated, lazySideEffectService, lazyObjectInfoService, getRecord);
100549
103791
  const updateRecordQuickActionHandler = new UpdateRecordQuickActionExecutionRepresentationHandler(() => lazyLuvio, draftService, lazyDraftQueue, lazyNetworkAdapter, isGenerated, lazySideEffectService);
100550
- const contentDocumentCompositeActionHandler = new ContentDocumentCompositeRepresentationActionHandler(() => lazyLuvio, userId, draftService, lazyObjectInfoService, lazyDraftQueue, lazyNetworkAdapter, isGenerated, NimbusBinaryStore, lazySideEffectService);
103792
+ // Route the CD composite to the concurrent binary queue when gate-ON so
103793
+ // multipart uploads run in parallel and don't block other DML.
103794
+ const contentDocumentTargetQueue = lazyBinaryDraftQueue ?? lazyDraftQueue;
103795
+ logInfo(`ContentDocumentVersion handler routed to ${lazyBinaryDraftQueue !== undefined ? 'concurrent draft queue' : 'serial draft queue'}`);
103796
+ const contentDocumentCompositeActionHandler = new ContentDocumentCompositeRepresentationActionHandler(() => lazyLuvio, userId, draftService, lazyObjectInfoService, contentDocumentTargetQueue, lazyNetworkAdapter, isGenerated, NimbusBinaryStore, lazySideEffectService);
100551
103797
  lazyDraftQueue.addHandler(uiApiRecordHandler);
100552
103798
  lazyDraftQueue.addHandler(quickActionHandler);
100553
103799
  lazyDraftQueue.addHandler(updateRecordQuickActionHandler);
100554
- lazyDraftQueue.addHandler(contentDocumentCompositeActionHandler);
100555
- // draft manager
100556
- lazyDraftManager = new DraftManager(lazyDraftQueue);
103800
+ contentDocumentTargetQueue.addHandler(contentDocumentCompositeActionHandler);
103801
+ // Build the routing façade and manager only when the binary lane exists: flag on and
103802
+ // in the worker realm, over concrete, handler-owning queues (see where it is built).
103803
+ // Feature-detecting isHandlerRegistered rather than sniffing the realm via `window` tests
103804
+ // the exact invariant the queue casts below rely on, and stays correct in non-browser
103805
+ // (e.g. jest) realms where `window` is an unreliable realm signal.
103806
+ const shouldBuildRoutingQueue = lazyBinaryDraftQueue !== undefined;
103807
+ if (shouldBuildRoutingQueue) {
103808
+ // Serial is the primary/fallback lane, so the bridged `draftQueue` routes enqueue
103809
+ // by handler ownership in the worker. Lane labels are stamped onto each action
103810
+ // by getQueueActions so the WebView can display the two queues separately.
103811
+ lazyRoutingDraftQueue = new RoutingDraftQueue({
103812
+ queue: lazyDraftQueue,
103813
+ lane: DRAFT_QUEUE_LANE.serial,
103814
+ }, [
103815
+ {
103816
+ queue: lazyBinaryDraftQueue,
103817
+ lane: DRAFT_QUEUE_LANE.concurrentBinary,
103818
+ },
103819
+ ]);
103820
+ }
103821
+ // The WebView realm's (unused) manager falls back to the base DraftManager: native only
103822
+ // drives the worker-realm manager, and RoutingDraftManager needs the concrete queues.
103823
+ lazyDraftManager = shouldBuildRoutingQueue
103824
+ ? new RoutingDraftManager(lazyDraftQueue, lazyBinaryDraftQueue, DRAFT_QUEUE_LANE)
103825
+ : new DraftManager(lazyDraftQueue);
100557
103826
  const objectInfoCachingEnvironment = enableObjectInfoCaching(durableEnv, lazyObjectInfoService.ensureObjectInfoCached);
100558
103827
  let draftEnv = makeEnvironmentUiApiRecordDraftAware(lazyLuvio, {
100559
103828
  store,
@@ -100628,7 +103897,7 @@ function getRuntime({ primeUser = false } = {}) {
100628
103897
  internalLuvio: lazyInternalLuvio,
100629
103898
  environment: lazyEnvironment,
100630
103899
  draftManager: lazyDraftManager,
100631
- draftQueue: lazyDraftQueue,
103900
+ draftQueue: lazyRoutingDraftQueue ?? lazyDraftQueue,
100632
103901
  createPrimingSession: (config) => {
100633
103902
  return primingSessionFactory({
100634
103903
  store: lazyDurableStore,
@@ -100654,7 +103923,7 @@ register$1({
100654
103923
  id: '@salesforce/lds-network-adapter',
100655
103924
  instrument: instrument$2,
100656
103925
  });
100657
- // version: 1.466.0-b7b08a8e3c
103926
+ // version: 1.468.0-9ef0fbdaee
100658
103927
 
100659
103928
  const { create: create$2, keys: keys$2 } = Object;
100660
103929
  const { stringify, parse } = JSON;
@@ -108204,7 +111473,7 @@ function registerCallback(cb) {
108204
111473
  cb(graphql_v1_import, graphql_imperative$1, graphql_imperative_legacy_v1_import, graphql_state_manager);
108205
111474
  }
108206
111475
  }
108207
- // version: 1.466.0-ba624febc8
111476
+ // version: 1.468.0-c060a9b9c6
108208
111477
 
108209
111478
  function createFragmentMap(documentNode) {
108210
111479
  const fragments = {};
@@ -137433,7 +140702,7 @@ register$1({
137433
140702
  configuration: { ...configurationForGraphQLAdapters$1 },
137434
140703
  instrument: instrument$1,
137435
140704
  });
137436
- // version: 1.466.0-ba624febc8
140705
+ // version: 1.468.0-c060a9b9c6
137437
140706
 
137438
140707
  // On core the unstable adapters are re-exported with different names,
137439
140708
  // we want to match them here.
@@ -137585,7 +140854,7 @@ withDefaultLuvio((luvio) => {
137585
140854
  unstable_graphQL_imperative = createImperativeAdapter(luvio, createInstrumentedAdapter(ldsAdapter, adapterMetadata), adapterMetadata);
137586
140855
  graphQLImperative = ldsAdapter;
137587
140856
  });
137588
- // version: 1.466.0-ba624febc8
140857
+ // version: 1.468.0-c060a9b9c6
137589
140858
 
137590
140859
  var gqlApi = /*#__PURE__*/Object.freeze({
137591
140860
  __proto__: null,
@@ -138437,7 +141706,7 @@ const callbacks$1 = [];
138437
141706
  function register(r) {
138438
141707
  callbacks$1.forEach((callback) => callback(r));
138439
141708
  }
138440
- // version: 1.466.0-b7b08a8e3c
141709
+ // version: 1.468.0-9ef0fbdaee
138441
141710
 
138442
141711
  /**
138443
141712
  * Copyright (c) 2022, Salesforce, Inc.,
@@ -139726,4 +142995,4 @@ const { luvio } = getRuntime();
139726
142995
  setDefaultLuvio({ luvio });
139727
142996
 
139728
142997
  export { createPrimingSession, draftManager, draftQueue, evictCacheRecordsByIds, evictExpiredCacheEntries, executeAdapter, executeMutatingAdapter, getImperativeAdapterNames, importLuvioAdapterModule, importOneStoreAdapterModule, invokeAdapter, invokeAdapterWithDraftToMerge, invokeAdapterWithDraftToMergeAndMetadata, invokeAdapterWithDraftToReplace, invokeAdapterWithMetadata, nimbusDraftQueue, registerReportObserver, setMetadataTTL, setUiApiRecordTTL, stopEviction, subscribeToAdapter };
139729
- // version: 1.466.0-b7b08a8e3c
142998
+ // version: 1.468.0-9ef0fbdaee