@salesforce/lds-runtime-mobile 1.451.0-dev2 → 1.452.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -20,6 +20,7 @@ import { setupInstrumentation, instrumentAdapter as instrumentAdapter$1, instrum
20
20
  import { HttpStatusCode as HttpStatusCode$1, setBypassDeepFreeze, StoreKeySet, StringKeyInMemoryStore, Reader, serializeStructuredKey, deepFreeze as deepFreeze$1, emitAdapterEvent, ingestShape, coerceConfig as coerceConfig$1, typeCheckConfig as typeCheckConfig$h, createResourceParams as createResourceParams$h, StoreKeyMap, buildNetworkSnapshotCachePolicy as buildNetworkSnapshotCachePolicy$f, resolveLink, createCustomAdapterEventEmitter, isFileReference, Environment, Luvio, InMemoryStore } from '@luvio/engine';
21
21
  import { isSupportedEntity, configuration, getObjectInfoAdapterFactory, RECORD_ID_PREFIX, RECORD_FIELDS_KEY_JUNCTION, isStoreKeyRecordViewEntity, extractRecordIdFromStoreKey, buildRecordRepKeyFromId, keyBuilderRecord, RecordRepresentationTTL, keyBuilderQuickActionExecutionRepresentation, ingestQuickActionExecutionRepresentation, getRecordId18 as getRecordId18$1, getRecordsAdapterFactory as getRecordsAdapterFactory$1, RecordRepresentationRepresentationType, ObjectInfoRepresentationType, getObjectInfosAdapterFactory, getObjectInfoDirectoryAdapterFactory, UiApiNamespace, RecordRepresentationType, RecordRepresentationVersion } from '@salesforce/lds-adapters-uiapi';
22
22
  import draftQueueMaxRetryAttemptsGate from '@salesforce/gate/lmr.draft-queue-max-retry-attempts';
23
+ import draftQueueRedirectScanGate from '@salesforce/gate/lmr.draft-queue-redirect-scan';
23
24
  import rotateIdempotencyKeyOn400Killswitch from '@salesforce/gate/lds.idempotency-key-on-400-killswitch';
24
25
  import { getInstrumentation, idleDetector } from 'o11y/client';
25
26
  import allowUpdatesForNonCachedRecords from '@salesforce/gate/lmr.allowUpdatesForNonCachedRecords';
@@ -30,6 +31,7 @@ import FIRST_DAY_OF_WEEK from '@salesforce/i18n/firstDayOfWeek';
30
31
  import { entityFetchedGraphqlMetadataSchema, entityFetchedMetadataSchema } from 'o11y_schema/sf_lightningsdk';
31
32
  import graphqQueryFieldLimit from '@salesforce/gate/lmr.graphqQueryFieldLimit';
32
33
  import graphqlPartialEmitParity from '@salesforce/gate/lmr.graphqlPartialEmitParity';
34
+ import draftQueueStoreSnapshotGate from '@salesforce/gate/lmr.draft-queue-store-snapshot';
33
35
  import { pdpEventSchema } from 'o11y_schema/sf_pdp';
34
36
  import { instrument as instrument$1 } from '@salesforce/lds-bindings';
35
37
  import ldsAdapterO11yLoggingGate from '@salesforce/gate/lmr.ldsAdapterO11yLogging';
@@ -49,6 +51,7 @@ import useOneStore from '@salesforce/gate/lmr.useOneStore';
49
51
  import { setServices } from '@conduit-client/service-provisioner/v1';
50
52
  import '@conduit-client/type-normalization/v1';
51
53
  import { Kind as Kind$2, visit as visit$2, print as print$1, resolveAndValidateGraphQLConfig, toGraphQLErrorResponse } from '@conduit-client/onestore-graphql-parser/v1';
54
+ import draftQueueSideEffectMapGate from '@salesforce/gate/lmr.draft-queue-sideeffect-map';
52
55
  import productConsumedSideEffects from '@salesforce/gate/com.salesforce.fieldservice.vanStockLDSBypass256';
53
56
  import reviveOnlyRequestedFields from '@salesforce/gate/lmr.reviveOnlyRequestedFields';
54
57
 
@@ -2362,7 +2365,7 @@ class DurableDraftQueue {
2362
2365
  }
2363
2366
  return handler;
2364
2367
  }
2365
- constructor(draftStore) {
2368
+ constructor(draftStore, instrumentation) {
2366
2369
  this.retryIntervalMilliseconds = 0;
2367
2370
  this.minimumRetryInterval = 250;
2368
2371
  this.maximumRetryInterval = 32000;
@@ -2379,6 +2382,7 @@ class DurableDraftQueue {
2379
2382
  this.handlers = {};
2380
2383
  this.draftStore = draftStore;
2381
2384
  this.workerPool = new AsyncWorkerPool(1);
2385
+ this.instrumentation = instrumentation;
2382
2386
  }
2383
2387
  addHandler(handler) {
2384
2388
  const id = handler.handlerId;
@@ -2469,15 +2473,22 @@ class DurableDraftQueue {
2469
2473
  this.state = DraftQueueState.Stopped;
2470
2474
  }
2471
2475
  async getQueueActions() {
2476
+ // W-23515682: time the whole call, including this queue's own internal
2477
+ // this.getQueueActions() during a drain — the amplification signal an external wrapper
2478
+ // cannot see. Measure-only; control flow below is unchanged.
2479
+ const { instrumentation } = this;
2480
+ const start = instrumentation?.onGetQueueActions !== undefined ? Date.now() : 0;
2472
2481
  const drafts = (await this.draftStore.getAllDrafts());
2473
2482
  const queue = [];
2474
2483
  drafts.forEach((draft) => {
2475
2484
  if (draft.id === this.uploadingActionId) {
2476
- draft.status = DraftActionStatus.Uploading;
2485
+ queue.push({ ...draft, status: DraftActionStatus.Uploading });
2486
+ }
2487
+ else {
2488
+ queue.push(draft);
2477
2489
  }
2478
- queue.push(draft);
2479
2490
  });
2480
- return queue.sort((a, b) => {
2491
+ const sorted = queue.sort((a, b) => {
2481
2492
  const aTime = parseInt(a.id, 10);
2482
2493
  const bTime = parseInt(b.id, 10);
2483
2494
  // safety check
@@ -2489,6 +2500,8 @@ class DurableDraftQueue {
2489
2500
  }
2490
2501
  return aTime - bTime;
2491
2502
  });
2503
+ instrumentation?.onGetQueueActions?.(Date.now() - start, sorted.length);
2504
+ return sorted;
2492
2505
  }
2493
2506
  async enqueue(handlerId, data, observabilityContext) {
2494
2507
  return this.workerPool.push({
@@ -2637,6 +2650,8 @@ class DurableDraftQueue {
2637
2650
  const listener = draftQueueChangedListeners[i];
2638
2651
  results.push(listener(event));
2639
2652
  }
2653
+ // W-23515682: listener fan-out per event type. Measure-only.
2654
+ this.instrumentation?.onNotifyChangedListeners?.(event.type, draftQueueLen);
2640
2655
  await Promise.all(results);
2641
2656
  }
2642
2657
  /**
@@ -2884,36 +2899,66 @@ function buildDraftDurableStoreKey(recordKey, draftActionId) {
2884
2899
  *
2885
2900
  */
2886
2901
  class DurableDraftStore {
2887
- constructor(durableStore) {
2902
+ constructor(durableStore, instrumentation, useSnapshot = true) {
2888
2903
  this.draftStore = {};
2904
+ // Snapshot: holds deep clones of each draft, refreshed at write time.
2905
+ // getAllDrafts serves from this snapshot with zero deep clones per read.
2906
+ this.cleanSnapshot = {};
2889
2907
  // queue of writes that were made during the initial sync
2890
2908
  this.writeQueue = [];
2891
2909
  this.durableStore = durableStore;
2910
+ this.instrumentation = instrumentation;
2911
+ this.useSnapshot = useSnapshot;
2892
2912
  this.resyncDraftStore();
2893
2913
  }
2914
+ // When no instrumentation is injected the promise is returned as-is (zero overhead).
2915
+ timeDurableOp(op, segment, run) {
2916
+ const onDurableOperation = this.instrumentation?.onDurableOperation;
2917
+ if (onDurableOperation === undefined) {
2918
+ return run();
2919
+ }
2920
+ const start = Date.now();
2921
+ return run().finally(() => {
2922
+ onDurableOperation(op, segment, Date.now() - start);
2923
+ });
2924
+ }
2894
2925
  writeAction(action) {
2895
2926
  const addAction = () => {
2896
2927
  const { id, tag } = action;
2897
2928
  this.draftStore[id] = action;
2929
+ this.cleanSnapshot[id] = clone$1(action);
2898
2930
  const durableEntryKey = buildDraftDurableStoreKey(tag, id);
2899
2931
  const entry = {
2900
2932
  data: action,
2901
2933
  };
2902
2934
  const entries = { [durableEntryKey]: entry };
2903
- return this.durableStore.setEntries(entries, DRAFT_SEGMENT);
2935
+ return this.timeDurableOp('setEntries', DRAFT_SEGMENT, () => this.durableStore.setEntries(entries, DRAFT_SEGMENT));
2904
2936
  };
2905
2937
  return this.enqueueAction(addAction);
2906
2938
  }
2907
2939
  getAllDrafts() {
2908
2940
  const waitForOngoingSync = this.syncPromise || Promise.resolve();
2909
2941
  return waitForOngoingSync.then(() => {
2910
- const { draftStore } = this;
2911
- const keys$1 = keys$4(draftStore);
2912
2942
  const actionArray = [];
2913
- for (let i = 0, len = keys$1.length; i < len; i++) {
2914
- const key = keys$1[i];
2915
- // clone draft so we don't expose the internal draft store
2916
- actionArray.push(clone$1(draftStore[key]));
2943
+ if (this.useSnapshot) {
2944
+ const { cleanSnapshot } = this;
2945
+ const keys$1 = keys$4(cleanSnapshot);
2946
+ for (let i = 0, len = keys$1.length; i < len; i++) {
2947
+ const key = keys$1[i];
2948
+ // Return shallow spread of snapshot entries so top-level caller mutations
2949
+ // (e.g., .status =) don't persist into the snapshot. The snapshot itself
2950
+ // is a deep clone of draftStore, so no nested mutation reaches draftStore.
2951
+ actionArray.push({ ...cleanSnapshot[key] });
2952
+ }
2953
+ }
2954
+ else {
2955
+ // Killswitch (W-23515682): original pre-fix behavior — deep-clone every draft on
2956
+ // every read so the internal draft store is never exposed. O(N) clones per read.
2957
+ const { draftStore } = this;
2958
+ const keys$1 = keys$4(draftStore);
2959
+ for (let i = 0, len = keys$1.length; i < len; i++) {
2960
+ actionArray.push(clone$1(draftStore[keys$1[i]]));
2961
+ }
2917
2962
  }
2918
2963
  return actionArray;
2919
2964
  });
@@ -2923,8 +2968,9 @@ class DurableDraftStore {
2923
2968
  const draft = this.draftStore[id];
2924
2969
  if (draft !== undefined) {
2925
2970
  delete this.draftStore[id];
2971
+ delete this.cleanSnapshot[id];
2926
2972
  const durableKey = buildDraftDurableStoreKey(draft.tag, draft.id);
2927
- return this.durableStore.evictEntries([durableKey], DRAFT_SEGMENT);
2973
+ return this.timeDurableOp('evictEntries', DRAFT_SEGMENT, () => this.durableStore.evictEntries([durableKey], DRAFT_SEGMENT));
2928
2974
  }
2929
2975
  return Promise.resolve();
2930
2976
  };
@@ -2940,10 +2986,11 @@ class DurableDraftStore {
2940
2986
  const action = draftStore[key];
2941
2987
  if (action.tag === tag) {
2942
2988
  delete draftStore[action.id];
2989
+ delete this.cleanSnapshot[action.id];
2943
2990
  durableKeys.push(buildDraftDurableStoreKey(action.tag, action.id));
2944
2991
  }
2945
2992
  }
2946
- return this.durableStore.evictEntries(durableKeys, DRAFT_SEGMENT);
2993
+ return this.timeDurableOp('evictEntries', DRAFT_SEGMENT, () => this.durableStore.evictEntries(durableKeys, DRAFT_SEGMENT));
2947
2994
  };
2948
2995
  return this.enqueueAction(deleteAction);
2949
2996
  }
@@ -2957,6 +3004,7 @@ class DurableDraftStore {
2957
3004
  const action = draftStore[operation.id];
2958
3005
  if (action !== undefined) {
2959
3006
  delete draftStore[operation.id];
3007
+ delete this.cleanSnapshot[operation.id];
2960
3008
  const key = buildDraftDurableStoreKey(action.tag, action.id);
2961
3009
  durableStoreOperations.push({
2962
3010
  ids: [key],
@@ -2969,6 +3017,7 @@ class DurableDraftStore {
2969
3017
  const { action } = operation;
2970
3018
  const key = buildDraftDurableStoreKey(action.tag, action.id);
2971
3019
  draftStore[action.id] = action;
3020
+ this.cleanSnapshot[action.id] = clone$1(action);
2972
3021
  durableStoreOperations.push({
2973
3022
  type: 'setEntries',
2974
3023
  segment: DRAFT_SEGMENT,
@@ -2980,7 +3029,7 @@ class DurableDraftStore {
2980
3029
  });
2981
3030
  }
2982
3031
  }
2983
- return this.durableStore.batchOperations(durableStoreOperations);
3032
+ return this.timeDurableOp('batchOperations', DRAFT_SEGMENT, () => this.durableStore.batchOperations(durableStoreOperations));
2984
3033
  };
2985
3034
  return this.enqueueAction(action);
2986
3035
  }
@@ -3023,6 +3072,7 @@ class DurableDraftStore {
3023
3072
  .then((durableEntries) => {
3024
3073
  if (durableEntries === undefined) {
3025
3074
  this.draftStore = {};
3075
+ this.cleanSnapshot = {};
3026
3076
  return this.runQueuedOperations();
3027
3077
  }
3028
3078
  const { draftStore } = this;
@@ -3039,6 +3089,7 @@ class DurableDraftStore {
3039
3089
  }
3040
3090
  }
3041
3091
  draftStore[action.id] = action;
3092
+ this.cleanSnapshot[action.id] = clone$1(action);
3042
3093
  }
3043
3094
  return this.runQueuedOperations();
3044
3095
  })
@@ -42277,6 +42328,14 @@ const DRAFT_QUEUE_TOTAL_MERGE_ACTIONS_CALLS = 'draft-queue-total-mergeActions-ca
42277
42328
  // Used to triage the WOLI duplicate-record investigation in Splunk — correlate by draftId
42278
42329
  // with the other draft-queue events. @W-23428669
42279
42330
  const DRAFT_QUEUE_IDEMPOTENCY_KEY_ROTATED = 'draft-queue-idempotency-key-rotated';
42331
+ // Hotspot-attribution telemetry (@W-23515682). Operational counts/durations only — no
42332
+ // draft content, field values, or server record ids. These attribute the offline-drain wall-clock
42333
+ // to the O(N^2) hotspots (getQueueActions amplification, listener fan-out, durable-op latency).
42334
+ const DRAFT_QUEUE_GET_QUEUE_ACTIONS_COUNT = 'draft-queue-get-queue-actions-count';
42335
+ const DRAFT_QUEUE_GET_QUEUE_ACTIONS_DURATION = 'draft-queue-get-queue-actions-duration';
42336
+ const DRAFT_QUEUE_NOTIFY_CHANGED_LISTENERS_COUNT = 'draft-queue-notify-changed-listeners-count';
42337
+ const DRAFT_QUEUE_DURABLE_OP_COUNT = 'draft-queue-durable-op-count';
42338
+ const DRAFT_QUEUE_DURABLE_OP_DURATION = 'draft-queue-durable-op-duration';
42280
42339
  /** Content Document */
42281
42340
  const CREATE_CONTENT_DOCUMENT_AND_VERSION_TOTAL_SYNTHESIZE_CALLS = 'content-document-version-total-synthesize-calls';
42282
42341
  const CREATE_CONTENT_DOCUMENT_AND_VERSION_DRAFT_SYNTHESIZE_ERROR = 'create-content-document-version-draft-synthesize-error';
@@ -42369,6 +42428,33 @@ function reportDraftActionEvent(state, draftCount, message) {
42369
42428
  break;
42370
42429
  }
42371
42430
  }
42431
+ /**
42432
+ * @W-23515682: count (amplification — queue re-reads per drain) + duration histogram (the
42433
+ * pre/post signal for the getAllDrafts clone-on-write fix). actionCount is queue depth, never content.
42434
+ */
42435
+ function reportDraftQueueGetQueueActions(durationMs, actionCount) {
42436
+ const depthBuckets = [1, 2, 5, 10, 20, 50, 100, 250, 500];
42437
+ ldsMobileInstrumentation.incrementCounter(DRAFT_QUEUE_GET_QUEUE_ACTIONS_COUNT);
42438
+ ldsMobileInstrumentation.trackValue(DRAFT_QUEUE_GET_QUEUE_ACTIONS_DURATION, durationMs);
42439
+ ldsMobileInstrumentation.bucketValue(DRAFT_QUEUE_GET_QUEUE_ACTIONS_COUNT + '-depth', actionCount, depthBuckets);
42440
+ }
42441
+ /**
42442
+ * @W-23515682: count per event type — listener fan-out / amplification per event.
42443
+ */
42444
+ function reportDraftQueueNotifyChangedListeners(eventType, listenerCount) {
42445
+ ldsMobileInstrumentation.incrementCounter(DRAFT_QUEUE_NOTIFY_CHANGED_LISTENERS_COUNT, listenerCount, undefined, { eventType });
42446
+ }
42447
+ /**
42448
+ * @W-23515682: count + duration histogram by operation kind + segment — attributes the
42449
+ * client-CPU-vs-durable split of per-action processing time.
42450
+ */
42451
+ function reportDraftQueueDurableOperation(op, segment, durationMs) {
42452
+ ldsMobileInstrumentation.incrementCounter(DRAFT_QUEUE_DURABLE_OP_COUNT, 1, undefined, {
42453
+ op,
42454
+ segment,
42455
+ });
42456
+ ldsMobileInstrumentation.trackValue(DRAFT_QUEUE_DURABLE_OP_DURATION, durationMs);
42457
+ }
42372
42458
  /**
42373
42459
  * Reports an exception thrown while uploading a draft action. The thrown error is
42374
42460
  * otherwise swallowed by the upload handler's retry path, leaving the failure
@@ -42852,14 +42938,45 @@ class AbstractResourceRequestActionHandler {
42852
42938
  let updatedActionTargetId = undefined;
42853
42939
  const { tag: queueActionTag, data: queueActionRequest, id: queueActionId, } = queueAction;
42854
42940
  let { basePath, body } = queueActionRequest;
42941
+ // Literal indexOf pre-check (no RegExp coercion, unlike the old String.search) +
42942
+ // a replace RegExp built only when a reference is present — the win over the old scan.
42855
42943
  let stringifiedBody = stringify$5(body);
42944
+ // Read the killswitch once for the whole scan (W-23515682) — avoids a
42945
+ // per-redirect gate lookup in this hot loop.
42946
+ const useOptimizedScan = draftQueueRedirectScanGate.isOpen({ fallback: true });
42856
42947
  // for each redirected ID/key we loop over the operation to see if it needs
42857
42948
  // to be updated
42858
42949
  for (const { draftId, draftKey, canonicalId, canonicalKey } of redirects) {
42859
- if (basePath.search(draftId) >= 0 || stringifiedBody.search(draftId) >= 0) {
42860
- basePath = basePath.replace(new RegExp(draftId, 'g'), canonicalId);
42861
- stringifiedBody = stringifiedBody.replace(new RegExp(draftId, 'g'), canonicalId);
42862
- queueOperationMutated = true;
42950
+ if (useOptimizedScan) {
42951
+ // basePath AND body are checked independently: a queued action can carry
42952
+ // the draft id in either or BOTH, and each occurrence must be rewritten.
42953
+ const basePathNeedsUpdate = basePath.indexOf(draftId) >= 0;
42954
+ const bodyNeedsUpdate = stringifiedBody.indexOf(draftId) >= 0;
42955
+ if (basePathNeedsUpdate || bodyNeedsUpdate) {
42956
+ // Escape metacharacters so the draft id matches literally. Salesforce
42957
+ // ids carry no regex metacharacters today, so this is defensive against
42958
+ // a future id-format change silently corrupting unrelated ids. W-23515682.
42959
+ const escapedDraftId = draftId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
42960
+ const replaceRegex = new RegExp(escapedDraftId, 'g');
42961
+ if (basePathNeedsUpdate) {
42962
+ basePath = basePath.replace(replaceRegex, canonicalId);
42963
+ }
42964
+ if (bodyNeedsUpdate) {
42965
+ stringifiedBody = stringifiedBody.replace(replaceRegex, canonicalId);
42966
+ }
42967
+ queueOperationMutated = true;
42968
+ }
42969
+ }
42970
+ else {
42971
+ // Killswitch closed (W-23515682): original pre-fix scan — String.search
42972
+ // (coerces draftId to a RegExp) + a raw (unescaped) replace RegExp applied
42973
+ // to basePath and body together.
42974
+ if (basePath.search(draftId) >= 0 ||
42975
+ stringifiedBody.search(draftId) >= 0) {
42976
+ basePath = basePath.replace(new RegExp(draftId, 'g'), canonicalId);
42977
+ stringifiedBody = stringifiedBody.replace(new RegExp(draftId, 'g'), canonicalId);
42978
+ queueOperationMutated = true;
42979
+ }
42863
42980
  }
42864
42981
  // if the action is performed on a previous draft id, we need to replace the action
42865
42982
  // with a new one at the updated canonical key
@@ -52438,6 +52555,20 @@ function obtainFailureMessage(error) {
52438
52555
  }
52439
52556
  }
52440
52557
 
52558
+ // Mobile impl of the shared DraftQueueInstrumentation seam (@W-23515682). Forwards to
52559
+ // ldsMobileInstrumentation via metrics.ts; injected at construction (DraftQueueFactory).
52560
+ const draftQueueInstrumentation = {
52561
+ onGetQueueActions(durationMs, actionCount) {
52562
+ reportDraftQueueGetQueueActions(durationMs, actionCount);
52563
+ },
52564
+ onNotifyChangedListeners(eventType, listenerCount) {
52565
+ reportDraftQueueNotifyChangedListeners(eventType, listenerCount);
52566
+ },
52567
+ onDurableOperation(op, segment, durationMs) {
52568
+ reportDraftQueueDurableOperation(op, segment, durationMs);
52569
+ },
52570
+ };
52571
+
52441
52572
  // so eslint doesn't complain about nimbus
52442
52573
  /* global __nimbus */
52443
52574
  function buildLdsDraftQueue(durableStore) {
@@ -52446,7 +52577,8 @@ function buildLdsDraftQueue(durableStore) {
52446
52577
  __nimbus.plugins.LdsDraftQueue !== undefined) {
52447
52578
  return new NimbusDraftQueue();
52448
52579
  }
52449
- const draftQueue = new DurableDraftQueue(new DurableDraftStore(durableStore));
52580
+ const useSnapshot = draftQueueStoreSnapshotGate.isOpen({ fallback: true });
52581
+ const draftQueue = new DurableDraftQueue(new DurableDraftStore(durableStore, draftQueueInstrumentation, useSnapshot), draftQueueInstrumentation);
52450
52582
  return instrumentDraftQueue(draftQueue);
52451
52583
  }
52452
52584
 
@@ -55925,18 +56057,34 @@ class SideEffectStore {
55925
56057
  this.initialize();
55926
56058
  }
55927
56059
  async addEffects(effects) {
56060
+ const incremental = draftQueueSideEffectMapGate.isOpen({ fallback: true });
55928
56061
  const durableEntries = {};
55929
56062
  effects.forEach((effect) => {
55930
56063
  durableEntries[effect.uniqueId] = { data: effect };
55931
56064
  this.allEffects.set(effect.uniqueId, effect);
56065
+ if (incremental) {
56066
+ this.addEffectToKeyMap(effect);
56067
+ }
55932
56068
  });
55933
- this.regenerateKeyToEffectMap();
56069
+ // Killswitch (W-23515682): closed → original O(N) full rebuild on every add.
56070
+ if (!incremental) {
56071
+ this.regenerateKeyToEffectMap();
56072
+ }
55934
56073
  await this.durableStore.setEntries(durableEntries, SIDE_EFFECT_SEGMENT);
55935
56074
  }
55936
56075
  async removeEffects(effects) {
56076
+ const incremental = draftQueueSideEffectMapGate.isOpen({ fallback: true });
55937
56077
  const effectKeys = effects.map((e) => e.uniqueId);
55938
- effects.forEach((k) => this.allEffects.delete(k.uniqueId));
55939
- this.regenerateKeyToEffectMap();
56078
+ effects.forEach((effect) => {
56079
+ this.allEffects.delete(effect.uniqueId);
56080
+ if (incremental) {
56081
+ this.removeEffectFromKeyMap(effect);
56082
+ }
56083
+ });
56084
+ // Killswitch (W-23515682): closed → original O(N) full rebuild on every remove.
56085
+ if (!incremental) {
56086
+ this.regenerateKeyToEffectMap();
56087
+ }
55940
56088
  await this.durableStore.evictEntries(effectKeys, SIDE_EFFECT_SEGMENT);
55941
56089
  }
55942
56090
  getEffects(key) {
@@ -55971,6 +56119,39 @@ class SideEffectStore {
55971
56119
  });
55972
56120
  this.regenerateKeyToEffectMap();
55973
56121
  }
56122
+ addEffectToKeyMap(effect) {
56123
+ const recordEffects = this.keyToEffectMap.get(effect.key);
56124
+ if (recordEffects) {
56125
+ // Effects are keyed by uniqueId (mirroring allEffects.set / regenerateKeyToEffectMap):
56126
+ // a re-add of the same uniqueId must REPLACE the resident entry in place, not append a
56127
+ // duplicate. The action-completion replay path (setSideEffectsForActions on still-queued
56128
+ // actions) re-adds resident effects with no preceding remove; without this dedup a
56129
+ // record-field-increment would sit in the array twice and be applied twice (e.g. a
56130
+ // ProductConsumed QuantityOnHand -=5 becomes -=10). W-23515682.
56131
+ const index = recordEffects.findIndex((e) => e.uniqueId === effect.uniqueId);
56132
+ if (index !== -1) {
56133
+ recordEffects[index] = effect;
56134
+ }
56135
+ else {
56136
+ recordEffects.push(effect);
56137
+ }
56138
+ }
56139
+ else {
56140
+ this.keyToEffectMap.set(effect.key, [effect]);
56141
+ }
56142
+ }
56143
+ removeEffectFromKeyMap(effect) {
56144
+ const recordEffects = this.keyToEffectMap.get(effect.key);
56145
+ if (recordEffects) {
56146
+ const index = recordEffects.findIndex((e) => e.uniqueId === effect.uniqueId);
56147
+ if (index !== -1) {
56148
+ recordEffects.splice(index, 1);
56149
+ if (recordEffects.length === 0) {
56150
+ this.keyToEffectMap.delete(effect.key);
56151
+ }
56152
+ }
56153
+ }
56154
+ }
55974
56155
  regenerateKeyToEffectMap() {
55975
56156
  this.keyToEffectMap.clear();
55976
56157
  this.allEffects.forEach((effect) => {
@@ -59137,7 +59318,7 @@ function buildServiceDescriptor$b(luvio) {
59137
59318
  },
59138
59319
  };
59139
59320
  }
59140
- // version: 1.451.0-dev2-a5cd560c12
59321
+ // version: 1.452.0-bcd113b7fd
59141
59322
 
59142
59323
  /**
59143
59324
  * Copyright (c) 2022, Salesforce, Inc.,
@@ -59163,7 +59344,7 @@ function buildServiceDescriptor$a(notifyRecordUpdateAvailable, getNormalizedLuvi
59163
59344
  },
59164
59345
  };
59165
59346
  }
59166
- // version: 1.451.0-dev2-a5cd560c12
59347
+ // version: 1.452.0-bcd113b7fd
59167
59348
 
59168
59349
  function findExecutableOperation(input) {
59169
59350
  const operations = input.query.definitions.filter(
@@ -61875,4 +62056,4 @@ register({
61875
62056
  });
61876
62057
 
61877
62058
  export { O11Y_NAMESPACE_LDS_MOBILE, getRuntime, ingest$1o as ingestDenormalizedRecordRepresentation, initializeOneStore, registerReportObserver, reportGraphqlQueryParseError };
61878
- // version: 1.451.0-dev2-a5cd560c12
62059
+ // version: 1.452.0-bcd113b7fd
@@ -10,5 +10,7 @@ export declare class SideEffectStore {
10
10
  getEffects(key: string): SideEffect[];
11
11
  getEffectsByTag(tag: string): SideEffect[];
12
12
  private initialize;
13
+ private addEffectToKeyMap;
14
+ private removeEffectFromKeyMap;
13
15
  private regenerateKeyToEffectMap;
14
16
  }
@@ -0,0 +1,2 @@
1
+ import type { DraftQueueInstrumentation } from '@salesforce/lds-drafts';
2
+ export declare const draftQueueInstrumentation: DraftQueueInstrumentation;
@@ -10,6 +10,20 @@ export declare function reportGraphqlAdapterError(errorCode: string): void;
10
10
  export declare function reportGraphqlQueryInstrumentation(data: QueryInstrumentation): void;
11
11
  export declare function incrementGraphQLRefreshUndfined(): void;
12
12
  export declare function reportDraftActionEvent(state: 'added' | 'uploading' | 'completed' | 'deleted' | 'updated' | 'failed', draftCount: number, message?: string): void;
13
+ /**
14
+ * @W-23515682: count (amplification — queue re-reads per drain) + duration histogram (the
15
+ * pre/post signal for the getAllDrafts clone-on-write fix). actionCount is queue depth, never content.
16
+ */
17
+ export declare function reportDraftQueueGetQueueActions(durationMs: number, actionCount: number): void;
18
+ /**
19
+ * @W-23515682: count per event type — listener fan-out / amplification per event.
20
+ */
21
+ export declare function reportDraftQueueNotifyChangedListeners(eventType: string, listenerCount: number): void;
22
+ /**
23
+ * @W-23515682: count + duration histogram by operation kind + segment — attributes the
24
+ * client-CPU-vs-durable split of per-action processing time.
25
+ */
26
+ export declare function reportDraftQueueDurableOperation(op: string, segment: string, durationMs: number): void;
13
27
  /**
14
28
  * Reports an exception thrown while uploading a draft action. The thrown error is
15
29
  * otherwise swallowed by the upload handler's retry path, leaving the failure
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/lds-runtime-mobile",
3
- "version": "1.451.0-dev2",
3
+ "version": "1.452.0",
4
4
  "license": "SEE LICENSE IN LICENSE.txt",
5
5
  "description": "LDS runtime for mobile/hybrid environments.",
6
6
  "main": "dist/main.js",
@@ -35,11 +35,11 @@
35
35
  "@conduit-client/service-bindings-imperative": "3.26.0",
36
36
  "@conduit-client/service-bindings-lwc": "3.26.0",
37
37
  "@conduit-client/service-provisioner": "3.26.0",
38
- "@salesforce/lds-adapters-uiapi": "^1.451.0-dev2",
39
- "@salesforce/lds-bindings": "^1.451.0-dev2",
40
- "@salesforce/lds-instrumentation": "^1.451.0-dev2",
41
- "@salesforce/lds-luvio-service": "^1.451.0-dev2",
42
- "@salesforce/lds-luvio-uiapi-records-service": "^1.451.0-dev2",
38
+ "@salesforce/lds-adapters-uiapi": "^1.452.0",
39
+ "@salesforce/lds-bindings": "^1.452.0",
40
+ "@salesforce/lds-instrumentation": "^1.452.0",
41
+ "@salesforce/lds-luvio-service": "^1.452.0",
42
+ "@salesforce/lds-luvio-uiapi-records-service": "^1.452.0",
43
43
  "@salesforce/user": "0.0.21",
44
44
  "o11y": "250.7.0",
45
45
  "o11y_schema": "256.126.0"
@@ -59,16 +59,16 @@
59
59
  "@conduit-client/service-pubsub": "3.26.0",
60
60
  "@conduit-client/service-store": "3.26.0",
61
61
  "@conduit-client/utils": "3.26.0",
62
- "@salesforce/lds-adapters-graphql": "^1.451.0-dev2",
63
- "@salesforce/lds-drafts": "^1.451.0-dev2",
64
- "@salesforce/lds-durable-records": "^1.451.0-dev2",
65
- "@salesforce/lds-network-adapter": "^1.451.0-dev2",
66
- "@salesforce/lds-network-nimbus": "^1.451.0-dev2",
67
- "@salesforce/lds-store-binary": "^1.451.0-dev2",
68
- "@salesforce/lds-store-nimbus": "^1.451.0-dev2",
69
- "@salesforce/lds-store-sql": "^1.451.0-dev2",
70
- "@salesforce/lds-utils-adapters": "^1.451.0-dev2",
71
- "@salesforce/nimbus-plugin-lds": "^1.451.0-dev2",
62
+ "@salesforce/lds-adapters-graphql": "^1.452.0",
63
+ "@salesforce/lds-drafts": "^1.452.0",
64
+ "@salesforce/lds-durable-records": "^1.452.0",
65
+ "@salesforce/lds-network-adapter": "^1.452.0",
66
+ "@salesforce/lds-network-nimbus": "^1.452.0",
67
+ "@salesforce/lds-store-binary": "^1.452.0",
68
+ "@salesforce/lds-store-nimbus": "^1.452.0",
69
+ "@salesforce/lds-store-sql": "^1.452.0",
70
+ "@salesforce/lds-utils-adapters": "^1.452.0",
71
+ "@salesforce/nimbus-plugin-lds": "^1.452.0",
72
72
  "babel-plugin-dynamic-import-node": "^2.3.3",
73
73
  "wait-for-expect": "^3.0.2"
74
74
  },
package/sfdc/main.js CHANGED
@@ -20,6 +20,7 @@ import { setupInstrumentation, instrumentAdapter as instrumentAdapter$1, instrum
20
20
  import { HttpStatusCode as HttpStatusCode$1, setBypassDeepFreeze, StoreKeySet, StringKeyInMemoryStore, Reader, serializeStructuredKey, deepFreeze as deepFreeze$1, emitAdapterEvent, ingestShape, coerceConfig as coerceConfig$1, typeCheckConfig as typeCheckConfig$h, createResourceParams as createResourceParams$h, StoreKeyMap, buildNetworkSnapshotCachePolicy as buildNetworkSnapshotCachePolicy$f, resolveLink, createCustomAdapterEventEmitter, isFileReference, Environment, Luvio, InMemoryStore } from 'force/luvioEngine';
21
21
  import { isSupportedEntity, configuration, getObjectInfoAdapterFactory, RECORD_ID_PREFIX, RECORD_FIELDS_KEY_JUNCTION, isStoreKeyRecordViewEntity, extractRecordIdFromStoreKey, buildRecordRepKeyFromId, keyBuilderRecord, RecordRepresentationTTL, keyBuilderQuickActionExecutionRepresentation, ingestQuickActionExecutionRepresentation, getRecordId18 as getRecordId18$1, getRecordsAdapterFactory as getRecordsAdapterFactory$1, RecordRepresentationRepresentationType, ObjectInfoRepresentationType, getObjectInfosAdapterFactory, getObjectInfoDirectoryAdapterFactory, UiApiNamespace, RecordRepresentationType, RecordRepresentationVersion } from 'force/ldsAdaptersUiapi';
22
22
  import draftQueueMaxRetryAttemptsGate from '@salesforce/gate/lmr.draft-queue-max-retry-attempts';
23
+ import draftQueueRedirectScanGate from '@salesforce/gate/lmr.draft-queue-redirect-scan';
23
24
  import rotateIdempotencyKeyOn400Killswitch from '@salesforce/gate/lds.idempotency-key-on-400-killswitch';
24
25
  import { getInstrumentation, idleDetector } from 'o11y/client';
25
26
  import allowUpdatesForNonCachedRecords from '@salesforce/gate/lmr.allowUpdatesForNonCachedRecords';
@@ -30,6 +31,7 @@ import FIRST_DAY_OF_WEEK from '@salesforce/i18n/firstDayOfWeek';
30
31
  import { entityFetchedGraphqlMetadataSchema, entityFetchedMetadataSchema } from 'o11y_schema/sf_lightningsdk';
31
32
  import graphqQueryFieldLimit from '@salesforce/gate/lmr.graphqQueryFieldLimit';
32
33
  import graphqlPartialEmitParity from '@salesforce/gate/lmr.graphqlPartialEmitParity';
34
+ import draftQueueStoreSnapshotGate from '@salesforce/gate/lmr.draft-queue-store-snapshot';
33
35
  import { pdpEventSchema } from 'o11y_schema/sf_pdp';
34
36
  import { instrument as instrument$1 } from 'force/ldsBindings';
35
37
  import ldsAdapterO11yLoggingGate from '@salesforce/gate/lmr.ldsAdapterO11yLogging';
@@ -49,6 +51,7 @@ import useOneStore from '@salesforce/gate/lmr.useOneStore';
49
51
  import { setServices } from 'force/luvioServiceProvisioner1';
50
52
  import 'force/luvioTypeNormalization1';
51
53
  import { Kind as Kind$2, visit as visit$2, print as print$1, resolveAndValidateGraphQLConfig, toGraphQLErrorResponse } from 'force/luvioOnestoreGraphqlParser';
54
+ import draftQueueSideEffectMapGate from '@salesforce/gate/lmr.draft-queue-sideeffect-map';
52
55
  import productConsumedSideEffects from '@salesforce/gate/com.salesforce.fieldservice.vanStockLDSBypass256';
53
56
  import reviveOnlyRequestedFields from '@salesforce/gate/lmr.reviveOnlyRequestedFields';
54
57
 
@@ -2362,7 +2365,7 @@ class DurableDraftQueue {
2362
2365
  }
2363
2366
  return handler;
2364
2367
  }
2365
- constructor(draftStore) {
2368
+ constructor(draftStore, instrumentation) {
2366
2369
  this.retryIntervalMilliseconds = 0;
2367
2370
  this.minimumRetryInterval = 250;
2368
2371
  this.maximumRetryInterval = 32000;
@@ -2379,6 +2382,7 @@ class DurableDraftQueue {
2379
2382
  this.handlers = {};
2380
2383
  this.draftStore = draftStore;
2381
2384
  this.workerPool = new AsyncWorkerPool(1);
2385
+ this.instrumentation = instrumentation;
2382
2386
  }
2383
2387
  addHandler(handler) {
2384
2388
  const id = handler.handlerId;
@@ -2469,15 +2473,22 @@ class DurableDraftQueue {
2469
2473
  this.state = DraftQueueState.Stopped;
2470
2474
  }
2471
2475
  async getQueueActions() {
2476
+ // W-23515682: time the whole call, including this queue's own internal
2477
+ // this.getQueueActions() during a drain — the amplification signal an external wrapper
2478
+ // cannot see. Measure-only; control flow below is unchanged.
2479
+ const { instrumentation } = this;
2480
+ const start = instrumentation?.onGetQueueActions !== undefined ? Date.now() : 0;
2472
2481
  const drafts = (await this.draftStore.getAllDrafts());
2473
2482
  const queue = [];
2474
2483
  drafts.forEach((draft) => {
2475
2484
  if (draft.id === this.uploadingActionId) {
2476
- draft.status = DraftActionStatus.Uploading;
2485
+ queue.push({ ...draft, status: DraftActionStatus.Uploading });
2486
+ }
2487
+ else {
2488
+ queue.push(draft);
2477
2489
  }
2478
- queue.push(draft);
2479
2490
  });
2480
- return queue.sort((a, b) => {
2491
+ const sorted = queue.sort((a, b) => {
2481
2492
  const aTime = parseInt(a.id, 10);
2482
2493
  const bTime = parseInt(b.id, 10);
2483
2494
  // safety check
@@ -2489,6 +2500,8 @@ class DurableDraftQueue {
2489
2500
  }
2490
2501
  return aTime - bTime;
2491
2502
  });
2503
+ instrumentation?.onGetQueueActions?.(Date.now() - start, sorted.length);
2504
+ return sorted;
2492
2505
  }
2493
2506
  async enqueue(handlerId, data, observabilityContext) {
2494
2507
  return this.workerPool.push({
@@ -2637,6 +2650,8 @@ class DurableDraftQueue {
2637
2650
  const listener = draftQueueChangedListeners[i];
2638
2651
  results.push(listener(event));
2639
2652
  }
2653
+ // W-23515682: listener fan-out per event type. Measure-only.
2654
+ this.instrumentation?.onNotifyChangedListeners?.(event.type, draftQueueLen);
2640
2655
  await Promise.all(results);
2641
2656
  }
2642
2657
  /**
@@ -2884,36 +2899,66 @@ function buildDraftDurableStoreKey(recordKey, draftActionId) {
2884
2899
  *
2885
2900
  */
2886
2901
  class DurableDraftStore {
2887
- constructor(durableStore) {
2902
+ constructor(durableStore, instrumentation, useSnapshot = true) {
2888
2903
  this.draftStore = {};
2904
+ // Snapshot: holds deep clones of each draft, refreshed at write time.
2905
+ // getAllDrafts serves from this snapshot with zero deep clones per read.
2906
+ this.cleanSnapshot = {};
2889
2907
  // queue of writes that were made during the initial sync
2890
2908
  this.writeQueue = [];
2891
2909
  this.durableStore = durableStore;
2910
+ this.instrumentation = instrumentation;
2911
+ this.useSnapshot = useSnapshot;
2892
2912
  this.resyncDraftStore();
2893
2913
  }
2914
+ // When no instrumentation is injected the promise is returned as-is (zero overhead).
2915
+ timeDurableOp(op, segment, run) {
2916
+ const onDurableOperation = this.instrumentation?.onDurableOperation;
2917
+ if (onDurableOperation === undefined) {
2918
+ return run();
2919
+ }
2920
+ const start = Date.now();
2921
+ return run().finally(() => {
2922
+ onDurableOperation(op, segment, Date.now() - start);
2923
+ });
2924
+ }
2894
2925
  writeAction(action) {
2895
2926
  const addAction = () => {
2896
2927
  const { id, tag } = action;
2897
2928
  this.draftStore[id] = action;
2929
+ this.cleanSnapshot[id] = clone$1(action);
2898
2930
  const durableEntryKey = buildDraftDurableStoreKey(tag, id);
2899
2931
  const entry = {
2900
2932
  data: action,
2901
2933
  };
2902
2934
  const entries = { [durableEntryKey]: entry };
2903
- return this.durableStore.setEntries(entries, DRAFT_SEGMENT);
2935
+ return this.timeDurableOp('setEntries', DRAFT_SEGMENT, () => this.durableStore.setEntries(entries, DRAFT_SEGMENT));
2904
2936
  };
2905
2937
  return this.enqueueAction(addAction);
2906
2938
  }
2907
2939
  getAllDrafts() {
2908
2940
  const waitForOngoingSync = this.syncPromise || Promise.resolve();
2909
2941
  return waitForOngoingSync.then(() => {
2910
- const { draftStore } = this;
2911
- const keys$1 = keys$4(draftStore);
2912
2942
  const actionArray = [];
2913
- for (let i = 0, len = keys$1.length; i < len; i++) {
2914
- const key = keys$1[i];
2915
- // clone draft so we don't expose the internal draft store
2916
- actionArray.push(clone$1(draftStore[key]));
2943
+ if (this.useSnapshot) {
2944
+ const { cleanSnapshot } = this;
2945
+ const keys$1 = keys$4(cleanSnapshot);
2946
+ for (let i = 0, len = keys$1.length; i < len; i++) {
2947
+ const key = keys$1[i];
2948
+ // Return shallow spread of snapshot entries so top-level caller mutations
2949
+ // (e.g., .status =) don't persist into the snapshot. The snapshot itself
2950
+ // is a deep clone of draftStore, so no nested mutation reaches draftStore.
2951
+ actionArray.push({ ...cleanSnapshot[key] });
2952
+ }
2953
+ }
2954
+ else {
2955
+ // Killswitch (W-23515682): original pre-fix behavior — deep-clone every draft on
2956
+ // every read so the internal draft store is never exposed. O(N) clones per read.
2957
+ const { draftStore } = this;
2958
+ const keys$1 = keys$4(draftStore);
2959
+ for (let i = 0, len = keys$1.length; i < len; i++) {
2960
+ actionArray.push(clone$1(draftStore[keys$1[i]]));
2961
+ }
2917
2962
  }
2918
2963
  return actionArray;
2919
2964
  });
@@ -2923,8 +2968,9 @@ class DurableDraftStore {
2923
2968
  const draft = this.draftStore[id];
2924
2969
  if (draft !== undefined) {
2925
2970
  delete this.draftStore[id];
2971
+ delete this.cleanSnapshot[id];
2926
2972
  const durableKey = buildDraftDurableStoreKey(draft.tag, draft.id);
2927
- return this.durableStore.evictEntries([durableKey], DRAFT_SEGMENT);
2973
+ return this.timeDurableOp('evictEntries', DRAFT_SEGMENT, () => this.durableStore.evictEntries([durableKey], DRAFT_SEGMENT));
2928
2974
  }
2929
2975
  return Promise.resolve();
2930
2976
  };
@@ -2940,10 +2986,11 @@ class DurableDraftStore {
2940
2986
  const action = draftStore[key];
2941
2987
  if (action.tag === tag) {
2942
2988
  delete draftStore[action.id];
2989
+ delete this.cleanSnapshot[action.id];
2943
2990
  durableKeys.push(buildDraftDurableStoreKey(action.tag, action.id));
2944
2991
  }
2945
2992
  }
2946
- return this.durableStore.evictEntries(durableKeys, DRAFT_SEGMENT);
2993
+ return this.timeDurableOp('evictEntries', DRAFT_SEGMENT, () => this.durableStore.evictEntries(durableKeys, DRAFT_SEGMENT));
2947
2994
  };
2948
2995
  return this.enqueueAction(deleteAction);
2949
2996
  }
@@ -2957,6 +3004,7 @@ class DurableDraftStore {
2957
3004
  const action = draftStore[operation.id];
2958
3005
  if (action !== undefined) {
2959
3006
  delete draftStore[operation.id];
3007
+ delete this.cleanSnapshot[operation.id];
2960
3008
  const key = buildDraftDurableStoreKey(action.tag, action.id);
2961
3009
  durableStoreOperations.push({
2962
3010
  ids: [key],
@@ -2969,6 +3017,7 @@ class DurableDraftStore {
2969
3017
  const { action } = operation;
2970
3018
  const key = buildDraftDurableStoreKey(action.tag, action.id);
2971
3019
  draftStore[action.id] = action;
3020
+ this.cleanSnapshot[action.id] = clone$1(action);
2972
3021
  durableStoreOperations.push({
2973
3022
  type: 'setEntries',
2974
3023
  segment: DRAFT_SEGMENT,
@@ -2980,7 +3029,7 @@ class DurableDraftStore {
2980
3029
  });
2981
3030
  }
2982
3031
  }
2983
- return this.durableStore.batchOperations(durableStoreOperations);
3032
+ return this.timeDurableOp('batchOperations', DRAFT_SEGMENT, () => this.durableStore.batchOperations(durableStoreOperations));
2984
3033
  };
2985
3034
  return this.enqueueAction(action);
2986
3035
  }
@@ -3023,6 +3072,7 @@ class DurableDraftStore {
3023
3072
  .then((durableEntries) => {
3024
3073
  if (durableEntries === undefined) {
3025
3074
  this.draftStore = {};
3075
+ this.cleanSnapshot = {};
3026
3076
  return this.runQueuedOperations();
3027
3077
  }
3028
3078
  const { draftStore } = this;
@@ -3039,6 +3089,7 @@ class DurableDraftStore {
3039
3089
  }
3040
3090
  }
3041
3091
  draftStore[action.id] = action;
3092
+ this.cleanSnapshot[action.id] = clone$1(action);
3042
3093
  }
3043
3094
  return this.runQueuedOperations();
3044
3095
  })
@@ -42277,6 +42328,14 @@ const DRAFT_QUEUE_TOTAL_MERGE_ACTIONS_CALLS = 'draft-queue-total-mergeActions-ca
42277
42328
  // Used to triage the WOLI duplicate-record investigation in Splunk — correlate by draftId
42278
42329
  // with the other draft-queue events. @W-23428669
42279
42330
  const DRAFT_QUEUE_IDEMPOTENCY_KEY_ROTATED = 'draft-queue-idempotency-key-rotated';
42331
+ // Hotspot-attribution telemetry (@W-23515682). Operational counts/durations only — no
42332
+ // draft content, field values, or server record ids. These attribute the offline-drain wall-clock
42333
+ // to the O(N^2) hotspots (getQueueActions amplification, listener fan-out, durable-op latency).
42334
+ const DRAFT_QUEUE_GET_QUEUE_ACTIONS_COUNT = 'draft-queue-get-queue-actions-count';
42335
+ const DRAFT_QUEUE_GET_QUEUE_ACTIONS_DURATION = 'draft-queue-get-queue-actions-duration';
42336
+ const DRAFT_QUEUE_NOTIFY_CHANGED_LISTENERS_COUNT = 'draft-queue-notify-changed-listeners-count';
42337
+ const DRAFT_QUEUE_DURABLE_OP_COUNT = 'draft-queue-durable-op-count';
42338
+ const DRAFT_QUEUE_DURABLE_OP_DURATION = 'draft-queue-durable-op-duration';
42280
42339
  /** Content Document */
42281
42340
  const CREATE_CONTENT_DOCUMENT_AND_VERSION_TOTAL_SYNTHESIZE_CALLS = 'content-document-version-total-synthesize-calls';
42282
42341
  const CREATE_CONTENT_DOCUMENT_AND_VERSION_DRAFT_SYNTHESIZE_ERROR = 'create-content-document-version-draft-synthesize-error';
@@ -42369,6 +42428,33 @@ function reportDraftActionEvent(state, draftCount, message) {
42369
42428
  break;
42370
42429
  }
42371
42430
  }
42431
+ /**
42432
+ * @W-23515682: count (amplification — queue re-reads per drain) + duration histogram (the
42433
+ * pre/post signal for the getAllDrafts clone-on-write fix). actionCount is queue depth, never content.
42434
+ */
42435
+ function reportDraftQueueGetQueueActions(durationMs, actionCount) {
42436
+ const depthBuckets = [1, 2, 5, 10, 20, 50, 100, 250, 500];
42437
+ ldsMobileInstrumentation.incrementCounter(DRAFT_QUEUE_GET_QUEUE_ACTIONS_COUNT);
42438
+ ldsMobileInstrumentation.trackValue(DRAFT_QUEUE_GET_QUEUE_ACTIONS_DURATION, durationMs);
42439
+ ldsMobileInstrumentation.bucketValue(DRAFT_QUEUE_GET_QUEUE_ACTIONS_COUNT + '-depth', actionCount, depthBuckets);
42440
+ }
42441
+ /**
42442
+ * @W-23515682: count per event type — listener fan-out / amplification per event.
42443
+ */
42444
+ function reportDraftQueueNotifyChangedListeners(eventType, listenerCount) {
42445
+ ldsMobileInstrumentation.incrementCounter(DRAFT_QUEUE_NOTIFY_CHANGED_LISTENERS_COUNT, listenerCount, undefined, { eventType });
42446
+ }
42447
+ /**
42448
+ * @W-23515682: count + duration histogram by operation kind + segment — attributes the
42449
+ * client-CPU-vs-durable split of per-action processing time.
42450
+ */
42451
+ function reportDraftQueueDurableOperation(op, segment, durationMs) {
42452
+ ldsMobileInstrumentation.incrementCounter(DRAFT_QUEUE_DURABLE_OP_COUNT, 1, undefined, {
42453
+ op,
42454
+ segment,
42455
+ });
42456
+ ldsMobileInstrumentation.trackValue(DRAFT_QUEUE_DURABLE_OP_DURATION, durationMs);
42457
+ }
42372
42458
  /**
42373
42459
  * Reports an exception thrown while uploading a draft action. The thrown error is
42374
42460
  * otherwise swallowed by the upload handler's retry path, leaving the failure
@@ -42852,14 +42938,45 @@ class AbstractResourceRequestActionHandler {
42852
42938
  let updatedActionTargetId = undefined;
42853
42939
  const { tag: queueActionTag, data: queueActionRequest, id: queueActionId, } = queueAction;
42854
42940
  let { basePath, body } = queueActionRequest;
42941
+ // Literal indexOf pre-check (no RegExp coercion, unlike the old String.search) +
42942
+ // a replace RegExp built only when a reference is present — the win over the old scan.
42855
42943
  let stringifiedBody = stringify$5(body);
42944
+ // Read the killswitch once for the whole scan (W-23515682) — avoids a
42945
+ // per-redirect gate lookup in this hot loop.
42946
+ const useOptimizedScan = draftQueueRedirectScanGate.isOpen({ fallback: true });
42856
42947
  // for each redirected ID/key we loop over the operation to see if it needs
42857
42948
  // to be updated
42858
42949
  for (const { draftId, draftKey, canonicalId, canonicalKey } of redirects) {
42859
- if (basePath.search(draftId) >= 0 || stringifiedBody.search(draftId) >= 0) {
42860
- basePath = basePath.replace(new RegExp(draftId, 'g'), canonicalId);
42861
- stringifiedBody = stringifiedBody.replace(new RegExp(draftId, 'g'), canonicalId);
42862
- queueOperationMutated = true;
42950
+ if (useOptimizedScan) {
42951
+ // basePath AND body are checked independently: a queued action can carry
42952
+ // the draft id in either or BOTH, and each occurrence must be rewritten.
42953
+ const basePathNeedsUpdate = basePath.indexOf(draftId) >= 0;
42954
+ const bodyNeedsUpdate = stringifiedBody.indexOf(draftId) >= 0;
42955
+ if (basePathNeedsUpdate || bodyNeedsUpdate) {
42956
+ // Escape metacharacters so the draft id matches literally. Salesforce
42957
+ // ids carry no regex metacharacters today, so this is defensive against
42958
+ // a future id-format change silently corrupting unrelated ids. W-23515682.
42959
+ const escapedDraftId = draftId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
42960
+ const replaceRegex = new RegExp(escapedDraftId, 'g');
42961
+ if (basePathNeedsUpdate) {
42962
+ basePath = basePath.replace(replaceRegex, canonicalId);
42963
+ }
42964
+ if (bodyNeedsUpdate) {
42965
+ stringifiedBody = stringifiedBody.replace(replaceRegex, canonicalId);
42966
+ }
42967
+ queueOperationMutated = true;
42968
+ }
42969
+ }
42970
+ else {
42971
+ // Killswitch closed (W-23515682): original pre-fix scan — String.search
42972
+ // (coerces draftId to a RegExp) + a raw (unescaped) replace RegExp applied
42973
+ // to basePath and body together.
42974
+ if (basePath.search(draftId) >= 0 ||
42975
+ stringifiedBody.search(draftId) >= 0) {
42976
+ basePath = basePath.replace(new RegExp(draftId, 'g'), canonicalId);
42977
+ stringifiedBody = stringifiedBody.replace(new RegExp(draftId, 'g'), canonicalId);
42978
+ queueOperationMutated = true;
42979
+ }
42863
42980
  }
42864
42981
  // if the action is performed on a previous draft id, we need to replace the action
42865
42982
  // with a new one at the updated canonical key
@@ -52438,6 +52555,20 @@ function obtainFailureMessage(error) {
52438
52555
  }
52439
52556
  }
52440
52557
 
52558
+ // Mobile impl of the shared DraftQueueInstrumentation seam (@W-23515682). Forwards to
52559
+ // ldsMobileInstrumentation via metrics.ts; injected at construction (DraftQueueFactory).
52560
+ const draftQueueInstrumentation = {
52561
+ onGetQueueActions(durationMs, actionCount) {
52562
+ reportDraftQueueGetQueueActions(durationMs, actionCount);
52563
+ },
52564
+ onNotifyChangedListeners(eventType, listenerCount) {
52565
+ reportDraftQueueNotifyChangedListeners(eventType, listenerCount);
52566
+ },
52567
+ onDurableOperation(op, segment, durationMs) {
52568
+ reportDraftQueueDurableOperation(op, segment, durationMs);
52569
+ },
52570
+ };
52571
+
52441
52572
  // so eslint doesn't complain about nimbus
52442
52573
  /* global __nimbus */
52443
52574
  function buildLdsDraftQueue(durableStore) {
@@ -52446,7 +52577,8 @@ function buildLdsDraftQueue(durableStore) {
52446
52577
  __nimbus.plugins.LdsDraftQueue !== undefined) {
52447
52578
  return new NimbusDraftQueue();
52448
52579
  }
52449
- const draftQueue = new DurableDraftQueue(new DurableDraftStore(durableStore));
52580
+ const useSnapshot = draftQueueStoreSnapshotGate.isOpen({ fallback: true });
52581
+ const draftQueue = new DurableDraftQueue(new DurableDraftStore(durableStore, draftQueueInstrumentation, useSnapshot), draftQueueInstrumentation);
52450
52582
  return instrumentDraftQueue(draftQueue);
52451
52583
  }
52452
52584
 
@@ -55925,18 +56057,34 @@ class SideEffectStore {
55925
56057
  this.initialize();
55926
56058
  }
55927
56059
  async addEffects(effects) {
56060
+ const incremental = draftQueueSideEffectMapGate.isOpen({ fallback: true });
55928
56061
  const durableEntries = {};
55929
56062
  effects.forEach((effect) => {
55930
56063
  durableEntries[effect.uniqueId] = { data: effect };
55931
56064
  this.allEffects.set(effect.uniqueId, effect);
56065
+ if (incremental) {
56066
+ this.addEffectToKeyMap(effect);
56067
+ }
55932
56068
  });
55933
- this.regenerateKeyToEffectMap();
56069
+ // Killswitch (W-23515682): closed → original O(N) full rebuild on every add.
56070
+ if (!incremental) {
56071
+ this.regenerateKeyToEffectMap();
56072
+ }
55934
56073
  await this.durableStore.setEntries(durableEntries, SIDE_EFFECT_SEGMENT);
55935
56074
  }
55936
56075
  async removeEffects(effects) {
56076
+ const incremental = draftQueueSideEffectMapGate.isOpen({ fallback: true });
55937
56077
  const effectKeys = effects.map((e) => e.uniqueId);
55938
- effects.forEach((k) => this.allEffects.delete(k.uniqueId));
55939
- this.regenerateKeyToEffectMap();
56078
+ effects.forEach((effect) => {
56079
+ this.allEffects.delete(effect.uniqueId);
56080
+ if (incremental) {
56081
+ this.removeEffectFromKeyMap(effect);
56082
+ }
56083
+ });
56084
+ // Killswitch (W-23515682): closed → original O(N) full rebuild on every remove.
56085
+ if (!incremental) {
56086
+ this.regenerateKeyToEffectMap();
56087
+ }
55940
56088
  await this.durableStore.evictEntries(effectKeys, SIDE_EFFECT_SEGMENT);
55941
56089
  }
55942
56090
  getEffects(key) {
@@ -55971,6 +56119,39 @@ class SideEffectStore {
55971
56119
  });
55972
56120
  this.regenerateKeyToEffectMap();
55973
56121
  }
56122
+ addEffectToKeyMap(effect) {
56123
+ const recordEffects = this.keyToEffectMap.get(effect.key);
56124
+ if (recordEffects) {
56125
+ // Effects are keyed by uniqueId (mirroring allEffects.set / regenerateKeyToEffectMap):
56126
+ // a re-add of the same uniqueId must REPLACE the resident entry in place, not append a
56127
+ // duplicate. The action-completion replay path (setSideEffectsForActions on still-queued
56128
+ // actions) re-adds resident effects with no preceding remove; without this dedup a
56129
+ // record-field-increment would sit in the array twice and be applied twice (e.g. a
56130
+ // ProductConsumed QuantityOnHand -=5 becomes -=10). W-23515682.
56131
+ const index = recordEffects.findIndex((e) => e.uniqueId === effect.uniqueId);
56132
+ if (index !== -1) {
56133
+ recordEffects[index] = effect;
56134
+ }
56135
+ else {
56136
+ recordEffects.push(effect);
56137
+ }
56138
+ }
56139
+ else {
56140
+ this.keyToEffectMap.set(effect.key, [effect]);
56141
+ }
56142
+ }
56143
+ removeEffectFromKeyMap(effect) {
56144
+ const recordEffects = this.keyToEffectMap.get(effect.key);
56145
+ if (recordEffects) {
56146
+ const index = recordEffects.findIndex((e) => e.uniqueId === effect.uniqueId);
56147
+ if (index !== -1) {
56148
+ recordEffects.splice(index, 1);
56149
+ if (recordEffects.length === 0) {
56150
+ this.keyToEffectMap.delete(effect.key);
56151
+ }
56152
+ }
56153
+ }
56154
+ }
55974
56155
  regenerateKeyToEffectMap() {
55975
56156
  this.keyToEffectMap.clear();
55976
56157
  this.allEffects.forEach((effect) => {
@@ -59137,7 +59318,7 @@ function buildServiceDescriptor$b(luvio) {
59137
59318
  },
59138
59319
  };
59139
59320
  }
59140
- // version: 1.451.0-dev2-a5cd560c12
59321
+ // version: 1.452.0-bcd113b7fd
59141
59322
 
59142
59323
  /**
59143
59324
  * Copyright (c) 2022, Salesforce, Inc.,
@@ -59163,7 +59344,7 @@ function buildServiceDescriptor$a(notifyRecordUpdateAvailable, getNormalizedLuvi
59163
59344
  },
59164
59345
  };
59165
59346
  }
59166
- // version: 1.451.0-dev2-a5cd560c12
59347
+ // version: 1.452.0-bcd113b7fd
59167
59348
 
59168
59349
  function findExecutableOperation(input) {
59169
59350
  const operations = input.query.definitions.filter(
@@ -61875,4 +62056,4 @@ register({
61875
62056
  });
61876
62057
 
61877
62058
  export { O11Y_NAMESPACE_LDS_MOBILE, getRuntime, ingest$1o as ingestDenormalizedRecordRepresentation, initializeOneStore, registerReportObserver, reportGraphqlQueryParseError };
61878
- // version: 1.451.0-dev2-a5cd560c12
62059
+ // version: 1.452.0-bcd113b7fd
@@ -10,5 +10,7 @@ export declare class SideEffectStore {
10
10
  getEffects(key: string): SideEffect[];
11
11
  getEffectsByTag(tag: string): SideEffect[];
12
12
  private initialize;
13
+ private addEffectToKeyMap;
14
+ private removeEffectFromKeyMap;
13
15
  private regenerateKeyToEffectMap;
14
16
  }
@@ -0,0 +1,2 @@
1
+ import type { DraftQueueInstrumentation } from '@salesforce/lds-drafts';
2
+ export declare const draftQueueInstrumentation: DraftQueueInstrumentation;
@@ -10,6 +10,20 @@ export declare function reportGraphqlAdapterError(errorCode: string): void;
10
10
  export declare function reportGraphqlQueryInstrumentation(data: QueryInstrumentation): void;
11
11
  export declare function incrementGraphQLRefreshUndfined(): void;
12
12
  export declare function reportDraftActionEvent(state: 'added' | 'uploading' | 'completed' | 'deleted' | 'updated' | 'failed', draftCount: number, message?: string): void;
13
+ /**
14
+ * @W-23515682: count (amplification — queue re-reads per drain) + duration histogram (the
15
+ * pre/post signal for the getAllDrafts clone-on-write fix). actionCount is queue depth, never content.
16
+ */
17
+ export declare function reportDraftQueueGetQueueActions(durationMs: number, actionCount: number): void;
18
+ /**
19
+ * @W-23515682: count per event type — listener fan-out / amplification per event.
20
+ */
21
+ export declare function reportDraftQueueNotifyChangedListeners(eventType: string, listenerCount: number): void;
22
+ /**
23
+ * @W-23515682: count + duration histogram by operation kind + segment — attributes the
24
+ * client-CPU-vs-durable split of per-action processing time.
25
+ */
26
+ export declare function reportDraftQueueDurableOperation(op: string, segment: string, durationMs: number): void;
13
27
  /**
14
28
  * Reports an exception thrown while uploading a draft action. The thrown error is
15
29
  * otherwise swallowed by the upload handler's retry path, leaving the failure