@memberjunction/core 6.2.0-edge.3 → 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 (60) hide show
  1. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +15 -0
  2. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
  3. package/dist/generic/RegisterForStartup.d.ts +44 -0
  4. package/dist/generic/RegisterForStartup.d.ts.map +1 -1
  5. package/dist/generic/RegisterForStartup.js +124 -48
  6. package/dist/generic/RegisterForStartup.js.map +1 -1
  7. package/dist/generic/baseEngine.d.ts +193 -2
  8. package/dist/generic/baseEngine.d.ts.map +1 -1
  9. package/dist/generic/baseEngine.js +445 -33
  10. package/dist/generic/baseEngine.js.map +1 -1
  11. package/dist/generic/baseEngineSweeper.d.ts +35 -0
  12. package/dist/generic/baseEngineSweeper.d.ts.map +1 -0
  13. package/dist/generic/baseEngineSweeper.js +97 -0
  14. package/dist/generic/baseEngineSweeper.js.map +1 -0
  15. package/dist/generic/databaseProviderBase.d.ts +20 -11
  16. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  17. package/dist/generic/databaseProviderBase.js +41 -15
  18. package/dist/generic/databaseProviderBase.js.map +1 -1
  19. package/dist/generic/entityEventBatch.d.ts +124 -0
  20. package/dist/generic/entityEventBatch.d.ts.map +1 -0
  21. package/dist/generic/entityEventBatch.js +194 -0
  22. package/dist/generic/entityEventBatch.js.map +1 -0
  23. package/dist/generic/entityInfo.d.ts +25 -5
  24. package/dist/generic/entityInfo.d.ts.map +1 -1
  25. package/dist/generic/entityInfo.js +28 -5
  26. package/dist/generic/entityInfo.js.map +1 -1
  27. package/dist/generic/interfaces.d.ts +118 -2
  28. package/dist/generic/interfaces.d.ts.map +1 -1
  29. package/dist/generic/interfaces.js.map +1 -1
  30. package/dist/generic/localCacheManager.d.ts +267 -16
  31. package/dist/generic/localCacheManager.d.ts.map +1 -1
  32. package/dist/generic/localCacheManager.js +728 -113
  33. package/dist/generic/localCacheManager.js.map +1 -1
  34. package/dist/generic/logging.d.ts +15 -0
  35. package/dist/generic/logging.d.ts.map +1 -1
  36. package/dist/generic/logging.js +17 -0
  37. package/dist/generic/logging.js.map +1 -1
  38. package/dist/generic/metadata.d.ts +1 -1
  39. package/dist/generic/metadata.d.ts.map +1 -1
  40. package/dist/generic/metadata.js.map +1 -1
  41. package/dist/generic/providerBase.d.ts +184 -3
  42. package/dist/generic/providerBase.d.ts.map +1 -1
  43. package/dist/generic/providerBase.js +353 -69
  44. package/dist/generic/providerBase.js.map +1 -1
  45. package/dist/generic/securityInfo.d.ts +5 -0
  46. package/dist/generic/securityInfo.d.ts.map +1 -1
  47. package/dist/generic/securityInfo.js +5 -0
  48. package/dist/generic/securityInfo.js.map +1 -1
  49. package/dist/generic/telemetryManager.d.ts.map +1 -1
  50. package/dist/generic/telemetryManager.js +4 -1
  51. package/dist/generic/telemetryManager.js.map +1 -1
  52. package/dist/generic/transactionGroup.d.ts +27 -0
  53. package/dist/generic/transactionGroup.d.ts.map +1 -1
  54. package/dist/generic/transactionGroup.js +58 -5
  55. package/dist/generic/transactionGroup.js.map +1 -1
  56. package/dist/index.d.ts +1 -0
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +1 -0
  59. package/dist/index.js.map +1 -1
  60. package/package.json +3 -3
@@ -3,7 +3,7 @@ import { EntityDocumentTypeInfo, EntityFieldTSType, EntityInfo, EntityPermission
3
3
  import { AllMetadata } from "./interfaces.js";
4
4
  import { RecordChangeFieldSecurityProjector } from "./recordChangeFieldSecurity.js";
5
5
  import { ComputeRRF } from "./scoring/ReciprocalRankFusion.js";
6
- import { LocalCacheManager } from "./localCacheManager.js";
6
+ import { LocalCacheManager, CacheCategory } from "./localCacheManager.js";
7
7
  import { ApplicationInfo } from "../generic/applicationInfo.js";
8
8
  import { AuditLogTypeInfo, AuthorizationInfo, AuthorizationRoleInfo, RoleInfo, RowLevelSecurityFilterInfo, UserInfo } from "./securityInfo.js";
9
9
  import { MJGlobal, MJEventType, NormalizeUUID, UUIDsEqual, EscapeSQLString, ordinalCompare } from "@memberjunction/global";
@@ -184,6 +184,12 @@ export function ProjectRowsToFields(rows, requestedFields) {
184
184
  export class ProviderBase {
185
185
  constructor() {
186
186
  this._localMetadata = new AllMetadata();
187
+ /**
188
+ * The stored timestamps value (raw string) that describes the snapshot this process last
189
+ * loaded from or saved to local storage; null when that load found none. Undefined until a load
190
+ * or save has happened. See {@link syncLocalMetadataFromStorage}.
191
+ */
192
+ this._storedTimestampsRaw = undefined;
187
193
  this._entityMapByName = new Map();
188
194
  this._entityMapByID = new Map();
189
195
  this._refresh = false;
@@ -244,6 +250,13 @@ export class ProviderBase {
244
250
  this._metadataReadsJoinTransaction = false;
245
251
  /** Debounce timer for {@link scheduleMetadataMemberRefresh}. */
246
252
  this._metadataMemberRefreshTimer = null;
253
+ // ── Metadata change notices from other servers ─────────────────────
254
+ /** When the current run of undebounced peer notices began; caps how long the check is deferred. */
255
+ this._peerMetadataNoticeFirstAt = null;
256
+ /** Windows the pending peer-notice check has waited for an ambient transaction to settle. */
257
+ this._peerMetadataNoticeRetries = 0;
258
+ /** Debounce timer for {@link HandlePeerMetadataNotice}. */
259
+ this._peerMetadataNoticeTimer = null;
247
260
  this._metadataMemberRefreshWaits = 0;
248
261
  this._clientFingerprintMemo = new WeakMap();
249
262
  this._cachedVisibleExplorerNavigationItems = null;
@@ -483,6 +496,63 @@ export class ProviderBase {
483
496
  this._metadataDatasetEntityNames = names;
484
497
  this.ensureInflightViewInvalidation();
485
498
  }
499
+ /**
500
+ * Result of {@link SweepMetadataAgainstDatabase}.
501
+ */
502
+ static { this.MetadataSweepSkipped = { Declared: [], Checked: false, Refreshed: false }; }
503
+ /**
504
+ * Compares this provider's metadata with the database — but only for metadata whose entities
505
+ * have **declared** that their rows can change without firing an event.
506
+ *
507
+ * Metadata staleness is otherwise event-driven: a `BaseEntity` write to one of the entities the
508
+ * metadata is built from schedules a refresh, another process saving its snapshot publishes a
509
+ * notice, and a tool that changed the database clears the shared cache. None of those fire for a
510
+ * change made with raw SQL, so metadata edited directly in the database is never noticed by a
511
+ * running process. This is the backstop for that case.
512
+ *
513
+ * It is **not** an unconditional poll. `Entity.TrustServerCacheCompletely = true`
514
+ * (the default) states that every mutation flows through `BaseEntity`, which the event paths
515
+ * already hear; polling such an entity cannot discover anything, and a recurring query prevents a
516
+ * serverless database from pausing. So the check reads the database only when at least one
517
+ * metadata member entity declares `false` — typically because an operator writes that table
518
+ * directly. On a stock installation no metadata entity declares it, and this costs one in-memory
519
+ * pass over the membership set.
520
+ *
521
+ * @param providerToUse Provider to compare against, for multi-provider clients.
522
+ * @returns Which entities declared drift, whether the database was consulted, and whether
523
+ * metadata was actually reloaded.
524
+ */
525
+ async SweepMetadataAgainstDatabase(providerToUse) {
526
+ const declared = this.MetadataMembersDeclaringDrift();
527
+ if (declared.length === 0) {
528
+ return { Declared: [], Checked: false, Refreshed: false };
529
+ }
530
+ // bypassMinCheckInterval: the sweep interval is the throttle, and this caller holds positive
531
+ // evidence that an entity can change without telling anyone.
532
+ const refreshed = await this.RefreshIfNeeded(providerToUse, true);
533
+ return { Declared: declared, Checked: true, Refreshed: refreshed };
534
+ }
535
+ /**
536
+ * The entities this provider's metadata is built from that declare they can change without
537
+ * firing an event (`Entity.TrustServerCacheCompletely === false`).
538
+ *
539
+ * Empty — the usual case — means a metadata sweep has nothing to look for, so a caller can skip
540
+ * the work (and any lease) without asking the database anything.
541
+ */
542
+ MetadataMembersDeclaringDrift() {
543
+ const names = this._metadataDatasetEntityNames;
544
+ if (!names || names.size === 0) {
545
+ return [];
546
+ }
547
+ const declared = [];
548
+ for (const lowerName of names) {
549
+ const entity = this.Entities.find(e => e.Name.trim().toLowerCase() === lowerName);
550
+ if (entity && entity.TrustServerCacheCompletely === false) {
551
+ declared.push(entity.Name);
552
+ }
553
+ }
554
+ return declared;
555
+ }
486
556
  /**
487
557
  * Whether `entityName` is one of the entities this provider's metadata is built from.
488
558
  * False when the membership set has not been loaded yet — a caller must not treat "unknown"
@@ -600,6 +670,118 @@ export class ProviderBase {
600
670
  });
601
671
  }, this.MetadataMemberRefreshDelayMs);
602
672
  }
673
+ /**
674
+ * Upper bound, in milliseconds, of the random delay added to {@link HandlePeerMetadataNotice}'s
675
+ * debounce. A tool that changes the schema removes the shared snapshot, which reaches every
676
+ * server at once; the spread lets the first server reload and republish, so the others adopt
677
+ * its snapshot instead of all querying the database together.
678
+ */
679
+ static { this.PeerMetadataNoticeJitterMs = 2000; }
680
+ /**
681
+ * How long a peer notice may be deferred by later notices before the check runs anyway. The
682
+ * debounce timer is reset by each arrival, so a fleet publishing steadily — exactly what a bulk
683
+ * import looks like — could postpone the check indefinitely.
684
+ */
685
+ static { this.PeerMetadataNoticeMaxDeferralMs = 15000; }
686
+ /**
687
+ * True when a shared-cache event says the metadata snapshot changed: another process saved one
688
+ * (`set`), or a tool that changed the database removed it (`removed`, e.g. `mj migrate`). Only
689
+ * the timestamps key counts — it is written last (see {@link SaveLocalMetadataToStorage}) and
690
+ * removed last — so each save or removal is one notice.
691
+ */
692
+ IsMetadataChangeNotice(event) {
693
+ return (event.Action === 'set' || event.Action === 'removed')
694
+ && (event.Category || CacheCategory.Default) === CacheCategory.Default
695
+ && event.CacheKey === this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey;
696
+ }
697
+ /**
698
+ * Reacts to another server saving its metadata snapshot: after a short debounce, runs the
699
+ * normal staleness check ({@link RefreshIfNeeded}, bypassing the check throttle). The check
700
+ * compares this process's timestamps with the database and adopts the shared snapshot, so a
701
+ * metadata change made on one server reaches the others in seconds instead of at the next
702
+ * poll. A server that is already current does no refresh and saves nothing, so notices do not
703
+ * echo around the fleet.
704
+ *
705
+ * @returns true when the event was a metadata notice (and a check was scheduled)
706
+ */
707
+ HandlePeerMetadataNotice(event) {
708
+ if (!this.IsMetadataChangeNotice(event)) {
709
+ return false;
710
+ }
711
+ const now = Date.now();
712
+ this._peerMetadataNoticeFirstAt ??= now;
713
+ if (now - this._peerMetadataNoticeFirstAt >= ProviderBase.PeerMetadataNoticeMaxDeferralMs) {
714
+ // Notices have been arriving without a pause for longer than the deferral budget: stop
715
+ // resetting the timer and let the pending check run now.
716
+ return true;
717
+ }
718
+ if (this._peerMetadataNoticeTimer) {
719
+ clearTimeout(this._peerMetadataNoticeTimer);
720
+ }
721
+ const timer = setTimeout(() => {
722
+ this._peerMetadataNoticeTimer = null;
723
+ // A provider inside a transaction cannot run the check now (its reads would see the
724
+ // transaction's own uncommitted world, and SQL Server's RefreshIfNeeded refuses
725
+ // outright). Re-arm instead of dropping the notice, which used to lose it entirely.
726
+ // The deferral budget is deliberately NOT cleared here: it bounds how long one check
727
+ // may be put off, and the check has not run yet. Clearing it as the timer fires ended
728
+ // the budget exactly when deferral began, leaving the retry count as the only real
729
+ // bound.
730
+ if (this.MetadataMemberRefreshMustWait) {
731
+ this.handlePeerMetadataNoticeRetry();
732
+ return;
733
+ }
734
+ this.runPeerMetadataCheck();
735
+ }, ProviderBase.MetadataDatasetRefreshDebounceMs + Math.floor(Math.random() * ProviderBase.PeerMetadataNoticeJitterMs));
736
+ if (typeof timer === 'object' && timer !== null && 'unref' in timer) {
737
+ timer.unref();
738
+ }
739
+ this._peerMetadataNoticeTimer = timer;
740
+ return true;
741
+ }
742
+ /**
743
+ * Runs the deferred metadata check and closes out both budgets.
744
+ *
745
+ * Both are reset HERE rather than at any earlier point, because both measure deferral of a
746
+ * check that has now happened. The retry count in particular used to be cleared only on the
747
+ * re-armed path, so a check that ran on the direct path — which is what happens when a fresh
748
+ * notice arrives after the transaction closed — left its count behind for the next deferral to
749
+ * inherit.
750
+ */
751
+ runPeerMetadataCheck() {
752
+ this._peerMetadataNoticeRetries = 0;
753
+ this._peerMetadataNoticeFirstAt = null;
754
+ this.RefreshIfNeeded(undefined, true).catch((e) => {
755
+ LogError(`Metadata check after another server's change failed: ${e instanceof Error ? e.message : String(e)}`);
756
+ });
757
+ }
758
+ /**
759
+ * Re-arms the peer-notice check when the provider was inside a transaction at the moment it
760
+ * came due. Bounded by the same budget as the debounce, so a provider that never leaves its
761
+ * transaction (a leaked handle) stops re-arming rather than holding a timer open forever; the
762
+ * next notice, or the periodic poll, picks the change up.
763
+ */
764
+ handlePeerMetadataNoticeRetry() {
765
+ this._peerMetadataNoticeRetries++;
766
+ if (this._peerMetadataNoticeRetries > ProviderBase.MaxMetadataMemberRefreshWaits) {
767
+ LogError(`Metadata check after another server's change was dropped: this provider has been inside a transaction for ${this._peerMetadataNoticeRetries} windows. The next notice or the periodic check will pick the change up.`);
768
+ this._peerMetadataNoticeRetries = 0;
769
+ this._peerMetadataNoticeFirstAt = null; // the next notice starts a fresh budget
770
+ return;
771
+ }
772
+ const timer = setTimeout(() => {
773
+ this._peerMetadataNoticeTimer = null;
774
+ if (this.MetadataMemberRefreshMustWait) {
775
+ this.handlePeerMetadataNoticeRetry();
776
+ return;
777
+ }
778
+ this.runPeerMetadataCheck();
779
+ }, this.MetadataMemberRefreshDelayMs);
780
+ if (typeof timer === 'object' && timer !== null && 'unref' in timer) {
781
+ timer.unref();
782
+ }
783
+ this._peerMetadataNoticeTimer = timer;
784
+ }
603
785
  /**
604
786
  * How this provider refreshes after a metadata member entity changed. The base behavior is a
605
787
  * hard {@link Refresh} — correct for database providers, where the process that PERFORMED the
@@ -1367,7 +1549,19 @@ export class ProviderBase {
1367
1549
  ].join('|');
1368
1550
  return `${base}|${extras}`;
1369
1551
  });
1370
- return parts.join('||');
1552
+ return `${parts.join('||')}|||${this.RunViewDedupScope}`;
1553
+ }
1554
+ /**
1555
+ * What, besides the request itself and the user, decides whether two identical RunViews may
1556
+ * share one execution (in flight or lingering). Empty by default. A provider whose callers can
1557
+ * see different data for the same query — a database provider whose open transaction belongs
1558
+ * to one async scope only (#4786) — returns a value that differs between those callers, so a
1559
+ * transaction member never awaits an outsider's pool read (missing its own writes, or on
1560
+ * SQL Server waiting on its own row lock) and an outsider never receives uncommitted rows.
1561
+ * Read synchronously while the dedup key is built, in the caller's async context.
1562
+ */
1563
+ get RunViewDedupScope() {
1564
+ return '';
1371
1565
  }
1372
1566
  /**
1373
1567
  * Normalizes a Fields list into a stable key segment: trimmed, lowercased,
@@ -1412,13 +1606,15 @@ export class ProviderBase {
1412
1606
  if (memoized) {
1413
1607
  return memoized;
1414
1608
  }
1415
- // The fls: segment participates in CLIENT slot identity too, keyed on the ALLOWED list
1416
- // (see ComputeClientFLSAllowedKey). Without it, a user whose field access is TIGHTENED
1417
- // keeps being served their persisted IndexedDB slot: the currency check compares only
1418
- // maxUpdatedAt and rowCount, neither of which notices a column, so the server answers
1419
- // "current" and the browser shows a column that was just taken away — until the rows
1420
- // change. Empty for unrestricted users, whose fingerprints are unchanged.
1421
- const base = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString, undefined, this.ComputeClientFLSAllowedKey(param));
1609
+ // The signed-in user's row filter (rls:) and allowed field list (fls:, see
1610
+ // ComputeClientFLSAllowedKey) are part of client slot identity, as they are on the server.
1611
+ // The browser cache outlives a session — one IndexedDB store per server URL — and the
1612
+ // currency check compares only maxUpdatedAt and rowCount, which cannot tell one user's
1613
+ // rows from another's, nor notice a column. Without these segments, a different user on
1614
+ // the same browser could be served the previous user's rows, and a user whose field
1615
+ // access is tightened would keep seeing a column just taken away. Both are empty for an
1616
+ // unrestricted user, whose fingerprints are unchanged.
1617
+ const base = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString, this.ComputeRunViewRLSWhereClause(param), undefined, this.ComputeClientFLSAllowedKey(param));
1422
1618
  // Normalize a FULL-COVERAGE field list to '*' — in the FINGERPRINT only (B44).
1423
1619
  //
1424
1620
  // entity_object params get widened to an explicit list of every entity field
@@ -3493,32 +3689,14 @@ export class ProviderBase {
3493
3689
  return true;
3494
3690
  }
3495
3691
  extractMaxUpdatedAt(results) {
3496
- let maxDate = null;
3497
- // Early exit: SQL result rows are uniform — if the first row carries neither
3498
- // timestamp column, none do, and the full O(rows) scan is pointless.
3499
- if (results.length > 0 && results[0] && typeof results[0] === 'object') {
3500
- const probe = results[0];
3501
- if (probe['__mj_UpdatedAt'] === undefined && probe['UpdatedAt'] === undefined) {
3502
- return '';
3503
- }
3504
- }
3505
- for (const item of results) {
3506
- if (item && typeof item === 'object') {
3507
- const record = item;
3508
- // Check for __mj_UpdatedAt field (standard MJ timestamp field)
3509
- const updatedAt = record['__mj_UpdatedAt'] || record['UpdatedAt'];
3510
- if (updatedAt) {
3511
- const date = updatedAt instanceof Date ? updatedAt : new Date(updatedAt);
3512
- if (!isNaN(date.getTime()) && (!maxDate || date > maxDate)) {
3513
- maxDate = date;
3514
- }
3515
- }
3516
- }
3517
- }
3518
- // Return empty string for empty/timestamp-less results instead of current server time.
3519
- // Using current time would make empty result sets perpetually "stale" — each smart-cache-check
3520
- // would see a different timestamp and force an unnecessary DB refresh.
3521
- return maxDate ? maxDate.toISOString() : '';
3692
+ // Delegates so the RunView write path and the engine/slot path cannot drift apart: they
3693
+ // used to be two implementations with different fallback columns and different answers for
3694
+ // timestamp-less rows, which stamped the same rows differently depending on which funnel
3695
+ // wrote the slot.
3696
+ //
3697
+ // Empty string, never the current time, for empty or timestamp-less results: a clock value
3698
+ // would make such a slot perpetually "stale", since every check would see a new timestamp.
3699
+ return LocalCacheManager.MaxUpdatedAtOfRows(results) ?? '';
3522
3700
  }
3523
3701
  /**
3524
3702
  * Merges cached and fresh results for RunViews, maintaining original order.
@@ -3899,8 +4077,11 @@ export class ProviderBase {
3899
4077
  // Describes THIS Config only: a flag left by an earlier Refresh() must not make the
3900
4078
  // pre-validation that follows a Config which loaded nothing skip its check.
3901
4079
  this._configLoadedFromServer = false;
3902
- // Initialize LocalCacheManager early so dataset loading can use the cache.
3903
- // Initialize() is idempotent — subsequent calls (e.g. from StartupManager) are no-ops.
4080
+ // Initialize LocalCacheManager early so dataset loading can use the cache. This call has no
4081
+ // settings to give it, so a LATER caller that does (StartupManager, passing the host's
4082
+ // cacheSettings) is not a no-op: Initialize applies that configuration to the already
4083
+ // initialized manager rather than discarding it, which is what made cacheSettings inert
4084
+ // before.
3904
4085
  if (!LocalCacheManager.Instance.IsInitialized) {
3905
4086
  const storageProvider = this.LocalStorageProvider;
3906
4087
  await LocalCacheManager.Instance.Initialize(storageProvider);
@@ -4641,15 +4822,47 @@ export class ProviderBase {
4641
4822
  }
4642
4823
  }
4643
4824
  }
4644
- /** One refresh check: fetch remote timestamps, arm the throttle only on success, compare. */
4825
+ /**
4826
+ * One refresh check: fetch the remote timestamps, arm the throttle only on success, bring the
4827
+ * in-memory snapshot up to the stored one, then compare. The third step is what makes the
4828
+ * answer correct in a fleet — a peer may have written a newer snapshot to the shared store
4829
+ * since this process last loaded — and {@link syncLocalMetadataFromStorage} keeps it cheap by
4830
+ * reading the whole snapshot only when the stored timestamps say it could have changed.
4831
+ */
4645
4832
  async runRefreshCheck(providerToUse) {
4646
4833
  const gotTimestamps = await this.RefreshRemoteMetadataTimestamps(providerToUse);
4647
4834
  if (gotTimestamps) {
4648
4835
  this._lastRefreshCheckAt = Date.now();
4649
4836
  }
4650
- await this.LoadLocalMetadataFromStorage();
4837
+ await this.syncLocalMetadataFromStorage();
4651
4838
  return this.LocalMetadataObsolete();
4652
4839
  }
4840
+ /**
4841
+ * Brings the in-memory metadata up to the stored snapshot before a staleness check.
4842
+ *
4843
+ * The full snapshot (several MB compressed) is read only when it may differ from what this
4844
+ * process holds: nothing is held yet, or the stored timestamps are not the ones this process
4845
+ * last loaded or saved — another process sharing the store wrote a newer snapshot, or the
4846
+ * store was cleared. Otherwise only the small timestamps key is read. Reading and parsing the
4847
+ * whole snapshot on every periodic check cost each server ~230 ms per poll on a shared cache
4848
+ *.
4849
+ */
4850
+ async syncLocalMetadataFromStorage() {
4851
+ const ls = this.LocalStorageProvider;
4852
+ if (ls && this._storedTimestampsRaw !== undefined && this._localMetadata?.AllEntities?.length) {
4853
+ try {
4854
+ const tsKey = this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey;
4855
+ const stored = (await ls.GetItems([tsKey])).get(tsKey) ?? null;
4856
+ if (stored === this._storedTimestampsRaw) {
4857
+ return;
4858
+ }
4859
+ }
4860
+ catch (e) {
4861
+ LogError(`[Metadata Cache] Reading stored metadata timestamps failed, loading the full snapshot: ${e instanceof Error ? e.message : String(e)}`);
4862
+ }
4863
+ }
4864
+ await this.LoadLocalMetadataFromStorage();
4865
+ }
4653
4866
  /**
4654
4867
  * Refreshes metadata only if needed based on timestamp comparison.
4655
4868
  * Combines check and refresh into a single operation.
@@ -4796,7 +5009,7 @@ export class ProviderBase {
4796
5009
  let cachedDataset = null;
4797
5010
  let cachedDateStr = null;
4798
5011
  if (ls && key && dateKey) {
4799
- const both = await ls.GetItems([key, dateKey], 'DatasetCache');
5012
+ const both = await ls.GetItems([key, dateKey], ProviderBase.DatasetCacheCategory);
4800
5013
  const rawData = both.get(key);
4801
5014
  const rawDate = both.get(dateKey);
4802
5015
  // Type guards — entries can be either depending on which key matched.
@@ -4812,15 +5025,8 @@ export class ProviderBase {
4812
5025
  if (status && localDate.getTime() >= status.LatestUpdateDate.getTime()) {
4813
5026
  // Timestamps suggest cache is fresh; verify per-entity row counts to
4814
5027
  // catch deleted rows (timestamp comparison alone misses pure deletes).
4815
- let allCountsMatch = true;
4816
- for (const eu of status.EntityUpdateDates) {
4817
- const localEntity = cachedDataset.Results.find(e => UUIDsEqual(e.EntityID, eu.EntityID));
4818
- if (!localEntity || localEntity.Results.length !== eu.RowCount) {
4819
- allCountsMatch = false;
4820
- break;
4821
- }
4822
- }
4823
- if (allCountsMatch) {
5028
+ // Shared with IsDatasetCacheUpToDate so the two cannot drift again.
5029
+ if (this.DatasetRowCountsMatch(cachedDataset, status)) {
4824
5030
  return cachedDataset;
4825
5031
  }
4826
5032
  }
@@ -4841,7 +5047,7 @@ export class ProviderBase {
4841
5047
  if (ls) {
4842
5048
  const key = this.GetDatasetCacheKey(datasetName, itemFilters);
4843
5049
  const dateKey = key + '_date';
4844
- const val = await ls.GetItem(dateKey);
5050
+ const val = await ls.GetItem(dateKey, ProviderBase.DatasetCacheCategory);
4845
5051
  if (val) {
4846
5052
  return new Date(val);
4847
5053
  }
@@ -4865,17 +5071,7 @@ export class ProviderBase {
4865
5071
  // in this situation, the last thing we check is for each entity, if the rowcount is the same as the server, if it is, we're good
4866
5072
  // iterate through all of the entities and check the row counts
4867
5073
  const localDataset = await this.GetCachedDataset(datasetName, itemFilters);
4868
- for (const eu of status.EntityUpdateDates) {
4869
- const localEntity = localDataset.Results.find(e => UUIDsEqual(e.EntityID, eu.EntityID));
4870
- if (!localEntity || localEntity.Results.length !== eu.RowCount) {
4871
- // we either couldn't find the entity in the local cache or the row count is different, so we're out of date
4872
- // the RowCount being different picks up on DELETED rows. The UpdatedAt check which is handled above would pick up
4873
- // on any new rows or updated rows. This approach makes sure we detect deleted rows and refresh the cache.
4874
- return false;
4875
- }
4876
- }
4877
- // if we get here that means that the row counts are the same for all entities and we're up to date
4878
- return true;
5074
+ return this.DatasetRowCountsMatch(localDataset, status);
4879
5075
  }
4880
5076
  else {
4881
5077
  // our local cache timestamp is < the server timestamp, so we're out of date
@@ -4892,6 +5088,40 @@ export class ProviderBase {
4892
5088
  return false;
4893
5089
  }
4894
5090
  }
5091
+ /**
5092
+ * Compares the per-entity row counts of a cached dataset against the server's status.
5093
+ *
5094
+ * The timestamp comparison its callers run first catches new and updated rows; it cannot catch a
5095
+ * pure DELETE, which leaves `LatestUpdateDate` untouched while the count drops. This is the
5096
+ * check that catches those, and it is shared by `GetAndCacheDatasetByName` (which already holds
5097
+ * the blob) and `IsDatasetCacheUpToDate` (which reads it) so the two cannot drift apart again —
5098
+ * they had, and the differences were only ever masked by the warm path not running.
5099
+ *
5100
+ * Two cases decide the shape of this, both load-bearing:
5101
+ *
5102
+ * - **No counts to compare.** A status reporting no `EntityUpdateDates` never touches the blob
5103
+ * and is judged on its timestamp alone, so it answers `true`. Returning `false` here instead
5104
+ * looks strictly safer and is not: it makes such a dataset permanently stale, reloading on
5105
+ * every check (plan — the `dataset-cache.DS2` regression).
5106
+ * - **A count to compare but no blob.** The blob and its `_date` proxy expire independently and
5107
+ * a clear removes them in order, so the date can outlive the blob. Counts then cannot be
5108
+ * compared, which is `false` — refetch. A blob present but missing `Results`
5109
+ * answers `false` for the same reason rather than throwing, which is what the pre-unification
5110
+ * copy in the warm path would have done the moment that path started running.
5111
+ *
5112
+ * @param cachedDataset the cached dataset, which may be absent or incomplete
5113
+ * @param status the server's status for the same dataset/filters combination
5114
+ * @returns true when every entity the server reports matches the cache's row count for it
5115
+ */
5116
+ DatasetRowCountsMatch(cachedDataset, status) {
5117
+ for (const eu of status.EntityUpdateDates) {
5118
+ const localEntity = cachedDataset?.Results?.find(e => UUIDsEqual(e.EntityID, eu.EntityID));
5119
+ if (!localEntity || localEntity.Results.length !== eu.RowCount) {
5120
+ return false;
5121
+ }
5122
+ }
5123
+ return true;
5124
+ }
4895
5125
  /**
4896
5126
  * This routine gets the local cached version of a given datasetName/itemFilters combination, it does NOT check the server status first and does not fall back on the server if there isn't a local cache version of this dataset/itemFilters combination
4897
5127
  * @param datasetName
@@ -4903,7 +5133,7 @@ export class ProviderBase {
4903
5133
  if (ls) {
4904
5134
  const key = this.GetDatasetCacheKey(datasetName, itemFilters);
4905
5135
  // Native object read — IDB structured-clones, localStorage / Redis JSON-decode internally.
4906
- const dataset = await ls.GetItem(key);
5136
+ const dataset = await ls.GetItem(key, ProviderBase.DatasetCacheCategory);
4907
5137
  if (dataset) {
4908
5138
  return dataset;
4909
5139
  }
@@ -4920,10 +5150,10 @@ export class ProviderBase {
4920
5150
  if (ls) {
4921
5151
  const key = this.GetDatasetCacheKey(datasetName, itemFilters);
4922
5152
  // Native object storage — no JSON.stringify on the hot path.
4923
- await ls.SetItem(key, dataset);
5153
+ await ls.SetItem(key, dataset, ProviderBase.DatasetCacheCategory, { TTLSeconds: ProviderBase.DatasetCacheTTLSeconds });
4924
5154
  // Date is stored as ISO string for forward-compatibility across providers
4925
5155
  // (Redis can't natively round-trip Date; localStorage requires string).
4926
- await ls.SetItem(key + '_date', dataset.LatestUpdateDate.toISOString());
5156
+ await ls.SetItem(key + '_date', dataset.LatestUpdateDate.toISOString(), ProviderBase.DatasetCacheCategory, { TTLSeconds: ProviderBase.DatasetDateCacheTTLSeconds });
4927
5157
  }
4928
5158
  }
4929
5159
  /**
@@ -4940,10 +5170,42 @@ export class ProviderBase {
4940
5170
  // CacheDataset, so the date's presence is a faithful proxy for the
4941
5171
  // data's presence.
4942
5172
  const key = this.GetDatasetCacheKey(datasetName, itemFilters);
4943
- const val = await ls.GetItem(key + '_date');
5173
+ const val = await ls.GetItem(key + '_date', ProviderBase.DatasetCacheCategory);
4944
5174
  return val !== null && val !== undefined;
4945
5175
  }
4946
5176
  }
5177
+ /**
5178
+ * The cache category every dataset read and write uses.
5179
+ *
5180
+ * Every storage provider isolates by category — Redis keys are `{prefix}:{category}:{key}`,
5181
+ * browser localStorage `[mj]:[category]:[key]`, IndexedDB a dedicated object store, the
5182
+ * in-memory providers a nested map — so a read and a write that disagree about the category can
5183
+ * never meet. When the batched warm read in `GetAndCacheDatasetByName` named `'DatasetCache'`
5184
+ * while every write and every other reader landed in `default`, the warm-serve path missed on
5185
+ * every transport and silently refetched from the server. Naming the category once is what keeps
5186
+ * all six call sites in agreement.
5187
+ */
5188
+ static { this.DatasetCacheCategory = 'DatasetCache'; }
5189
+ /**
5190
+ * How long a cached dataset lives.
5191
+ *
5192
+ * Datasets have a category to themselves and it carries no category-wide expiry, so without a
5193
+ * per-write TTL every distinct filter set (`GetDatasetCacheKey` includes the filters) would
5194
+ * leave behind a blob nothing removes. An explicit TTL bounds that growth, and the store
5195
+ * prefers a per-write value over any category or global setting — which is also why giving
5196
+ * datasets their own category did not change how long one lives. Providers that do not
5197
+ * implement expiry ignore it.
5198
+ */
5199
+ static { this.DatasetCacheTTLSeconds = 3600; }
5200
+ /**
5201
+ * How long the `_date` key lives — deliberately SHORTER than the blob it vouches for.
5202
+ *
5203
+ * It is written second, so with equal lifetimes it would usually be the one to survive, leaving
5204
+ * a freshness claim with nothing behind it: `IsDatasetCached` answers true and the blob is gone.
5205
+ * Expiring the claim first makes the pair fail in the safe direction — the cache reads as
5206
+ * absent and is refetched.
5207
+ */
5208
+ static { this.DatasetDateCacheTTLSeconds = ProviderBase.DatasetCacheTTLSeconds - 300; }
4947
5209
  /**
4948
5210
  * Creates a unique key for the given datasetName and itemFilters combination coupled with the instance connection string to ensure uniqueness when 2+ connections exist
4949
5211
  * @param datasetName
@@ -4975,9 +5237,9 @@ export class ProviderBase {
4975
5237
  const ls = this.LocalStorageProvider;
4976
5238
  if (ls) {
4977
5239
  const key = this.GetDatasetCacheKey(datasetName, itemFilters);
4978
- await ls.Remove(key);
5240
+ await ls.Remove(key, ProviderBase.DatasetCacheCategory);
4979
5241
  const dateKey = key + '_date';
4980
- await ls.Remove(dateKey);
5242
+ await ls.Remove(dateKey, ProviderBase.DatasetCacheCategory);
4981
5243
  }
4982
5244
  }
4983
5245
  /**
@@ -5148,7 +5410,27 @@ export class ProviderBase {
5148
5410
  const format = all.get(fmtKey) ?? null;
5149
5411
  const raw = all.get(dataKey) ?? null;
5150
5412
  const membershipRaw = all.get(membershipKey) ?? null;
5151
- this._latestLocalMetadataTimestamps = tsRaw ? JSON.parse(tsRaw) : null;
5413
+ // The timestamps are a freshness claim FOR THE PAYLOAD, so they are only adopted when the
5414
+ // payload is there to adopt with them. Timestamps without a payload happen whenever the
5415
+ // two keys do not disappear together: the timestamps key is written last (and removed
5416
+ // last by a cache clear), so it outlives the payload under any per-key expiry. Adopting
5417
+ // them alone left this process claiming to be current while holding no metadata at all
5418
+ // — the staleness check then confirms "current" and nothing ever reloads.
5419
+ const claimUsable = !!tsRaw && !!raw;
5420
+ if (claimUsable) {
5421
+ this._latestLocalMetadataTimestamps = JSON.parse(tsRaw);
5422
+ }
5423
+ else if (!this._localMetadata?.AllEntities?.length) {
5424
+ // Nothing usable stored and nothing held: fall back to asking the database.
5425
+ this._latestLocalMetadataTimestamps = null;
5426
+ }
5427
+ // A store without timestamps says nothing about the metadata this process already holds
5428
+ // (another process may have just cleared it), so held timestamps survive: the staleness
5429
+ // check then compares what is held with the database instead of assuming it is stale.
5430
+ this._storedTimestampsRaw = claimUsable ? tsRaw : null;
5431
+ if (tsRaw && !raw) {
5432
+ LogStatusEx({ message: '[Metadata Cache] stored timestamps have no payload behind them (the payload expired or was cleared first) — ignoring the claim and checking the database', verboseOnly: true });
5433
+ }
5152
5434
  // Restore the metadata-member entity set so the event-driven refresh works on a warm
5153
5435
  // boot that never calls GetAllMetadata (the stale-while-revalidate fast start). The
5154
5436
  // set is only stored after a successful dataset load, so restoring it is exactly as
@@ -5333,7 +5615,9 @@ export class ProviderBase {
5333
5615
  }
5334
5616
  // Timestamps LAST: they are the freshness claim for everything written above, so they
5335
5617
  // must be the final thing to land.
5336
- await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey, JSON.stringify(this._latestLocalMetadataTimestamps));
5618
+ const timestampsRaw = JSON.stringify(this._latestLocalMetadataTimestamps);
5619
+ await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey, timestampsRaw);
5620
+ this._storedTimestampsRaw = timestampsRaw;
5337
5621
  this.logMetadataSaveComplete(Date.now() - start, jsonString.length, snapshot);
5338
5622
  }
5339
5623
  catch (e) {