@memberjunction/core 6.2.0-edge.2 → 6.2.0-edge.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/generic/InMemoryLocalStorageProvider.d.ts +5 -0
  2. package/dist/generic/InMemoryLocalStorageProvider.d.ts.map +1 -1
  3. package/dist/generic/InMemoryLocalStorageProvider.js +5 -0
  4. package/dist/generic/InMemoryLocalStorageProvider.js.map +1 -1
  5. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +27 -0
  6. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
  7. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts +19 -0
  8. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts.map +1 -1
  9. package/dist/generic/RegisterForStartup.d.ts +44 -0
  10. package/dist/generic/RegisterForStartup.d.ts.map +1 -1
  11. package/dist/generic/RegisterForStartup.js +124 -48
  12. package/dist/generic/RegisterForStartup.js.map +1 -1
  13. package/dist/generic/baseEngine.d.ts +214 -3
  14. package/dist/generic/baseEngine.d.ts.map +1 -1
  15. package/dist/generic/baseEngine.js +460 -34
  16. package/dist/generic/baseEngine.js.map +1 -1
  17. package/dist/generic/baseEngineSweeper.d.ts +35 -0
  18. package/dist/generic/baseEngineSweeper.d.ts.map +1 -0
  19. package/dist/generic/baseEngineSweeper.js +97 -0
  20. package/dist/generic/baseEngineSweeper.js.map +1 -0
  21. package/dist/generic/baseEntity.d.ts +91 -7
  22. package/dist/generic/baseEntity.d.ts.map +1 -1
  23. package/dist/generic/baseEntity.js +161 -16
  24. package/dist/generic/baseEntity.js.map +1 -1
  25. package/dist/generic/databaseProviderBase.d.ts +54 -11
  26. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  27. package/dist/generic/databaseProviderBase.js +129 -35
  28. package/dist/generic/databaseProviderBase.js.map +1 -1
  29. package/dist/generic/entityConfiguration.d.ts +20 -0
  30. package/dist/generic/entityConfiguration.d.ts.map +1 -1
  31. package/dist/generic/entityConfiguration.js +34 -0
  32. package/dist/generic/entityConfiguration.js.map +1 -1
  33. package/dist/generic/entityEventBatch.d.ts +124 -0
  34. package/dist/generic/entityEventBatch.d.ts.map +1 -0
  35. package/dist/generic/entityEventBatch.js +194 -0
  36. package/dist/generic/entityEventBatch.js.map +1 -0
  37. package/dist/generic/entityInfo.d.ts +57 -6
  38. package/dist/generic/entityInfo.d.ts.map +1 -1
  39. package/dist/generic/entityInfo.js +72 -7
  40. package/dist/generic/entityInfo.js.map +1 -1
  41. package/dist/generic/entityRecordNameCache.d.ts +48 -0
  42. package/dist/generic/entityRecordNameCache.d.ts.map +1 -0
  43. package/dist/generic/entityRecordNameCache.js +92 -0
  44. package/dist/generic/entityRecordNameCache.js.map +1 -0
  45. package/dist/generic/interfaces.d.ts +151 -8
  46. package/dist/generic/interfaces.d.ts.map +1 -1
  47. package/dist/generic/interfaces.js.map +1 -1
  48. package/dist/generic/jsonFieldBinding.d.ts +196 -0
  49. package/dist/generic/jsonFieldBinding.d.ts.map +1 -0
  50. package/dist/generic/jsonFieldBinding.js +508 -0
  51. package/dist/generic/jsonFieldBinding.js.map +1 -0
  52. package/dist/generic/localCacheManager.d.ts +278 -16
  53. package/dist/generic/localCacheManager.d.ts.map +1 -1
  54. package/dist/generic/localCacheManager.js +737 -113
  55. package/dist/generic/localCacheManager.js.map +1 -1
  56. package/dist/generic/logging.d.ts +15 -0
  57. package/dist/generic/logging.d.ts.map +1 -1
  58. package/dist/generic/logging.js +17 -0
  59. package/dist/generic/logging.js.map +1 -1
  60. package/dist/generic/metadata.d.ts +1 -1
  61. package/dist/generic/metadata.d.ts.map +1 -1
  62. package/dist/generic/metadata.js.map +1 -1
  63. package/dist/generic/providerBase.d.ts +252 -35
  64. package/dist/generic/providerBase.d.ts.map +1 -1
  65. package/dist/generic/providerBase.js +503 -189
  66. package/dist/generic/providerBase.js.map +1 -1
  67. package/dist/generic/runQuery.d.ts +18 -1
  68. package/dist/generic/runQuery.d.ts.map +1 -1
  69. package/dist/generic/runQuery.js.map +1 -1
  70. package/dist/generic/runQuerySQLFilterImplementations.d.ts +7 -1
  71. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  72. package/dist/generic/runQuerySQLFilterImplementations.js +65 -140
  73. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  74. package/dist/generic/securityInfo.d.ts +5 -0
  75. package/dist/generic/securityInfo.d.ts.map +1 -1
  76. package/dist/generic/securityInfo.js +5 -0
  77. package/dist/generic/securityInfo.js.map +1 -1
  78. package/dist/generic/telemetryManager.d.ts +11 -0
  79. package/dist/generic/telemetryManager.d.ts.map +1 -1
  80. package/dist/generic/telemetryManager.js +43 -0
  81. package/dist/generic/telemetryManager.js.map +1 -1
  82. package/dist/generic/transactionGroup.d.ts +27 -0
  83. package/dist/generic/transactionGroup.d.ts.map +1 -1
  84. package/dist/generic/transactionGroup.js +58 -5
  85. package/dist/generic/transactionGroup.js.map +1 -1
  86. package/dist/generic/util.d.ts +6 -0
  87. package/dist/generic/util.d.ts.map +1 -1
  88. package/dist/generic/util.js +20 -0
  89. package/dist/generic/util.js.map +1 -1
  90. package/dist/index.d.ts +3 -0
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +3 -0
  93. package/dist/index.js.map +1 -1
  94. package/dist/views/runView.d.ts +24 -0
  95. package/dist/views/runView.d.ts.map +1 -1
  96. package/dist/views/runView.js +4 -0
  97. package/dist/views/runView.js.map +1 -1
  98. package/package.json +3 -3
  99. package/readme.md +29 -2
@@ -1,9 +1,10 @@
1
1
  import { BaseSingleton, MJGlobal, MJEventType } from "@memberjunction/global";
2
2
  import { IsMaterializedDataSource } from "../views/runView.js";
3
- import { LogError, LogStatusEx } from "./logging.js";
3
+ import { LogError, LogStatusEx, LogWarning } from "./logging.js";
4
4
  import { BaseEntity } from "./baseEntity.js";
5
5
  import { Metadata } from "./metadata.js";
6
6
  import { CompositeKey, KeyValuePair } from "./compositeKey.js";
7
+ import { EntityEventBatchSet } from "./entityEventBatch.js";
7
8
  /** Verbose-only status logging — hidden unless verbose logging is enabled */
8
9
  function LogStatusVerbose(message) {
9
10
  LogStatusEx({ message, verboseOnly: true });
@@ -139,6 +140,12 @@ export class LocalCacheManager extends BaseSingleton {
139
140
  * (e.g., TransactionGroup batch deletes).
140
141
  */
141
142
  this._fingerprintLocks = new Map();
143
+ /** Logged once when a local TTL is configured against a shared store. See {@link runEvictionSweep}. */
144
+ this._sharedTTLWarned = false;
145
+ /** Open entity-event batches. See {@link BeginEntityEventBatch}. */
146
+ this._entityEventBatches = new EntityEventBatchSet();
147
+ /** Save/delete events a batch took when they were raised. See {@link WasTakenByEntityEventBatch}. */
148
+ this._eventsTakenByBatch = new WeakSet();
142
149
  this.REGISTRY_KEY = '__MJ_CACHE_REGISTRY__';
143
150
  /**
144
151
  * Reverse index from entity name to the set of RunView cache fingerprints
@@ -146,6 +153,7 @@ export class LocalCacheManager extends BaseSingleton {
146
153
  * event fires so we can update all relevant cached results.
147
154
  */
148
155
  this._entityFingerprintIndex = new Map();
156
+ this._initializedListeners = [];
149
157
  // ========================================================================
150
158
  // CROSS-SERVER CACHE CHANGE CALLBACKS
151
159
  // ========================================================================
@@ -168,22 +176,29 @@ export class LocalCacheManager extends BaseSingleton {
168
176
  * Initialize the cache manager with a storage provider.
169
177
  * This should be called during app startup after the storage provider is available.
170
178
  *
171
- * This method is safe to call multiple times - subsequent calls will return the same
172
- * promise as the first caller, ensuring initialization only happens once.
179
+ * Safe to call repeatedly: the cache is initialized once, but **a `config` passed by a later
180
+ * caller is still applied**. That matters because a database provider initializes this manager
181
+ * from inside its own `Config()` — before the host has a chance to pass anything — so a host
182
+ * that hands its settings to `StartupManager` would otherwise have them silently dropped, and
183
+ * every `cacheSettings` knob would be inert. The host's explicit settings win
184
+ * over the provider's implicit initialization, whichever runs first.
173
185
  *
174
186
  * @param storageProvider - The local storage provider to use for persistence
175
- * @param config - Optional configuration overrides
187
+ * @param config - Optional configuration overrides, applied even when already initialized
176
188
  * @returns A promise that resolves when initialization is complete
177
189
  */
178
190
  Initialize(storageProvider, config) {
179
- // If already initialized, return immediately
180
191
  if (this._initialized) {
192
+ if (config) {
193
+ this.UpdateConfig(config);
194
+ }
181
195
  return Promise.resolve();
182
196
  }
183
- // If initialization is in progress, return the existing promise
184
- // so all callers await the same initialization
197
+ // Initialization in flight: share it, then apply this caller's settings on top.
185
198
  if (this._initializePromise) {
186
- return this._initializePromise;
199
+ return config
200
+ ? this._initializePromise.then(() => { this.UpdateConfig(config); })
201
+ : this._initializePromise;
187
202
  }
188
203
  // First caller - start initialization and store the promise
189
204
  this._initializePromise = this.doInitialize(storageProvider, config);
@@ -254,6 +269,36 @@ export class LocalCacheManager extends BaseSingleton {
254
269
  // Subscribe to BaseEntity events for universal cache invalidation.
255
270
  // When any entity is saved/deleted, update all cached RunView results for that entity.
256
271
  this.subscribeToBaseEntityEvents();
272
+ this.notifyInitialized();
273
+ }
274
+ /**
275
+ * Runs `listener` once the cache manager is initialized — immediately if it already is.
276
+ * Lets code that ran too early (an engine loaded before startup initialized the cache) finish
277
+ * its cache wiring later instead of never.
278
+ *
279
+ * @param listener - Called once; errors are logged and do not affect other listeners
280
+ */
281
+ WhenInitialized(listener) {
282
+ if (this._initialized) {
283
+ this.runInitializedListener(listener);
284
+ return;
285
+ }
286
+ this._initializedListeners.push(listener);
287
+ }
288
+ runInitializedListener(listener) {
289
+ try {
290
+ listener();
291
+ }
292
+ catch (e) {
293
+ LogError(`LocalCacheManager.WhenInitialized listener failed: ${e instanceof Error ? e.message : String(e)}`);
294
+ }
295
+ }
296
+ notifyInitialized() {
297
+ const listeners = this._initializedListeners;
298
+ this._initializedListeners = [];
299
+ for (const listener of listeners) {
300
+ this.runInitializedListener(listener);
301
+ }
257
302
  }
258
303
  /**
259
304
  * Returns whether the cache manager has been initialized
@@ -268,10 +313,24 @@ export class LocalCacheManager extends BaseSingleton {
268
313
  return { ...this._config };
269
314
  }
270
315
  /**
271
- * Updates the configuration at runtime
316
+ * Updates the configuration at runtime, and re-arms the eviction sweep so a changed interval
317
+ * (or a disabled cache) takes effect immediately instead of at the next restart.
272
318
  */
273
319
  UpdateConfig(config) {
320
+ const previousInterval = this._config.evictionSweepIntervalMs;
321
+ const wasEnabled = this._config.enabled;
274
322
  this._config = { ...this._config, ...config };
323
+ if (!this._initialized) {
324
+ return; // doInitialize starts the sweep with the merged config
325
+ }
326
+ if (this._config.evictionSweepIntervalMs !== previousInterval || this._config.enabled !== wasEnabled) {
327
+ if (this._config.enabled) {
328
+ this.startEvictionSweep();
329
+ }
330
+ else {
331
+ this.stopEvictionSweep();
332
+ }
333
+ }
275
334
  }
276
335
  /**
277
336
  * Checks whether caching is enabled for a given entity. Returns the entity's
@@ -294,9 +353,15 @@ export class LocalCacheManager extends BaseSingleton {
294
353
  * @param newProvider - The new storage provider to use
295
354
  */
296
355
  async SetStorageProvider(newProvider) {
356
+ if (newProvider === this._storageProvider) {
357
+ // Already in use (a host that installed its shared store before startup): migrating
358
+ // every entry onto itself would rewrite — and republish — all of them.
359
+ return;
360
+ }
297
361
  // The freeze decision belongs to the ACTIVE provider, not to whichever one happened to be
298
- // installed at Initialize. MJAPI initializes on the in-memory provider during engine
299
- // loading and swaps to Redis afterward, so these two have OPPOSITE reference semantics on
362
+ // installed at Initialize. A host may still swap stores after initialization (and MJAPI did,
363
+ // before it began installing Redis ahead of metadata), and the two have OPPOSITE reference
364
+ // semantics on
300
365
  // every Redis deployment — carrying the old answer forward means freezing rows Redis has
301
366
  // already isolated (all of the hazard, none of the protection), or, on the reverse swap,
302
367
  // silently dropping the protection.
@@ -321,7 +386,7 @@ export class LocalCacheManager extends BaseSingleton {
321
386
  const category = this.getCategoryForType(entry.type);
322
387
  const data = await oldProvider?.GetItem(entry.key, category);
323
388
  if (data) {
324
- await newProvider.SetItem(entry.key, data, category);
389
+ await newProvider.SetItem(entry.key, data, category, this.writeOptionsForEntry(entry));
325
390
  migratedCount++;
326
391
  }
327
392
  }
@@ -381,6 +446,23 @@ export class LocalCacheManager extends BaseSingleton {
381
446
  * in-place mutation (`canUseImmediateMutation`); this closes the same gap in the raw
382
447
  * provider cache. (B42)
383
448
  *
449
+ * Maintaining the order in place was built and then withdrawn, so the payoff is on record rather
450
+ * than left to be rediscovered. It is possible for a narrow subset — plain column terms over
451
+ * numeric/date/boolean fields with no NULLs present, where JavaScript and SQL provably agree —
452
+ * but the subset is where the value dies, not the implementation. Of 918 RunView param literals
453
+ * in this repo, 289 carry an ORDER BY, 112 are also unfiltered and uncapped (the only ones a slot
454
+ * could maintain), and roughly 26 fall in that type subset, several of which order on a
455
+ * uniqueidentifier and are refused anyway. The survivors are about a dozen configuration and
456
+ * admin lists — API keys, credentials, view types, schema info — saved rarely, so the saving is a
457
+ * handful of small indexed reads per day. High-write entities gain nothing: every ordered read of
458
+ * MJ: AI Agent Runs, MJ: Conversation Details and MJ: AI Prompt Runs is FILTERED, so their slots
459
+ * land in the invalidate branch regardless. Against that, the cost is a comparator that must keep
460
+ * agreeing with SQL collation and NULL placement across dialects forever, whose failure mode is a
461
+ * silently reordered list in a slot other readers treat as authoritative. Widening it to strings —
462
+ * by far the largest bucket, `Name` — means owning collation per dialect and per column.
463
+ *
464
+ * If you are about to attempt this, the measurement above is the argument not to.
465
+ *
384
466
  * @param parts - the fingerprint already split on '|'
385
467
  */
386
468
  hasOrderBy(parts) {
@@ -557,14 +639,75 @@ export class LocalCacheManager extends BaseSingleton {
557
639
  if (!currentHash)
558
640
  return false;
559
641
  if (currentHash !== data.schemaHash) {
560
- LogStatusEx({
561
- message: `[CACHE-SCHEMA-STALE] Entity "${entityName}" schema changed (cached=${data.schemaHash}, current=${currentHash})`,
562
- verboseOnly: false
563
- });
642
+ LogWarning(`[CACHE-SCHEMA-STALE] Entity "${entityName}" schema changed (cached=${data.schemaHash}, current=${currentHash}) — dropping the slot`, 'Cache');
564
643
  return true;
565
644
  }
566
645
  return false;
567
646
  }
647
+ /**
648
+ * The newest `__mj_UpdatedAt` among `rows` as an ISO string, `''` for an empty set, or
649
+ * `undefined` when the rows carry no such column (so the caller can fall back to its own stamp).
650
+ * Mirrors how the RunView fill path stamps a slot.
651
+ */
652
+ static MaxUpdatedAtOfRows(rows) {
653
+ let max = Number.NEGATIVE_INFINITY;
654
+ let sawColumn = false;
655
+ for (const row of rows) {
656
+ if (!row || typeof row !== 'object') {
657
+ continue;
658
+ }
659
+ // `UpdatedAt` is the fallback an entity whose view predates the `__mj_` prefix carries.
660
+ // Both cache-write funnels must agree on the stamp they compute, or the same rows get
661
+ // different slot timestamps depending on which path wrote them and every currency check
662
+ // between them misfires.
663
+ const record = row;
664
+ const raw = record['__mj_UpdatedAt'] !== undefined ? record['__mj_UpdatedAt'] : record['UpdatedAt'];
665
+ if (raw === undefined) {
666
+ continue;
667
+ }
668
+ sawColumn = true;
669
+ if (raw === null) {
670
+ continue; // new Date(null) is the epoch, not "no timestamp"
671
+ }
672
+ const ms = raw instanceof Date ? raw.getTime() : new Date(raw).getTime();
673
+ if (!Number.isNaN(ms) && ms > max) {
674
+ max = ms;
675
+ }
676
+ }
677
+ if (rows.length === 0) {
678
+ return '';
679
+ }
680
+ if (!sawColumn) {
681
+ return undefined;
682
+ }
683
+ return max === Number.NEGATIVE_INFINITY ? '' : new Date(max).toISOString();
684
+ }
685
+ /**
686
+ * Storage write options for a RunView slot: the entity name as index group, so a shared
687
+ * provider can find the slot from any process, and the time left before the slot's own expiry
688
+ * (external-data-source entities), so a rewrite does not extend it.
689
+ *
690
+ * @param fingerprint - The slot key
691
+ * @param expiresAt - Absolute expiry (ms since epoch), when the slot has one
692
+ */
693
+ runViewWriteOptions(fingerprint, expiresAt) {
694
+ const options = {};
695
+ const entity = this.extractEntityFromFingerprint(fingerprint);
696
+ if (entity) {
697
+ options.IndexGroup = entity;
698
+ }
699
+ if (expiresAt) {
700
+ options.TTLSeconds = Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000));
701
+ }
702
+ return options;
703
+ }
704
+ /** Storage write options for any registry entry; only RunView slots are indexed. */
705
+ writeOptionsForEntry(entry) {
706
+ if (entry.type === 'runview') {
707
+ return this.runViewWriteOptions(entry.key, entry.expiresAt);
708
+ }
709
+ return entry.expiresAt ? { TTLSeconds: Math.max(1, Math.ceil((entry.expiresAt - Date.now()) / 1000)) } : undefined;
710
+ }
568
711
  /**
569
712
  * Adds a fingerprint to the entity→fingerprint reverse index.
570
713
  * Called when a RunView result is cached.
@@ -602,13 +745,31 @@ export class LocalCacheManager extends BaseSingleton {
602
745
  return this._entityFingerprintIndex.get(entityName) ?? new Set();
603
746
  }
604
747
  /**
605
- * Resolves cached fingerprints for an entity, checking the local in-memory
606
- * index first and falling back to the shared storage provider (e.g., Redis)
607
- * when the local index is empty. This handles cross-server scenarios where
608
- * Server A cached RunView results and Server B saves a record — Server B's
609
- * local index is empty but Redis still has the stale cached entries.
748
+ * Resolves cached fingerprints for an entity: this process's own index, plus — on a storage
749
+ * provider shared between processes — the slots other processes wrote.
750
+ *
751
+ * A save on server A has to reach slots server B wrote after A booted, because nothing else
752
+ * will: B only hears about A's save through the slots A rewrites. A shared provider therefore
753
+ * answers from its per-entity index ({@link ILocalStorageProvider.GetIndexGroupKeys}) on every
754
+ * call. A provider without that index is process-local or cannot enumerate cheaply, and keeps
755
+ * the older behaviour: the category is listed only when the local index knows nothing.
610
756
  */
611
757
  async resolveFingerprintsForEntity(entityName) {
758
+ if (this._storageProvider?.GetIndexGroupKeys) {
759
+ const shared = await this._storageProvider.GetIndexGroupKeys(CacheCategory.RunViewCache, entityName);
760
+ // The local index is deliberately NOT pruned against the shared group here. A slot this
761
+ // process wrote that the group no longer lists is one that expired or was cleared
762
+ // elsewhere — and peers may still be holding its rows in memory. Keeping it means the
763
+ // next save invalidates it, which is what publishes the `removed` those peers reload
764
+ // on (pinned by localCacheManager.sharedIndex.test.ts). The invalidation then
765
+ // drops it from the index, so that costs one notice per slot, once — not per save.
766
+ // Reviewed as and rejected on that evidence
767
+ for (const fp of shared) {
768
+ this.addToEntityIndex(fp);
769
+ }
770
+ const merged = this._entityFingerprintIndex.get(entityName);
771
+ return merged && merged.size > 0 ? merged : undefined;
772
+ }
612
773
  const local = this._entityFingerprintIndex.get(entityName);
613
774
  if (local && local.size > 0)
614
775
  return local;
@@ -656,6 +817,9 @@ export class LocalCacheManager extends BaseSingleton {
656
817
  // Only react to completed save and delete events
657
818
  if (entityEvent.type !== 'save' && entityEvent.type !== 'delete')
658
819
  return;
820
+ // Inside an open batch the change is recorded now and applied when the batch closes.
821
+ if (this.bufferIfBatching(entityEvent))
822
+ return;
659
823
  // Fire-and-forget to avoid blocking the save/delete operation
660
824
  this.HandleBaseEntityEvent(entityEvent).catch((err) => {
661
825
  LogError(`LocalCacheManager.HandleBaseEntityEvent error: ${err.message}`);
@@ -721,6 +885,306 @@ export class LocalCacheManager extends BaseSingleton {
721
885
  }));
722
886
  }
723
887
  }
888
+ // ========================================================================
889
+ // ENTITY-EVENT BATCHES
890
+ // ========================================================================
891
+ /**
892
+ * Opens a batch for `owner` (normally a provider), or enters the one already open. Until the
893
+ * matching {@link EndEntityEventBatch}, saves and deletes raised by entities that use `owner`
894
+ * as their provider are recorded instead of applied; closing the batch rewrites each affected
895
+ * slot once. `DatabaseProviderBase.BeginEntityTransaction` opens one for every transaction, so
896
+ * callers rarely need this directly — {@link RunInEntityEventBatch} is the wrapper for bulk
897
+ * writers that do not use a transaction.
898
+ *
899
+ * Every call must be paired with exactly one {@link EndEntityEventBatch}.
900
+ */
901
+ BeginEntityEventBatch(owner) {
902
+ this._entityEventBatches.Open(owner);
903
+ }
904
+ /**
905
+ * Replaces a RunView slot wholesale while holding the cross-process lock for it.
906
+ *
907
+ * The event-driven path already reads-modifies-writes under that lock; a writer that replaces
908
+ * the same slot outside it can land between a peer's read and its write, and whichever `SET`
909
+ * lands second wins — the engine sweep's fresh rows lost, or the peer's delta lost, and either
910
+ * way the losing state is published to the fleet.
911
+ */
912
+ async ReplaceRunViewResultLocked(fingerprint, params, results, maxUpdatedAt, totalRowCount, provider) {
913
+ if (!this._storageProvider || !this._config.enabled) {
914
+ return false;
915
+ }
916
+ // Carry the slot's existing expiry across the replace. An external-data-source slot has one,
917
+ // and a rewrite that dropped it would turn a bounded cache entry into a permanent one
918
+ //.
919
+ const ttlMs = this.remainingTTLForSlot(fingerprint);
920
+ return this.maintainSlotLocked(fingerprint, async () => {
921
+ await this.SetRunViewResult(fingerprint, params, results, maxUpdatedAt, undefined, totalRowCount, provider, ttlMs);
922
+ return true;
923
+ });
924
+ }
925
+ /** Milliseconds left on a slot's expiry, or undefined when it has none. @internal */
926
+ remainingTTLForSlot(fingerprint) {
927
+ const expiresAt = this._registry.get(fingerprint)?.expiresAt;
928
+ if (!expiresAt) {
929
+ return undefined;
930
+ }
931
+ return Math.max(1, expiresAt - Date.now());
932
+ }
933
+ /**
934
+ * Abandons `owner`'s batch at any depth and invalidates every slot it touched.
935
+ *
936
+ * For an owner that cannot settle its batch normally: an independent provider instance being
937
+ * released with a transaction still open, or a transaction handle reset after a failure. The
938
+ * buffered rows may or may not have reached the database, so the affected slots are invalidated
939
+ * rather than written, and the entities stop counting as pending — a batch left open makes
940
+ * every cached read of its entities miss, for the life of the process.
941
+ *
942
+ * Never throws. Safe when no batch is open.
943
+ */
944
+ async AbandonEntityEventBatch(owner) {
945
+ const batch = this._entityEventBatches.Abandon(owner);
946
+ if (!batch) {
947
+ return;
948
+ }
949
+ LogWarning(`LocalCacheManager: an entity-event batch was abandoned with ${batch.Changes.length} buffered change(s) across ${batch.TouchedEntities.size} entit${batch.TouchedEntities.size === 1 ? 'y' : 'ies'} — invalidating them, because whether those rows reached the database is unknown`, 'Cache');
950
+ try {
951
+ await this.invalidateBatchEntities(batch);
952
+ }
953
+ catch (e) {
954
+ LogError(`LocalCacheManager.AbandonEntityEventBatch failed: ${e instanceof Error ? e.message : String(e)}`);
955
+ }
956
+ }
957
+ /**
958
+ * Leaves one level of `owner`'s batch. Closing the outermost level applies the batch: with
959
+ * every level successful, each affected slot is rewritten once with all its changes; if any
960
+ * level failed (a rollback), the affected slots are invalidated instead, so every server reloads
961
+ * them from the database. Never throws.
962
+ *
963
+ * @param owner - The object passed to {@link BeginEntityEventBatch}
964
+ * @param succeeded - False when the work this level covered was rolled back or failed
965
+ */
966
+ async EndEntityEventBatch(owner, succeeded) {
967
+ const batch = this._entityEventBatches.Close(owner, succeeded);
968
+ if (!batch) {
969
+ return;
970
+ }
971
+ try {
972
+ await this.applyEntityEventBatch(batch);
973
+ }
974
+ catch (e) {
975
+ LogError(`LocalCacheManager: applying an entity-event batch failed: ${e instanceof Error ? e.message : String(e)}`);
976
+ }
977
+ }
978
+ /**
979
+ * Claims a named lease on the storage provider for `ttlMs`: on a shared store only one process
980
+ * gets it per period; on a private store the caller always does. A lease that cannot be
981
+ * checked is reported as not claimed.
982
+ */
983
+ async TryAcquireSharedLease(name, ttlMs) {
984
+ return (await this.claimLease(name, ttlMs)) === 'claimed';
985
+ }
986
+ /** One lease attempt: claimed, held by another process, or the store could not be asked. */
987
+ async claimLease(name, ttlMs) {
988
+ const provider = this._storageProvider;
989
+ if (!provider?.TryAcquireLease) {
990
+ return 'claimed';
991
+ }
992
+ try {
993
+ return (await provider.TryAcquireLease(name, ttlMs)) ? 'claimed' : 'held';
994
+ }
995
+ catch (e) {
996
+ LogWarning(`LocalCacheManager: could not claim lease "${name}" (${e instanceof Error ? e.message : String(e)})`, 'Cache');
997
+ return 'error';
998
+ }
999
+ }
1000
+ /**
1001
+ * Extends a lease this process already holds, so long-running work keeps its turn. Falls back
1002
+ * to re-claiming when the store has no dedicated renewal, which is a no-op for anyone else's
1003
+ * lease because the claim is `SET NX`. Never throws.
1004
+ *
1005
+ * @returns true when the lease is still (or again) held by this process.
1006
+ */
1007
+ async RenewSharedLease(name, ttlMs) {
1008
+ const provider = this._storageProvider;
1009
+ if (!provider?.TryAcquireLease) {
1010
+ return true; // no shared store: this process is the only one, and holds it by default
1011
+ }
1012
+ try {
1013
+ const renew = provider.RenewLease;
1014
+ if (renew) {
1015
+ return await renew.call(provider, name, ttlMs);
1016
+ }
1017
+ return await provider.TryAcquireLease(name, ttlMs);
1018
+ }
1019
+ catch (e) {
1020
+ LogWarning(`LocalCacheManager: could not renew lease "${name}" (${e instanceof Error ? e.message : String(e)})`, 'Cache');
1021
+ return false;
1022
+ }
1023
+ }
1024
+ /** Ends a lease claimed with {@link TryAcquireSharedLease}. Never throws. */
1025
+ async ReleaseSharedLease(name) {
1026
+ try {
1027
+ await this._storageProvider?.ReleaseLease?.(name);
1028
+ }
1029
+ catch (e) {
1030
+ LogWarning(`LocalCacheManager: could not release lease "${name}" (${e instanceof Error ? e.message : String(e)})`, 'Cache');
1031
+ }
1032
+ }
1033
+ /**
1034
+ * Waits until this process claims the lease, polling every `pollMs`, for at most `maxWaitMs`.
1035
+ * @returns Whether the lease was claimed, and how long the wait took.
1036
+ */
1037
+ async WaitForSharedLease(name, ttlMs, maxWaitMs, pollMs = 100) {
1038
+ const started = Date.now();
1039
+ for (;;) {
1040
+ const outcome = await this.claimLease(name, ttlMs);
1041
+ if (outcome !== 'held') {
1042
+ // An unreachable store coordinates nothing, so waiting on it would only delay the caller.
1043
+ return { Acquired: outcome === 'claimed', WaitedMs: Date.now() - started };
1044
+ }
1045
+ if (Date.now() - started >= maxWaitMs) {
1046
+ return { Acquired: false, WaitedMs: Date.now() - started };
1047
+ }
1048
+ await new Promise(resolve => setTimeout(resolve, pollMs));
1049
+ }
1050
+ }
1051
+ /** True while a batch is open for `owner`. */
1052
+ IsBatchingEntityEvents(owner) {
1053
+ return this._entityEventBatches.Find(owner) !== undefined;
1054
+ }
1055
+ /**
1056
+ * True when a batch took `entityEvent` as it was raised, so closing that batch maintains every
1057
+ * slot indexed for the entity. A listener that reaches its cache write only after an `await`
1058
+ * asks this instead of {@link IsBatchingEntityEvents}: by then the batch may have closed, and
1059
+ * a write of its own would rewrite the slot once per event.
1060
+ */
1061
+ WasTakenByEntityEventBatch(entityEvent) {
1062
+ return this._eventsTakenByBatch.has(entityEvent);
1063
+ }
1064
+ /**
1065
+ * Runs `work` inside an entity-event batch for `owner` and applies the batch when it finishes
1066
+ * (as a failure when `work` throws). Use for bulk writes that do not already run in a
1067
+ * transaction.
1068
+ */
1069
+ async RunInEntityEventBatch(owner, work) {
1070
+ this.BeginEntityEventBatch(owner);
1071
+ let succeeded = false;
1072
+ try {
1073
+ const result = await work();
1074
+ succeeded = true;
1075
+ return result;
1076
+ }
1077
+ finally {
1078
+ await this.EndEntityEventBatch(owner, succeeded);
1079
+ }
1080
+ }
1081
+ /**
1082
+ * Records a save/delete in the batch open for the entity's provider.
1083
+ * @returns True when the event was taken by a batch (or needs no cache work at all).
1084
+ */
1085
+ bufferIfBatching(entityEvent) {
1086
+ const owner = entityEvent.baseEntity?.ProviderToUse;
1087
+ if (!this._entityEventBatches.Find(owner)) {
1088
+ return false;
1089
+ }
1090
+ const taken = this._entityEventBatches.Record(owner, this.captureEntityChange(entityEvent));
1091
+ if (taken) {
1092
+ this._eventsTakenByBatch.add(entityEvent);
1093
+ }
1094
+ return taken;
1095
+ }
1096
+ /**
1097
+ * True when `fingerprint`'s entity has changes waiting in an open batch. Such a slot is behind
1098
+ * what the unit of work has written, so reads treat it as a miss (the database answers,
1099
+ * including the uncommitted rows for a reader inside the transaction) and fills are not stored.
1100
+ */
1101
+ isPendingInBatch(fingerprint) {
1102
+ const entityName = this.extractEntityFromFingerprint(fingerprint);
1103
+ return entityName !== null && this._entityEventBatches.HasPendingChanges(entityName);
1104
+ }
1105
+ /**
1106
+ * The change a save/delete event describes, captured now — the entity object can change (or,
1107
+ * after a delete, be reset) before a batch closes. Null when the event needs no cache work.
1108
+ */
1109
+ captureEntityChange(entityEvent) {
1110
+ const entityInfo = entityEvent.baseEntity?.EntityInfo;
1111
+ if (!entityInfo?.Name || !this.IsCachingEnabledForEntity(entityInfo) || !entityInfo.PrimaryKeys?.length) {
1112
+ return null;
1113
+ }
1114
+ if (entityEvent.type !== 'save' && entityEvent.type !== 'delete') {
1115
+ return null;
1116
+ }
1117
+ // A delete resets the entity right after raising the event; its payload keeps the old values.
1118
+ const payload = entityEvent.payload;
1119
+ const record = (entityEvent.type === 'delete' && payload?.OldValues)
1120
+ ? payload.OldValues
1121
+ : entityEvent.baseEntity.GetAll();
1122
+ const key = new CompositeKey();
1123
+ key.LoadFromEntityInfoAndRecord(entityInfo, record);
1124
+ if (key.KeyValuePairs.length === 0 || key.KeyValuePairs.some(kv => kv.Value == null)) {
1125
+ return null;
1126
+ }
1127
+ return { EntityInfo: entityInfo, Type: entityEvent.type, Key: key, Record: record };
1128
+ }
1129
+ /** Applies a closed batch; see {@link EndEntityEventBatch}. */
1130
+ async applyEntityEventBatch(batch) {
1131
+ if (!batch.CanApplyRows) {
1132
+ await this.invalidateBatchEntities(batch);
1133
+ return;
1134
+ }
1135
+ const byEntity = new Map();
1136
+ for (const change of batch.Changes) {
1137
+ const list = byEntity.get(change.EntityInfo.Name) ?? [];
1138
+ list.push(change);
1139
+ byEntity.set(change.EntityInfo.Name, list);
1140
+ }
1141
+ for (const [entityName, changes] of byEntity) {
1142
+ await this.applyEntityChanges(entityName, changes);
1143
+ }
1144
+ }
1145
+ /** Invalidates every entity a batch touched — for a batch that failed or was abandoned. */
1146
+ async invalidateBatchEntities(batch) {
1147
+ for (const entityName of batch.TouchedEntities.keys()) {
1148
+ await this.InvalidateEntityCaches(entityName);
1149
+ }
1150
+ }
1151
+ /** Applies one entity's buffered changes to every slot indexed for it, each slot once. */
1152
+ async applyEntityChanges(entityName, changes) {
1153
+ const fingerprints = await this.resolveFingerprintsForEntity(entityName);
1154
+ if (!fingerprints || fingerprints.size === 0) {
1155
+ return;
1156
+ }
1157
+ const snapshot = [...fingerprints];
1158
+ const nowISO = new Date().toISOString();
1159
+ const BATCH_SIZE = 8;
1160
+ for (let i = 0; i < snapshot.length; i += BATCH_SIZE) {
1161
+ await Promise.all(snapshot.slice(i, i + BATCH_SIZE).map(async (fingerprint) => {
1162
+ try {
1163
+ await this.applyEntityChangesToSlot(fingerprint, changes, nowISO);
1164
+ }
1165
+ catch (err) {
1166
+ LogError(`LocalCacheManager: failed to apply batched changes to "${fingerprint}": ${err.message}`);
1167
+ }
1168
+ }));
1169
+ }
1170
+ }
1171
+ /**
1172
+ * One slot, several changes: the same decisions {@link processEntityEventForFingerprint} makes
1173
+ * per event, made once for the whole list.
1174
+ */
1175
+ async applyEntityChangesToSlot(fingerprint, changes, nowISO) {
1176
+ const hasSave = changes.some(c => c.Type === 'save');
1177
+ // Subset and aggregate slots cannot be maintained in place; filtered slots cannot take an
1178
+ // upsert (the new row may not match the filter) but can drop deleted rows.
1179
+ if (this.isSubsetFingerprint(fingerprint) || this.hasAggregates(fingerprint.split('|'))
1180
+ || (hasSave && this.isFilteredFingerprint(fingerprint))) {
1181
+ await this.InvalidateRunViewResult(fingerprint);
1182
+ return;
1183
+ }
1184
+ const rowChanges = changes.map(c => ({ Key: c.Key, Row: c.Type === 'save' ? c.Record : null }));
1185
+ const maintained = await this.ApplyRowChanges(fingerprint, rowChanges, nowISO);
1186
+ await this.invalidateIfNotMaintained(fingerprint, maintained);
1187
+ }
724
1188
  /**
725
1189
  * Handles remote-invalidate events that include recordData (the saved entity as JSON).
726
1190
  * Updates all cached RunView results for the entity without a server round-trip.
@@ -928,13 +1392,15 @@ export class LocalCacheManager extends BaseSingleton {
928
1392
  }
929
1393
  else if (eventType === 'delete') {
930
1394
  LogStatusVerbose(`LocalCacheManager: Removing entity ${keyStr} from cache "${fingerprint.substring(0, 60)}"`);
931
- await this.RemoveSingleEntity(fingerprint, key, nowISO);
1395
+ const maintained = await this.RemoveSingleEntity(fingerprint, key, nowISO);
1396
+ await this.invalidateIfNotMaintained(fingerprint, maintained);
932
1397
  }
933
1398
  else if (!this.isFilteredFingerprint(fingerprint)) {
934
1399
  // Unfiltered cache: update the record in place
935
1400
  LogStatusVerbose(`LocalCacheManager: Upserting entity ${keyStr} in unfiltered cache "${fingerprint.substring(0, 60)}"`);
936
1401
  const entityData = baseEntity.GetAll();
937
- await this.UpsertSingleEntity(fingerprint, entityData, key, nowISO);
1402
+ const maintained = await this.UpsertSingleEntity(fingerprint, entityData, key, nowISO);
1403
+ await this.invalidateIfNotMaintained(fingerprint, maintained);
938
1404
  }
939
1405
  else {
940
1406
  // Filtered cache: conservatively invalidate (can't verify filter match)
@@ -942,6 +1408,26 @@ export class LocalCacheManager extends BaseSingleton {
942
1408
  await this.InvalidateRunViewResult(fingerprint);
943
1409
  }
944
1410
  }
1411
+ /**
1412
+ * Handles a save/delete that could not be applied to a slot this process indexed — in practice
1413
+ * because the slot expired or was removed by someone else.
1414
+ *
1415
+ * Rewriting the slot is how peer servers hear about the change: their engines hold the rows in
1416
+ * memory and never read the slot again, so a slot that expired while they were running leaves
1417
+ * nothing to rewrite and the change would never reach them. Invalidating instead publishes a
1418
+ * `removed` event on a shared provider, which makes every peer engine tracking the slot reload
1419
+ * it from the database. It also drops the dead fingerprint from this process's index.
1420
+ *
1421
+ * @param fingerprint - The slot the event was applied to
1422
+ * @param maintained - What the in-place upsert/remove reported
1423
+ */
1424
+ async invalidateIfNotMaintained(fingerprint, maintained) {
1425
+ if (maintained) {
1426
+ return;
1427
+ }
1428
+ LogStatusVerbose(`LocalCacheManager: slot "${fingerprint.substring(0, 60)}" could not be maintained in place — invalidating so peers reload`);
1429
+ await this.InvalidateRunViewResult(fingerprint);
1430
+ }
945
1431
  /**
946
1432
  * Registers a callback that fires when a specific cache fingerprint is updated
947
1433
  * by another server instance. Returns an unsubscribe function to remove the callback.
@@ -988,9 +1474,8 @@ export class LocalCacheManager extends BaseSingleton {
988
1474
  * fingerprint. Called by infrastructure code (e.g., {@link RedisLocalStorageProvider})
989
1475
  * when another server modifies a cached entry.
990
1476
  *
991
- * For `category_cleared` events, dispatches to ALL registered callbacks whose
992
- * fingerprints belong to the cleared category (matched by the event's CacheKey
993
- * which contains the category name).
1477
+ * For `category_cleared` events on the RunView category, dispatches to ALL registered
1478
+ * callbacks (they all watch RunView slots); a clear of any other category reaches none.
994
1479
  *
995
1480
  * Errors in individual callbacks are caught and logged via {@link LogError}
996
1481
  * to prevent one bad callback from blocking others.
@@ -1001,9 +1486,13 @@ export class LocalCacheManager extends BaseSingleton {
1001
1486
  const sourceShort = event.SourceServerId ? event.SourceServerId.substring(0, 8) : 'unknown';
1002
1487
  LogStatusVerbose(`LocalCacheManager: DispatchCacheChange received — action="${event.Action}", key="${event.CacheKey}", source="${sourceShort}"`);
1003
1488
  if (event.Action === 'category_cleared') {
1004
- // For category-level clearing, notify ALL registered callbacks
1005
- // since we can't know which fingerprints belong to which category
1006
- // without parsing them. This is a rare operation so the overhead is acceptable.
1489
+ // Every registered callback watches a RunView slot (engine configs and RunView
1490
+ // OnDataChanged both key on RunView fingerprints), so only clearing that category
1491
+ // affects them. Notifying them for a Metadata or Dataset clear as well would make a
1492
+ // tool that clears several categories reload every engine once per category.
1493
+ if (event.Category !== CacheCategory.RunViewCache) {
1494
+ return;
1495
+ }
1007
1496
  for (const [, callbacks] of this._changeCallbacks) {
1008
1497
  for (const cb of callbacks) {
1009
1498
  try {
@@ -1282,6 +1771,15 @@ export class LocalCacheManager extends BaseSingleton {
1282
1771
  if (params.IgnoreMaxRows === true) {
1283
1772
  parts.push('imr:1');
1284
1773
  }
1774
+ // Binary-width segment. Cached slots are fetched at full width, but "full width" leaves
1775
+ // binary columns out unless the caller asks for them (RunViewParams.IncludeBinaryFields,
1776
+ // set automatically by ProviderBase when a caller names a binary field in Fields). A slot
1777
+ // without binary columns must never answer a request that needs them, so the two widths
1778
+ // get separate slots. Appended only when true, so every existing fingerprint stays
1779
+ // byte-for-byte identical.
1780
+ if (params.IncludeBinaryFields === true) {
1781
+ parts.push('bin:1');
1782
+ }
1285
1783
  // DataSource segment. RunViewParams.DataSource:'Materialized' routes the read to the entity's
1286
1784
  // materialized snapshot view (GetEffectiveBaseView), a DIFFERENT physical source than the default
1287
1785
  // live base view — so a Live read and a Materialized read of the same entity/filter/orderBy MUST
@@ -1548,6 +2046,12 @@ export class LocalCacheManager extends BaseSingleton {
1548
2046
  LogStatusEx({ message: `[CACHE-WRITE-GATE] Skipping cache write for keyset (AfterKey) query on "${params.EntityName}"`, verboseOnly: true });
1549
2047
  return;
1550
2048
  }
2049
+ // Rows read while a unit of work has unapplied changes for this entity may include its
2050
+ // uncommitted writes (or miss them); either way they must not become the shared slot.
2051
+ if (this.isPendingInBatch(fingerprint)) {
2052
+ LogStatusEx({ message: `[CACHE-WRITE-GATE] Skipping cache write for "${params.EntityName}" — an open batch has changes for it`, verboseOnly: true });
2053
+ return;
2054
+ }
1551
2055
  // Short-circuit: if the entity has AllowCaching = false, do not write to the cache.
1552
2056
  // The invalidation path (HandleBaseEntityEvent line 552) already short-circuits for
1553
2057
  // these entities, so any entry we write here would never be invalidated and would
@@ -1623,7 +2127,7 @@ export class LocalCacheManager extends BaseSingleton {
1623
2127
  // than serving this one query uncached. Always logged (not verbose-gated): an
1624
2128
  // oversized result is a perf smell the operator should be able to see.
1625
2129
  if (this.exceedsMaxEntrySize(sizeBytes)) {
1626
- LogStatusEx({ message: `[CACHE-WRITE-GATE] Skipping cache write for "${params.EntityName || fingerprint.substring(0, 60)}" — estimated entry size ${sizeBytes} bytes exceeds per-entry cap (${this._config.maxEntryPercentOfCache}% of ${this._config.maxSizeBytes} byte budget)` });
2130
+ LogWarning(`[CACHE-WRITE-GATE] Skipping cache write for "${params.EntityName || fingerprint.substring(0, 60)}" — estimated entry size ${sizeBytes} bytes exceeds per-entry cap (${this._config.maxEntryPercentOfCache}% of ${this._config.maxSizeBytes} byte budget)`, 'Cache');
1627
2131
  return;
1628
2132
  }
1629
2133
  // The oversized gate above is the ONLY step that can decline this write, and it is
@@ -1646,7 +2150,8 @@ export class LocalCacheManager extends BaseSingleton {
1646
2150
  await this.evictIfNeeded(sizeBytes);
1647
2151
  try {
1648
2152
  // Native object storage — IDB structured-clones, localStorage / Redis serialize internally.
1649
- await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunViewCache);
2153
+ const expiresAt = ttlMs ? Date.now() + ttlMs : undefined;
2154
+ await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunViewCache, this.runViewWriteOptions(fingerprint, expiresAt));
1650
2155
  this.registerEntry({
1651
2156
  key: fingerprint,
1652
2157
  type: 'runview',
@@ -1666,7 +2171,7 @@ export class LocalCacheManager extends BaseSingleton {
1666
2171
  sizeBytes,
1667
2172
  maxUpdatedAt,
1668
2173
  rowCount: results.length, // Registry still tracks this for display/stats, derived from actual results
1669
- expiresAt: ttlMs ? Date.now() + ttlMs : undefined // time-based expiry (external entities); undefined => event-invalidated (MJ-DB entities)
2174
+ expiresAt // time-based expiry (external entities); undefined => event-invalidated (MJ-DB entities)
1670
2175
  });
1671
2176
  // Maintain entity→fingerprint reverse index for universal cache invalidation
1672
2177
  this.addToEntityIndex(fingerprint);
@@ -1685,6 +2190,14 @@ export class LocalCacheManager extends BaseSingleton {
1685
2190
  * @returns The cached results, maxUpdatedAt, rowCount (derived), and aggregateResults, or null if not found
1686
2191
  */
1687
2192
  async GetRunViewResult(fingerprint) {
2193
+ if (this.isPendingInBatch(fingerprint)) {
2194
+ this._stats.misses++;
2195
+ return null;
2196
+ }
2197
+ return this.readRunViewSlot(fingerprint);
2198
+ }
2199
+ /** {@link GetRunViewResult} without the open-batch check; slot maintenance reads through this. */
2200
+ async readRunViewSlot(fingerprint) {
1688
2201
  if (!this._storageProvider || !this._config.enabled)
1689
2202
  return null;
1690
2203
  try {
@@ -1730,7 +2243,7 @@ export class LocalCacheManager extends BaseSingleton {
1730
2243
  try {
1731
2244
  const raw = await this._storageProvider.GetItems(fingerprints, CacheCategory.RunViewCache);
1732
2245
  for (const [fp, parsed] of raw) {
1733
- out.set(fp, this.materializeCachedRunViewResult(fp, parsed));
2246
+ out.set(fp, this.isPendingInBatch(fp) ? null : this.materializeCachedRunViewResult(fp, parsed));
1734
2247
  }
1735
2248
  return out;
1736
2249
  }
@@ -1773,6 +2286,7 @@ export class LocalCacheManager extends BaseSingleton {
1773
2286
  return null;
1774
2287
  }
1775
2288
  this.recordAccess(fingerprint);
2289
+ this.indexSlotThisProcessDidNotWrite(fingerprint);
1776
2290
  this._stats.hits++;
1777
2291
  const results = parsed.results || [];
1778
2292
  const result = {
@@ -1790,6 +2304,34 @@ export class LocalCacheManager extends BaseSingleton {
1790
2304
  }
1791
2305
  return result;
1792
2306
  }
2307
+ /**
2308
+ * Records a slot this process READ, so a later save can still find it once it has expired.
2309
+ *
2310
+ * An engine loads its rows once and never reads its slot again — that is the design, not a
2311
+ * defect: the slot is a boot shortcut and a propagation channel, and correctness comes from
2312
+ * events. Giving slots a finite expiry opened a window those events could not cross. When a
2313
+ * slot expires under running replicas, a save finds nothing to rewrite and publishes nothing,
2314
+ * so every peer engine keeps serving what it loaded.
2315
+ *
2316
+ * The fix — invalidate a slot that cannot be maintained, which publishes `removed` — only
2317
+ * fires if the saving process still holds the fingerprint, and only the process that WROTE the
2318
+ * slot did. A server that booted warm read it instead, and a read indexed nothing; behind a
2319
+ * load balancer, which server takes the save is arbitrary. A warm read is the only trace such a
2320
+ * process has of that slot, so it is indexed too.
2321
+ *
2322
+ * Gated on the provider exposing a shared per-entity index, which is exactly the case
2323
+ * {@link resolveFingerprintsForEntity} answers from that index — with no category-scan
2324
+ * fallback. On a process-local store there is nothing to gain (no peers to notify, and every
2325
+ * slot in it was written by this process, so it is already indexed) and something to lose: a
2326
+ * non-empty index SUPPRESSES that fallback scan, which is what finds persisted slots the index
2327
+ * does not know about.
2328
+ * @internal
2329
+ */
2330
+ indexSlotThisProcessDidNotWrite(fingerprint) {
2331
+ if (this._storageProvider?.GetIndexGroupKeys) {
2332
+ this.addToEntityIndex(fingerprint);
2333
+ }
2334
+ }
1793
2335
  /**
1794
2336
  * Invalidates a cached RunView result.
1795
2337
  *
@@ -1939,16 +2481,33 @@ export class LocalCacheManager extends BaseSingleton {
1939
2481
  }
1940
2482
  }
1941
2483
  /**
1942
- * Upserts a single entity in a cached RunView result.
1943
- * Used by BaseEngine for immediate cache sync when an entity is saved.
1944
- * If the entity exists (by primary key), it is replaced; otherwise it is added.
1945
- *
1946
- * Serializes async operations on the same cache fingerprint to prevent
1947
- * lost-update races. When multiple entity events fire simultaneously
1948
- * (e.g., 3 deletes from a TransactionGroup), each read-modify-write cycle
1949
- * must complete before the next one starts for the same fingerprint.
1950
- * Different fingerprints run concurrently with no contention.
2484
+ * Runs an in-place slot rewrite under {@link withFingerprintLock}. When the lock itself fails
2485
+ * (a shared lock another server held too long), reports the rewrite as not done rather than
2486
+ * throwing, so the caller's fallback — invalidate the slot, making every server reload —
2487
+ * applies.
1951
2488
  */
2489
+ async maintainSlotLocked(fingerprint, fn) {
2490
+ try {
2491
+ return await this.withFingerprintLock(fingerprint, fn);
2492
+ }
2493
+ catch (e) {
2494
+ // Distinguish "another process holds this slot" (expected contention — the caller
2495
+ // invalidates instead of writing) from a fault inside the work itself, which is a bug
2496
+ // and must be reported as one rather than filed under lock contention. The
2497
+ // storage provider owns those error types, and it depends on this package, so they are
2498
+ // recognised by name rather than imported.
2499
+ const name = e instanceof Error ? e.name : '';
2500
+ const isLockProblem = name === 'KeyLockTimeoutError' || name === 'KeyLockLostError';
2501
+ const detail = e instanceof Error ? e.message : String(e);
2502
+ if (isLockProblem) {
2503
+ LogWarning(`LocalCacheManager: could not hold the lock on "${fingerprint.substring(0, 60)}" for an in-place update (${detail}) — invalidating instead`, 'Cache');
2504
+ }
2505
+ else {
2506
+ LogError(`LocalCacheManager: the in-place update of "${fingerprint.substring(0, 60)}" failed (${detail}) — invalidating instead`);
2507
+ }
2508
+ return false;
2509
+ }
2510
+ }
1952
2511
  async withFingerprintLock(fingerprint, fn) {
1953
2512
  const existing = this._fingerprintLocks.get(fingerprint) ?? Promise.resolve();
1954
2513
  let releaseLock;
@@ -1956,6 +2515,13 @@ export class LocalCacheManager extends BaseSingleton {
1956
2515
  this._fingerprintLocks.set(fingerprint, lockPromise);
1957
2516
  try {
1958
2517
  await existing; // Wait for any previous operation on this fingerprint
2518
+ // Another server may be rewriting the same shared entry right now; its write and ours
2519
+ // would each start from the same old array, and the later SET would drop the other's
2520
+ // row. A shared provider serializes the whole read-modify-write across processes.
2521
+ const provider = this._storageProvider;
2522
+ if (provider?.WithKeyLock) {
2523
+ return await provider.WithKeyLock(fingerprint, CacheCategory.RunViewCache, fn);
2524
+ }
1959
2525
  return await fn();
1960
2526
  }
1961
2527
  finally {
@@ -1967,46 +2533,24 @@ export class LocalCacheManager extends BaseSingleton {
1967
2533
  }
1968
2534
  }
1969
2535
  /**
2536
+ * Upserts a single entity in a cached RunView result.
2537
+ * Used by BaseEngine for immediate cache sync when an entity is saved.
2538
+ * If the entity exists (by primary key), it is replaced; otherwise it is added.
2539
+ *
2540
+ * Serializes async operations on the same cache fingerprint to prevent
2541
+ * lost-update races. When multiple entity events fire simultaneously
2542
+ * (e.g., 3 deletes from a TransactionGroup), each read-modify-write cycle
2543
+ * must complete before the next one starts for the same fingerprint.
2544
+ * Different fingerprints run concurrently with no contention.
2545
+ *
1970
2546
  * @param fingerprint - The cache fingerprint to update
1971
2547
  * @param entityData - The entity data as a plain object (use entity.GetAll())
1972
- * @param primaryKeyFieldName - Name of the primary key field
2548
+ * @param key - The record's primary key
1973
2549
  * @param newMaxUpdatedAt - New maxUpdatedAt timestamp (from entity's __mj_UpdatedAt)
1974
2550
  * @returns true if cache was updated, false if cache not found or update failed
1975
2551
  */
1976
2552
  async UpsertSingleEntity(fingerprint, entityData, key, newMaxUpdatedAt) {
1977
- if (!this._storageProvider || !this._config.enabled)
1978
- return false;
1979
- return this.withFingerprintLock(fingerprint, async () => {
1980
- try {
1981
- const cached = await this.GetRunViewResult(fingerprint);
1982
- if (!cached) {
1983
- LogStatusVerbose(`LocalCacheManager.UpsertSingleEntity: No cached data found for fingerprint "${fingerprint.substring(0, 60)}" — skipping (cache will be populated on next RunView)`);
1984
- return false;
1985
- }
1986
- LogStatusVerbose(`LocalCacheManager.UpsertSingleEntity: Found cached data with ${cached.results.length} rows, updating...`);
1987
- const pkFieldNames = key.KeyValuePairs.map(kv => kv.FieldName);
1988
- const keyStr = this.cheapKeyFromCompositeKey(key);
1989
- // Build a map of existing records by composite key string. Uses a cheap
1990
- // delimiter-joined PK string (no per-row CompositeKey/KeyValuePair allocation);
1991
- // matching is consistent because keyStr above uses the same format.
1992
- const resultMap = new Map();
1993
- for (const row of cached.results) {
1994
- const rowObj = row;
1995
- if (pkFieldNames.some(fn => rowObj[fn] == null))
1996
- continue; // Skip rows with missing PK fields
1997
- resultMap.set(this.cheapRowKey(rowObj, pkFieldNames), row);
1998
- }
1999
- // Upsert the entity (add or replace)
2000
- resultMap.set(keyStr, entityData);
2001
- const updatedResults = Array.from(resultMap.values());
2002
- return await this.storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt, { totalRowCount: cached.totalRowCount, rowCount: cached.results.length, schemaHash: cached.schemaHash,
2003
- providerInternalScaffolding: cached.providerInternalScaffolding });
2004
- }
2005
- catch (e) {
2006
- LogError(`LocalCacheManager.UpsertSingleEntity failed: ${e}`);
2007
- return false;
2008
- }
2009
- });
2553
+ return this.ApplyRowChanges(fingerprint, [{ Key: key, Row: entityData }], newMaxUpdatedAt);
2010
2554
  }
2011
2555
  /**
2012
2556
  * Removes a single entity from a cached RunView result.
@@ -2018,39 +2562,70 @@ export class LocalCacheManager extends BaseSingleton {
2018
2562
  * @returns true if cache was updated, false if cache not found or update failed
2019
2563
  */
2020
2564
  async RemoveSingleEntity(fingerprint, key, newMaxUpdatedAt) {
2021
- if (!this._storageProvider || !this._config.enabled)
2565
+ return this.ApplyRowChanges(fingerprint, [{ Key: key, Row: null }], newMaxUpdatedAt);
2566
+ }
2567
+ /**
2568
+ * Applies several row changes to one cached RunView result in a single read-modify-write:
2569
+ * each change with a `Row` adds or replaces that row, each with `Row: null` removes it, in
2570
+ * order. The slot is written once, and not at all when only removals of absent rows were asked.
2571
+ * All keys must name the same primary-key fields.
2572
+ *
2573
+ * @returns true if the slot now reflects the changes, false if it was not cached or the update failed
2574
+ */
2575
+ async ApplyRowChanges(fingerprint, changes, newMaxUpdatedAt) {
2576
+ if (!this._storageProvider || !this._config.enabled || changes.length === 0)
2022
2577
  return false;
2023
- return this.withFingerprintLock(fingerprint, async () => {
2578
+ return this.maintainSlotLocked(fingerprint, async () => {
2024
2579
  try {
2025
- const cached = await this.GetRunViewResult(fingerprint);
2580
+ const cached = await this.readRunViewSlot(fingerprint);
2026
2581
  if (!cached) {
2582
+ LogStatusVerbose(`LocalCacheManager.ApplyRowChanges: No cached data found for fingerprint "${fingerprint.substring(0, 60)}" — skipping (cache will be populated on next RunView)`);
2027
2583
  return false;
2028
2584
  }
2029
- const pkFieldNames = key.KeyValuePairs.map(kv => kv.FieldName);
2030
- const keyStr = this.cheapKeyFromCompositeKey(key);
2031
- // Build a map of existing records by composite key string (cheap PK keying;
2032
- // see UpsertSingleEntity for the rationale — no per-row CompositeKey allocation).
2033
- const resultMap = new Map();
2034
- for (const row of cached.results) {
2035
- const rowObj = row;
2036
- if (pkFieldNames.some(fn => rowObj[fn] == null))
2037
- continue; // Skip rows with missing PK fields
2038
- resultMap.set(this.cheapRowKey(rowObj, pkFieldNames), row);
2039
- }
2040
- if (!resultMap.has(keyStr)) {
2041
- return true; // Not in cache, no-op
2585
+ const pkFieldNames = changes[0].Key.KeyValuePairs.map(kv => kv.FieldName);
2586
+ const resultMap = this.indexRowsByKey(cached.results, pkFieldNames);
2587
+ if (!this.applyChangesToRowMap(resultMap, changes)) {
2588
+ return true; // only removals of rows the slot does not hold
2042
2589
  }
2043
- resultMap.delete(keyStr);
2044
- const updatedResults = Array.from(resultMap.values());
2045
- return await this.storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt, { totalRowCount: cached.totalRowCount, rowCount: cached.results.length, schemaHash: cached.schemaHash,
2590
+ return await this.storeCachedResults(fingerprint, Array.from(resultMap.values()), newMaxUpdatedAt, { totalRowCount: cached.totalRowCount, rowCount: cached.results.length, schemaHash: cached.schemaHash,
2046
2591
  providerInternalScaffolding: cached.providerInternalScaffolding });
2047
2592
  }
2048
2593
  catch (e) {
2049
- LogError(`LocalCacheManager.RemoveSingleEntity failed: ${e}`);
2594
+ LogError(`LocalCacheManager.ApplyRowChanges failed: ${e}`);
2050
2595
  return false;
2051
2596
  }
2052
2597
  });
2053
2598
  }
2599
+ /**
2600
+ * Rows keyed by a cheap delimiter-joined PK string (no per-row CompositeKey/KeyValuePair
2601
+ * allocation); matching is consistent because {@link cheapKeyFromCompositeKey} uses the same
2602
+ * format. Rows missing a PK value are dropped.
2603
+ */
2604
+ indexRowsByKey(rows, pkFieldNames) {
2605
+ const resultMap = new Map();
2606
+ for (const row of rows) {
2607
+ const rowObj = row;
2608
+ if (pkFieldNames.some(fn => rowObj[fn] == null))
2609
+ continue;
2610
+ resultMap.set(this.cheapRowKey(rowObj, pkFieldNames), row);
2611
+ }
2612
+ return resultMap;
2613
+ }
2614
+ /** Applies `changes` to `rows` in order. @returns True when anything changed. */
2615
+ applyChangesToRowMap(rows, changes) {
2616
+ let changed = false;
2617
+ for (const change of changes) {
2618
+ const keyStr = this.cheapKeyFromCompositeKey(change.Key);
2619
+ if (change.Row) {
2620
+ rows.set(keyStr, change.Row);
2621
+ changed = true;
2622
+ }
2623
+ else if (rows.delete(keyStr)) {
2624
+ changed = true;
2625
+ }
2626
+ }
2627
+ return changed;
2628
+ }
2054
2629
  /**
2055
2630
  * Stores updated results array back to the cache and updates the registry.
2056
2631
  * Shared by UpsertSingleEntity and RemoveSingleEntity to avoid duplication.
@@ -2066,9 +2641,14 @@ export class LocalCacheManager extends BaseSingleton {
2066
2641
  * correct total.
2067
2642
  */
2068
2643
  async storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt, prior) {
2644
+ // Stamp the slot the way the fill path does — the newest row's __mj_UpdatedAt — so the
2645
+ // stamp means the same thing whichever funnel wrote the slot and matches what the database
2646
+ // reports for the same rows (the client smart-cache check compares the two). The caller's
2647
+ // value (the event time) is used only when the rows carry no timestamp column.
2648
+ const maxUpdatedAt = LocalCacheManager.MaxUpdatedAtOfRows(updatedResults) ?? newMaxUpdatedAt;
2069
2649
  const data = {
2070
2650
  results: updatedResults,
2071
- maxUpdatedAt: newMaxUpdatedAt
2651
+ maxUpdatedAt
2072
2652
  };
2073
2653
  // Carry the schemaHash FORWARD — never recompute it here (B38).
2074
2654
  //
@@ -2107,10 +2687,10 @@ export class LocalCacheManager extends BaseSingleton {
2107
2687
  if (!prior?.providerInternalScaffolding) {
2108
2688
  this.freezeRowDataIfProviderSharesReferences(data);
2109
2689
  }
2110
- await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunViewCache);
2111
2690
  const existingEntry = this._registry.get(fingerprint);
2691
+ await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunViewCache, this.runViewWriteOptions(fingerprint, existingEntry?.expiresAt));
2112
2692
  if (existingEntry) {
2113
- existingEntry.maxUpdatedAt = newMaxUpdatedAt;
2693
+ existingEntry.maxUpdatedAt = maxUpdatedAt;
2114
2694
  existingEntry.rowCount = updatedResults.length;
2115
2695
  existingEntry.sizeBytes = sizeBytes;
2116
2696
  existingEntry.lastAccessedAt = Date.now();
@@ -2205,7 +2785,7 @@ export class LocalCacheManager extends BaseSingleton {
2205
2785
  // Oversized-entry gate — same rationale as SetRunViewResult: never wipe the
2206
2786
  // cache to make room for an entry that can't be retained within budget.
2207
2787
  if (this.exceedsMaxEntrySize(sizeBytes)) {
2208
- LogStatusEx({ message: `[CACHE-WRITE-GATE] Skipping cache write for query "${queryName}" — estimated entry size ${sizeBytes} bytes exceeds per-entry cap (${this._config.maxEntryPercentOfCache}% of ${this._config.maxSizeBytes} byte budget)` });
2788
+ LogWarning(`[CACHE-WRITE-GATE] Skipping cache write for query "${queryName}" — estimated entry size ${sizeBytes} bytes exceeds per-entry cap (${this._config.maxEntryPercentOfCache}% of ${this._config.maxSizeBytes} byte budget)`, 'Cache');
2209
2789
  return;
2210
2790
  }
2211
2791
  // Same reference-sharing exposure as the RunView path (GetRunQueryResult hands
@@ -2218,10 +2798,17 @@ export class LocalCacheManager extends BaseSingleton {
2218
2798
  // Check if we need to evict entries. Evicts OTHER entries only — cannot cancel this write.
2219
2799
  await this.evictIfNeeded(sizeBytes);
2220
2800
  const now = Date.now();
2221
- const expiresAt = ttlMs ? now + ttlMs : undefined;
2801
+ // Keep the expiry a rewrite found, exactly as the RunView path does: recomputing it from
2802
+ // `now` on every write pushes a bounded entry's expiry forward for as long as anything keeps
2803
+ // rewriting it, so it never expires at all.
2804
+ const existingExpiry = this._registry.get(fingerprint)?.expiresAt;
2805
+ const expiresAt = existingExpiry ?? (ttlMs ? now + ttlMs : undefined);
2222
2806
  try {
2223
- // Native object storage — no JSON.stringify on the hot path.
2224
- await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunQueryCache);
2807
+ // The expiry must reach the STORE, not just this process's registry: another server
2808
+ // reading this slot has no registry entry for it, so a store-side TTL is the only thing
2809
+ // that bounds it there. Without it an external-data-source query result was served
2810
+ // forever by every server except the one that wrote it.
2811
+ await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunQueryCache, expiresAt ? { TTLSeconds: Math.max(1, Math.ceil((expiresAt - now) / 1000)) } : undefined);
2225
2812
  this.registerEntry({
2226
2813
  key: fingerprint,
2227
2814
  type: 'runquery',
@@ -2506,11 +3093,21 @@ export class LocalCacheManager extends BaseSingleton {
2506
3093
  // Don't persist on every access - too expensive
2507
3094
  }
2508
3095
  }
3096
+ /**
3097
+ * True when the registry must stay in this process: the storage is shared with other
3098
+ * processes, so a persisted registry would be one key every server overwrites with its own
3099
+ * view (and publishes to every peer on each write), and a booting server would adopt another
3100
+ * server's accounting. Entries written by other processes are found through the provider's
3101
+ * per-entity index instead ({@link resolveFingerprintsForEntity}).
3102
+ */
3103
+ registryIsProcessLocalOnly() {
3104
+ return this._storageProvider?.SharedAcrossProcesses === true;
3105
+ }
2509
3106
  /**
2510
3107
  * Loads the registry from storage.
2511
3108
  */
2512
3109
  async loadRegistry() {
2513
- if (!this._storageProvider)
3110
+ if (!this._storageProvider || this.registryIsProcessLocalOnly())
2514
3111
  return;
2515
3112
  try {
2516
3113
  // Native object read — registry is a plain CacheEntryInfo[] array.
@@ -2545,7 +3142,7 @@ export class LocalCacheManager extends BaseSingleton {
2545
3142
  * Persists the registry to storage.
2546
3143
  */
2547
3144
  async persistRegistry() {
2548
- if (!this._storageProvider)
3145
+ if (!this._storageProvider || this.registryIsProcessLocalOnly())
2549
3146
  return;
2550
3147
  try {
2551
3148
  // Native object storage — store the entries array directly.
@@ -2779,8 +3376,26 @@ export class LocalCacheManager extends BaseSingleton {
2779
3376
  if (!this._storageProvider || !this._config.enabled)
2780
3377
  return;
2781
3378
  const now = Date.now();
2782
- const ttlMs = this._config.defaultTTLMs;
3379
+ // On a shared store this process must not age out entries by its OWN clock: every server
3380
+ // would delete the same keys on its own schedule and publish a `removed` for each, and the
3381
+ // peers would reload every affected engine — a fleet-wide deletion and reload storm for
3382
+ // entries the store already expires itself (the Redis provider sets a per-key TTL). The
3383
+ // local TTL stays in force for process-private stores, where nothing else can expire them.
3384
+ const sharedStore = this._storageProvider.SharedAcrossProcesses === true;
3385
+ const ttlMs = sharedStore ? 0 : this._config.defaultTTLMs;
3386
+ if (sharedStore && this._config.defaultTTLMs > 0 && !this._sharedTTLWarned) {
3387
+ this._sharedTTLWarned = true;
3388
+ LogWarning(`LocalCacheManager: defaultTTLSeconds is set but the cache store is shared across processes — expiry is left to the store itself (set the store's own TTL, e.g. REDIS_TTL_SECONDS / cacheSettings.sharedCacheTTLSeconds)`, 'Cache');
3389
+ }
2783
3390
  const toDelete = [];
3391
+ // Entries to drop from this process's bookkeeping WITHOUT touching the store. An entry that
3392
+ // carries its own `expiresAt` always handed the store the matching TTL when it was written,
3393
+ // so a shared store expires the key itself. Deleting it here as well means every server
3394
+ // removes the same key on its own clock and publishes a `removed` for it, and every peer
3395
+ // reloads the affected engine — the storm the TTL branch above already avoids, in miniature.
3396
+ // Forgetting it locally still matters: otherwise this registry records slots the store no
3397
+ // longer has.
3398
+ const toForget = [];
2784
3399
  for (const [key, entry] of this._registry) {
2785
3400
  // TTL expiry check
2786
3401
  if (ttlMs > 0 && entry.cachedAt + ttlMs < now) {
@@ -2789,29 +3404,38 @@ export class LocalCacheManager extends BaseSingleton {
2789
3404
  }
2790
3405
  // expiresAt check (if set individually)
2791
3406
  if (entry.expiresAt && entry.expiresAt < now) {
2792
- toDelete.push(key);
3407
+ (sharedStore ? toForget : toDelete).push(key);
2793
3408
  }
2794
3409
  }
2795
- if (toDelete.length > 0) {
3410
+ if (toDelete.length > 0 || toForget.length > 0) {
2796
3411
  if (this._config.verboseLogging) {
2797
- LogStatusEx({ message: ` 🗑️ [Cache SWEEP] Evicting ${toDelete.length} TTL-expired entries`, verboseOnly: true });
3412
+ LogStatusEx({ message: ` 🗑️ [Cache SWEEP] Evicting ${toDelete.length} TTL-expired entries` +
3413
+ (toForget.length > 0 ? `, forgetting ${toForget.length} the shared store expires itself` : ''), verboseOnly: true });
2798
3414
  }
2799
3415
  for (const key of toDelete) {
2800
3416
  try {
2801
3417
  const entry = this._registry.get(key);
2802
3418
  const category = this.getCategoryForType(entry?.type);
2803
3419
  await this._storageProvider.Remove(key, category);
2804
- if (entry?.fingerprint) {
2805
- this.removeFromEntityIndex(entry.fingerprint);
2806
- }
2807
- this._registry.delete(key);
3420
+ this.forgetRegistryEntry(key);
2808
3421
  }
2809
3422
  catch {
2810
3423
  // Continue
2811
3424
  }
2812
3425
  }
3426
+ for (const key of toForget) {
3427
+ this.forgetRegistryEntry(key);
3428
+ }
2813
3429
  await this.persistRegistry();
2814
3430
  }
2815
3431
  }
3432
+ /** Drops one entry from the registry and the fingerprint index, leaving the store alone. @internal */
3433
+ forgetRegistryEntry(key) {
3434
+ const entry = this._registry.get(key);
3435
+ if (entry?.fingerprint) {
3436
+ this.removeFromEntityIndex(entry.fingerprint);
3437
+ }
3438
+ this._registry.delete(key);
3439
+ }
2816
3440
  }
2817
3441
  //# sourceMappingURL=localCacheManager.js.map