@memberjunction/core 6.1.5 → 6.1.6

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 (36) hide show
  1. package/dist/generic/baseEngine.d.ts +21 -0
  2. package/dist/generic/baseEngine.d.ts.map +1 -1
  3. package/dist/generic/baseEngine.js +60 -5
  4. package/dist/generic/baseEngine.js.map +1 -1
  5. package/dist/generic/baseEntity.d.ts +2 -1
  6. package/dist/generic/baseEntity.d.ts.map +1 -1
  7. package/dist/generic/baseEntity.js +3 -4
  8. package/dist/generic/baseEntity.js.map +1 -1
  9. package/dist/generic/databaseProviderBase.d.ts +41 -8
  10. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  11. package/dist/generic/databaseProviderBase.js +107 -42
  12. package/dist/generic/databaseProviderBase.js.map +1 -1
  13. package/dist/generic/entityRecordNameCache.d.ts +48 -0
  14. package/dist/generic/entityRecordNameCache.d.ts.map +1 -0
  15. package/dist/generic/entityRecordNameCache.js +92 -0
  16. package/dist/generic/entityRecordNameCache.js.map +1 -0
  17. package/dist/generic/interfaces.d.ts +39 -4
  18. package/dist/generic/interfaces.d.ts.map +1 -1
  19. package/dist/generic/interfaces.js.map +1 -1
  20. package/dist/generic/localCacheManager.d.ts +105 -11
  21. package/dist/generic/localCacheManager.d.ts.map +1 -1
  22. package/dist/generic/localCacheManager.js +221 -40
  23. package/dist/generic/localCacheManager.js.map +1 -1
  24. package/dist/generic/providerBase.d.ts +18 -22
  25. package/dist/generic/providerBase.d.ts.map +1 -1
  26. package/dist/generic/providerBase.js +26 -103
  27. package/dist/generic/providerBase.js.map +1 -1
  28. package/dist/generic/queryInfo.d.ts +14 -2
  29. package/dist/generic/queryInfo.d.ts.map +1 -1
  30. package/dist/generic/queryInfo.js +18 -5
  31. package/dist/generic/queryInfo.js.map +1 -1
  32. package/dist/index.d.ts +1 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1 -0
  35. package/dist/index.js.map +1 -1
  36. package/package.json +3 -3
@@ -118,6 +118,11 @@ export class LocalCacheManager extends BaseSingleton {
118
118
  static get Instance() {
119
119
  return super.getInstance();
120
120
  }
121
+ /**
122
+ * How often, at most, a process writes an entity's shared slots while it keeps finding that
123
+ * entity's slots under a schema different from its own. See {@link handleSchemaMismatch}.
124
+ */
125
+ static { this.SCHEMA_MISMATCH_WRITE_INTERVAL_MS = 60_000; }
121
126
  constructor() {
122
127
  super();
123
128
  this._storageProvider = null;
@@ -139,6 +144,14 @@ export class LocalCacheManager extends BaseSingleton {
139
144
  * (e.g., TransactionGroup batch deletes).
140
145
  */
141
146
  this._fingerprintLocks = new Map();
147
+ /**
148
+ * Write holds on entities whose shared slots were found under a different schema, keyed by
149
+ * lowercased entity name. While a hold applies, {@link SetRunViewResult} skips or rate-limits
150
+ * writes of that entity's slots. See {@link handleSchemaMismatch} and {@link mayWriteUnderSchemaHold}.
151
+ */
152
+ this._schemaWriteHolds = new Map();
153
+ /** Schema mismatches already reported, so each entity/hash pair is logged once per process. */
154
+ this._reportedSchemaMismatches = new Set();
142
155
  this.REGISTRY_KEY = '__MJ_CACHE_REGISTRY__';
143
156
  /**
144
157
  * Reverse index from entity name to the set of RunView cache fingerprints
@@ -540,30 +553,134 @@ export class LocalCacheManager extends BaseSingleton {
540
553
  return Number.isFinite(startRow) && startRow > 0;
541
554
  }
542
555
  /**
543
- * Checks whether a cached RunView entry is structurally stale due to a schema change
544
- * (e.g., new columns added via migration + CodeGen). Compares the stored schema hash
545
- * against the current entity field list. If they differ, the entry is invalidated.
556
+ * Compares a cached RunView entry's schema hash against this process's current field list for
557
+ * the entity (a mismatch follows a migration + CodeGen that added, removed or reordered
558
+ * columns). Entries without a hash, and entities this process cannot resolve, never mismatch.
546
559
  * @param fingerprint - The cache fingerprint
547
560
  * @param data - The cached data to validate
548
- * @returns true if the entry is stale and should not be served
561
+ * @returns The mismatch, or null when the entry may be served
549
562
  */
550
- isSchemaStaleCacheEntry(fingerprint, data) {
563
+ findSchemaMismatch(fingerprint, data) {
551
564
  if (!data.schemaHash)
552
- return false;
565
+ return null;
553
566
  const entityName = this.extractEntityFromFingerprint(fingerprint);
554
567
  if (!entityName)
568
+ return null;
569
+ const current = this.computeSchemaSignature(undefined, entityName);
570
+ if (!current || current.Hash === data.schemaHash)
571
+ return null;
572
+ return {
573
+ EntityName: entityName,
574
+ CachedHash: data.schemaHash,
575
+ Current: current,
576
+ EntryIsNewer: LocalCacheManager.isNewerSchemaStamp(data.schemaStamp, current.Stamp),
577
+ };
578
+ }
579
+ /**
580
+ * True only when both stamps are present and the cached entry's is strictly newer than this
581
+ * process's. Equal or missing stamps leave the order unknown, which counts as not newer.
582
+ */
583
+ static isNewerSchemaStamp(entryStamp, currentStamp) {
584
+ if (!entryStamp || !currentStamp) {
555
585
  return false;
556
- const currentHash = this.ComputeSchemaHash(undefined, entityName);
557
- if (!currentHash)
558
- return false;
559
- if (currentHash !== data.schemaHash) {
560
- LogStatusEx({
561
- message: `[CACHE-SCHEMA-STALE] Entity "${entityName}" schema changed (cached=${data.schemaHash}, current=${currentHash})`,
562
- verboseOnly: false
586
+ }
587
+ return LocalCacheManager.timestampMs(entryStamp) > LocalCacheManager.timestampMs(currentStamp);
588
+ }
589
+ /**
590
+ * Decides what a schema mismatch does to the slot. The entry is never served; the question is
591
+ * whether the slot is deleted, which on a shared store also tells every peer to reload.
592
+ *
593
+ * - Process-local store, or a slot-maintenance read: delete it. No other process reads a local
594
+ * store. A maintenance read happens because a row changed, and a slot that cannot take the
595
+ * change in place must go, whichever process wrote it.
596
+ * - Shared store, serving read: leave it, and hold back this process's writes of the entity.
597
+ * Deleting publishes `removed`, every peer engine holding the slot reloads and rewrites it
598
+ * under its own hash, and processes on either side of a migration keep doing that to each
599
+ * other. The caller reads the database instead.
600
+ * - When the slot is known to come from newer metadata, this process is the one out of date.
601
+ * It stops writing the entity's slots until its own metadata changes.
602
+ * - Otherwise it writes the entity's slots at most once per
603
+ * {@link SCHEMA_MISMATCH_WRITE_INTERVAL_MS} while mismatches keep appearing. That still
604
+ * replaces a slot left by older metadata, but a peer running code that deletes mismatched
605
+ * slots (any release before this check stopped deleting them) gets one slot to delete per
606
+ * interval instead of one per read. The hold lapses after an interval with no mismatch.
607
+ */
608
+ handleSchemaMismatch(fingerprint, mismatch, purpose) {
609
+ const shared = this._storageProvider?.SharedAcrossProcesses === true;
610
+ if (!shared || purpose === 'maintain') {
611
+ this.reportSchemaMismatch(mismatch, 'dropping the slot');
612
+ this.InvalidateRunViewResult(fingerprint).catch(() => { });
613
+ return;
614
+ }
615
+ this.holdSchemaWrites(mismatch);
616
+ if (mismatch.EntryIsNewer) {
617
+ this.reportSchemaMismatch(mismatch, 'another process wrote the slot under newer metadata. This process reads the entity from the database ' +
618
+ 'and stops writing its cache slots until its metadata changes. If it started before a migration, restart it.');
619
+ }
620
+ else {
621
+ this.reportSchemaMismatch(mismatch, `reading from the database. While slots under other metadata keep appearing, this process writes the entity's ` +
622
+ `slots at most once every ${LocalCacheManager.SCHEMA_MISMATCH_WRITE_INTERVAL_MS / 1000}s.`);
623
+ }
624
+ }
625
+ /** Records or extends the write hold for a mismatch seen on a shared store. */
626
+ holdSchemaWrites(mismatch) {
627
+ const key = mismatch.EntityName.trim().toLowerCase();
628
+ const now = Date.now();
629
+ const existing = this._schemaWriteHolds.get(key);
630
+ if (mismatch.EntryIsNewer) {
631
+ this._schemaWriteHolds.set(key, { OwnHash: mismatch.Current.Hash, NextWriteAt: Number.POSITIVE_INFINITY, LastMismatchAt: now });
632
+ }
633
+ else if (existing && existing.OwnHash === mismatch.Current.Hash) {
634
+ existing.LastMismatchAt = now;
635
+ }
636
+ else {
637
+ this._schemaWriteHolds.set(key, {
638
+ OwnHash: mismatch.Current.Hash,
639
+ NextWriteAt: now + LocalCacheManager.SCHEMA_MISMATCH_WRITE_INTERVAL_MS,
640
+ LastMismatchAt: now,
563
641
  });
642
+ }
643
+ }
644
+ /** Logs a schema mismatch once per entity and hash pair per process. */
645
+ reportSchemaMismatch(mismatch, outcome) {
646
+ const key = `${mismatch.EntityName.trim().toLowerCase()}|${mismatch.CachedHash}|${mismatch.Current.Hash}`;
647
+ if (this._reportedSchemaMismatches.has(key)) {
648
+ return;
649
+ }
650
+ this._reportedSchemaMismatches.add(key);
651
+ LogStatusEx({
652
+ message: `[CACHE-SCHEMA-STALE] Entity "${mismatch.EntityName}" schema differs from a cached slot ` +
653
+ `(cached=${mismatch.CachedHash}, current=${mismatch.Current.Hash}) — ${outcome}`,
654
+ verboseOnly: false
655
+ });
656
+ }
657
+ /**
658
+ * Whether a slot of `entityName` may be written now, given the entity's write hold (see
659
+ * {@link handleSchemaMismatch}). A permitted write under a rate-limiting hold uses up the
660
+ * interval. The hold is dropped once this process's schema hash changes (a metadata refresh)
661
+ * or, for a rate-limiting hold, once an interval passes with no mismatch.
662
+ */
663
+ mayWriteUnderSchemaHold(entityName, currentHash) {
664
+ if (this._storageProvider?.SharedAcrossProcesses !== true) {
564
665
  return true;
565
666
  }
566
- return false;
667
+ const key = entityName.trim().toLowerCase();
668
+ const hold = this._schemaWriteHolds.get(key);
669
+ if (!hold) {
670
+ return true;
671
+ }
672
+ const now = Date.now();
673
+ const interval = LocalCacheManager.SCHEMA_MISMATCH_WRITE_INTERVAL_MS;
674
+ const lapsed = hold.NextWriteAt !== Number.POSITIVE_INFINITY && now - hold.LastMismatchAt > interval;
675
+ if (hold.OwnHash !== currentHash || lapsed) {
676
+ this._schemaWriteHolds.delete(key);
677
+ return true;
678
+ }
679
+ if (now < hold.NextWriteAt) {
680
+ return false;
681
+ }
682
+ hold.NextWriteAt = now + interval;
683
+ return true;
567
684
  }
568
685
  /**
569
686
  * Adds a fingerprint to the entity→fingerprint reverse index.
@@ -602,21 +719,26 @@ export class LocalCacheManager extends BaseSingleton {
602
719
  return this._entityFingerprintIndex.get(entityName) ?? new Set();
603
720
  }
604
721
  /**
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.
722
+ * Resolves cached fingerprints for an entity: this process's own index, plus the slots the
723
+ * storage provider lists for the entity.
724
+ *
725
+ * On a provider shared between processes (e.g. Redis) the provider is listed on every call
726
+ * and merged into the local index. The local index only knows slots this process wrote, so
727
+ * answering from it alone would miss slots other processes wrote — including a slot this
728
+ * process only read warm at boot — and a save here would never reach the engines that load
729
+ * from those slots on its peers.
730
+ *
731
+ * On a process-local provider (e.g. IndexedDB) the category is listed only when the local
732
+ * index knows nothing for the entity, which picks up slots persisted by an earlier session.
610
733
  */
611
734
  async resolveFingerprintsForEntity(entityName) {
735
+ if (this._storageProvider?.SharedAcrossProcesses === true && this._storageProvider.GetCategoryKeys) {
736
+ return this.resolveFingerprintsFromSharedStore(entityName);
737
+ }
612
738
  const local = this._entityFingerprintIndex.get(entityName);
613
739
  if (local && local.size > 0)
614
740
  return local;
615
- if (!this._storageProvider?.GetCategoryKeys)
616
- return undefined;
617
- const allKeys = await this._storageProvider.GetCategoryKeys(CacheCategory.RunViewCache);
618
- const entityPrefix = entityName + '|';
619
- const remoteFingerprints = allKeys.filter(k => k.startsWith(entityPrefix));
741
+ const remoteFingerprints = await this.listStoredFingerprintsForEntity(entityName);
620
742
  if (remoteFingerprints.length > 0) {
621
743
  LogStatusVerbose(`LocalCacheManager: found ${remoteFingerprints.length} remote cached fingerprint(s) for "${entityName}" via storage provider`);
622
744
  const result = new Set(remoteFingerprints);
@@ -628,6 +750,26 @@ export class LocalCacheManager extends BaseSingleton {
628
750
  }
629
751
  return undefined;
630
752
  }
753
+ /**
754
+ * Merges the slots a shared store lists for the entity into the local index and returns the
755
+ * union. Local entries the store does not list are left in place; invalidation removes them
756
+ * from the index the same way it does for any other slot.
757
+ */
758
+ async resolveFingerprintsFromSharedStore(entityName) {
759
+ for (const fp of await this.listStoredFingerprintsForEntity(entityName)) {
760
+ this.addToEntityIndex(fp);
761
+ }
762
+ const merged = this._entityFingerprintIndex.get(entityName);
763
+ return merged && merged.size > 0 ? merged : undefined;
764
+ }
765
+ /** Lists the RunView slots the storage provider holds for an entity; empty when it cannot list keys. */
766
+ async listStoredFingerprintsForEntity(entityName) {
767
+ if (!this._storageProvider?.GetCategoryKeys)
768
+ return [];
769
+ const allKeys = await this._storageProvider.GetCategoryKeys(CacheCategory.RunViewCache);
770
+ const entityPrefix = entityName + '|';
771
+ return allKeys.filter(k => k.startsWith(entityPrefix));
772
+ }
631
773
  // ========================================================================
632
774
  // UNIVERSAL CACHE INVALIDATION (BaseEntity Events)
633
775
  // ========================================================================
@@ -1446,6 +1588,14 @@ export class LocalCacheManager extends BaseSingleton {
1446
1588
  * @returns The schema hash string, or undefined if the entity can't be resolved
1447
1589
  */
1448
1590
  ComputeSchemaHash(provider, entityName) {
1591
+ return this.computeSchemaSignature(provider, entityName)?.Hash;
1592
+ }
1593
+ /**
1594
+ * The entity's schema hash (see {@link ComputeSchemaHash}) together with its schema stamp: the
1595
+ * newest `__mj_UpdatedAt` across the entity row and its field rows, as an ISO string, or
1596
+ * undefined when the metadata carries no timestamps.
1597
+ */
1598
+ computeSchemaSignature(provider, entityName) {
1449
1599
  try {
1450
1600
  const md = provider ?? new Metadata();
1451
1601
  const entity = md.EntityByName(entityName);
@@ -1454,12 +1604,27 @@ export class LocalCacheManager extends BaseSingleton {
1454
1604
  // Use natural sequence order (EntityInfo.Fields is sorted by Sequence).
1455
1605
  // This detects field additions, removals, AND reorderings.
1456
1606
  const fieldNames = entity.Fields.map(f => f.Name).join('|');
1457
- return this.simpleHash(fieldNames);
1607
+ let newest = LocalCacheManager.timestampMs(entity.__mj_UpdatedAt);
1608
+ for (const field of entity.Fields) {
1609
+ newest = Math.max(newest, LocalCacheManager.timestampMs(field.__mj_UpdatedAt));
1610
+ }
1611
+ return {
1612
+ Hash: this.simpleHash(fieldNames),
1613
+ Stamp: newest > 0 ? new Date(newest).toISOString() : undefined,
1614
+ };
1458
1615
  }
1459
1616
  catch {
1460
1617
  return undefined;
1461
1618
  }
1462
1619
  }
1620
+ /** Milliseconds since the epoch for a metadata timestamp (a Date or a string), or 0 when unreadable. */
1621
+ static timestampMs(value) {
1622
+ if (value == null) {
1623
+ return 0;
1624
+ }
1625
+ const ms = value instanceof Date ? value.getTime() : Date.parse(value);
1626
+ return Number.isNaN(ms) ? 0 : ms;
1627
+ }
1463
1628
  /**
1464
1629
  * Deep-freezes an about-to-be-stored cache payload when — and only when — the active
1465
1630
  * storage provider hands out live object references
@@ -1609,9 +1774,16 @@ export class LocalCacheManager extends BaseSingleton {
1609
1774
  }
1610
1775
  // Compute and store schema hash for upgrade detection
1611
1776
  if (params.EntityName) {
1612
- const schemaHash = this.ComputeSchemaHash(provider, params.EntityName);
1613
- if (schemaHash) {
1614
- data.schemaHash = schemaHash;
1777
+ const signature = this.computeSchemaSignature(provider, params.EntityName);
1778
+ if (signature) {
1779
+ if (!this.mayWriteUnderSchemaHold(params.EntityName, signature.Hash)) {
1780
+ LogStatusEx({ message: `[CACHE-WRITE-GATE] Skipping cache write for "${params.EntityName}" — its shared slots were found under different metadata`, verboseOnly: true });
1781
+ return;
1782
+ }
1783
+ data.schemaHash = signature.Hash;
1784
+ if (signature.Stamp) {
1785
+ data.schemaStamp = signature.Stamp;
1786
+ }
1615
1787
  }
1616
1788
  }
1617
1789
  // Estimate size by sampling rows (eviction accounting only); the actual stored
@@ -1626,9 +1798,9 @@ export class LocalCacheManager extends BaseSingleton {
1626
1798
  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)` });
1627
1799
  return;
1628
1800
  }
1629
- // The oversized gate above is the ONLY step that can decline this write, and it is
1630
- // synchronous — so by here the row set is definitely becoming shared state, and it is
1631
- // frozen with no intervening yield point. That ordering is load-bearing: the eviction
1801
+ // The schema gate and the oversized gate above are the ONLY steps that can decline this
1802
+ // write, and they are synchronous — so by here the row set is definitely becoming shared
1803
+ // state, and it is frozen with no intervening yield point. That ordering is load-bearing: the eviction
1632
1804
  // steps below are awaited, and callers hold this very array while they run (the
1633
1805
  // smart-cache stale leg does not await this method at all). Freezing after them leaves
1634
1806
  // a window in which shared rows are handed out still mutable — and `BaseEntity`
@@ -1685,12 +1857,16 @@ export class LocalCacheManager extends BaseSingleton {
1685
1857
  * @returns The cached results, maxUpdatedAt, rowCount (derived), and aggregateResults, or null if not found
1686
1858
  */
1687
1859
  async GetRunViewResult(fingerprint) {
1860
+ return this.readRunViewSlot(fingerprint, 'serve');
1861
+ }
1862
+ /** {@link GetRunViewResult} for a given purpose; in-place slot maintenance reads with `'maintain'`. */
1863
+ async readRunViewSlot(fingerprint, purpose) {
1688
1864
  if (!this._storageProvider || !this._config.enabled)
1689
1865
  return null;
1690
1866
  try {
1691
1867
  // Native object read — IDB structured-clones the result back, no JSON.parse needed.
1692
1868
  const parsed = await this._storageProvider.GetItem(fingerprint, CacheCategory.RunViewCache);
1693
- return this.materializeCachedRunViewResult(fingerprint, parsed);
1869
+ return this.materializeCachedRunViewResult(fingerprint, parsed, purpose);
1694
1870
  }
1695
1871
  catch (e) {
1696
1872
  LogError(`LocalCacheManager.GetRunViewResult failed: ${e}`);
@@ -1730,7 +1906,7 @@ export class LocalCacheManager extends BaseSingleton {
1730
1906
  try {
1731
1907
  const raw = await this._storageProvider.GetItems(fingerprints, CacheCategory.RunViewCache);
1732
1908
  for (const [fp, parsed] of raw) {
1733
- out.set(fp, this.materializeCachedRunViewResult(fp, parsed));
1909
+ out.set(fp, this.materializeCachedRunViewResult(fp, parsed, 'serve'));
1734
1910
  }
1735
1911
  return out;
1736
1912
  }
@@ -1753,7 +1929,7 @@ export class LocalCacheManager extends BaseSingleton {
1753
1929
  * cache entries after schema migrations. If the entity's field list changed
1754
1930
  * since the entry was cached, the entry is invalidated and null is returned.
1755
1931
  */
1756
- materializeCachedRunViewResult(fingerprint, parsed) {
1932
+ materializeCachedRunViewResult(fingerprint, parsed, purpose) {
1757
1933
  if (!parsed) {
1758
1934
  this._stats.misses++;
1759
1935
  return null;
@@ -1767,8 +1943,9 @@ export class LocalCacheManager extends BaseSingleton {
1767
1943
  this._stats.misses++;
1768
1944
  return null;
1769
1945
  }
1770
- if (this.isSchemaStaleCacheEntry(fingerprint, parsed)) {
1771
- this.InvalidateRunViewResult(fingerprint).catch(() => { });
1946
+ const schemaMismatch = this.findSchemaMismatch(fingerprint, parsed);
1947
+ if (schemaMismatch) {
1948
+ this.handleSchemaMismatch(fingerprint, schemaMismatch, purpose);
1772
1949
  this._stats.misses++;
1773
1950
  return null;
1774
1951
  }
@@ -1782,6 +1959,7 @@ export class LocalCacheManager extends BaseSingleton {
1782
1959
  totalRowCount: parsed.totalRowCount,
1783
1960
  // Surfaced so in-place maintenance can carry it forward on rewrite (B38).
1784
1961
  schemaHash: parsed.schemaHash,
1962
+ schemaStamp: parsed.schemaStamp,
1785
1963
  // Same carry-forward rationale: a maintained scaffolding slot must stay exempt.
1786
1964
  providerInternalScaffolding: parsed.providerInternalScaffolding,
1787
1965
  };
@@ -1978,7 +2156,7 @@ export class LocalCacheManager extends BaseSingleton {
1978
2156
  return false;
1979
2157
  return this.withFingerprintLock(fingerprint, async () => {
1980
2158
  try {
1981
- const cached = await this.GetRunViewResult(fingerprint);
2159
+ const cached = await this.readRunViewSlot(fingerprint, 'maintain');
1982
2160
  if (!cached) {
1983
2161
  LogStatusVerbose(`LocalCacheManager.UpsertSingleEntity: No cached data found for fingerprint "${fingerprint.substring(0, 60)}" — skipping (cache will be populated on next RunView)`);
1984
2162
  return false;
@@ -1999,7 +2177,7 @@ export class LocalCacheManager extends BaseSingleton {
1999
2177
  // Upsert the entity (add or replace)
2000
2178
  resultMap.set(keyStr, entityData);
2001
2179
  const updatedResults = Array.from(resultMap.values());
2002
- return await this.storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt, { totalRowCount: cached.totalRowCount, rowCount: cached.results.length, schemaHash: cached.schemaHash,
2180
+ return await this.storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt, { totalRowCount: cached.totalRowCount, rowCount: cached.results.length, schemaHash: cached.schemaHash, schemaStamp: cached.schemaStamp,
2003
2181
  providerInternalScaffolding: cached.providerInternalScaffolding });
2004
2182
  }
2005
2183
  catch (e) {
@@ -2022,7 +2200,7 @@ export class LocalCacheManager extends BaseSingleton {
2022
2200
  return false;
2023
2201
  return this.withFingerprintLock(fingerprint, async () => {
2024
2202
  try {
2025
- const cached = await this.GetRunViewResult(fingerprint);
2203
+ const cached = await this.readRunViewSlot(fingerprint, 'maintain');
2026
2204
  if (!cached) {
2027
2205
  return false;
2028
2206
  }
@@ -2042,7 +2220,7 @@ export class LocalCacheManager extends BaseSingleton {
2042
2220
  }
2043
2221
  resultMap.delete(keyStr);
2044
2222
  const updatedResults = Array.from(resultMap.values());
2045
- return await this.storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt, { totalRowCount: cached.totalRowCount, rowCount: cached.results.length, schemaHash: cached.schemaHash,
2223
+ return await this.storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt, { totalRowCount: cached.totalRowCount, rowCount: cached.results.length, schemaHash: cached.schemaHash, schemaStamp: cached.schemaStamp,
2046
2224
  providerInternalScaffolding: cached.providerInternalScaffolding });
2047
2225
  }
2048
2226
  catch (e) {
@@ -2084,6 +2262,9 @@ export class LocalCacheManager extends BaseSingleton {
2084
2262
  if (prior?.schemaHash) {
2085
2263
  data.schemaHash = prior.schemaHash;
2086
2264
  }
2265
+ if (prior?.schemaStamp) {
2266
+ data.schemaStamp = prior.schemaStamp;
2267
+ }
2087
2268
  // Carry the freeze exemption forward too — a scaffolding slot that gets maintained in
2088
2269
  // place must not silently become frozen, or the owner that mutates those rows breaks on
2089
2270
  // the next read (for the metadata dataset that means booting with no metadata).