@memberjunction/core 6.1.4 → 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 (55) 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/baseEngine.d.ts +21 -0
  6. package/dist/generic/baseEngine.d.ts.map +1 -1
  7. package/dist/generic/baseEngine.js +60 -5
  8. package/dist/generic/baseEngine.js.map +1 -1
  9. package/dist/generic/baseEntity.d.ts +2 -1
  10. package/dist/generic/baseEntity.d.ts.map +1 -1
  11. package/dist/generic/baseEntity.js +3 -4
  12. package/dist/generic/baseEntity.js.map +1 -1
  13. package/dist/generic/databaseProviderBase.d.ts +57 -8
  14. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  15. package/dist/generic/databaseProviderBase.js +107 -42
  16. package/dist/generic/databaseProviderBase.js.map +1 -1
  17. package/dist/generic/entityRecordNameCache.d.ts +48 -0
  18. package/dist/generic/entityRecordNameCache.d.ts.map +1 -0
  19. package/dist/generic/entityRecordNameCache.js +92 -0
  20. package/dist/generic/entityRecordNameCache.js.map +1 -0
  21. package/dist/generic/interfaces.d.ts +59 -4
  22. package/dist/generic/interfaces.d.ts.map +1 -1
  23. package/dist/generic/interfaces.js.map +1 -1
  24. package/dist/generic/localCacheManager.d.ts +105 -11
  25. package/dist/generic/localCacheManager.d.ts.map +1 -1
  26. package/dist/generic/localCacheManager.js +221 -40
  27. package/dist/generic/localCacheManager.js.map +1 -1
  28. package/dist/generic/providerBase.d.ts +40 -22
  29. package/dist/generic/providerBase.d.ts.map +1 -1
  30. package/dist/generic/providerBase.js +93 -103
  31. package/dist/generic/providerBase.js.map +1 -1
  32. package/dist/generic/queryInfo.d.ts +14 -2
  33. package/dist/generic/queryInfo.d.ts.map +1 -1
  34. package/dist/generic/queryInfo.js +18 -5
  35. package/dist/generic/queryInfo.js.map +1 -1
  36. package/dist/generic/runQuery.d.ts +18 -1
  37. package/dist/generic/runQuery.d.ts.map +1 -1
  38. package/dist/generic/runQuery.js.map +1 -1
  39. package/dist/generic/runQuerySQLFilterImplementations.d.ts +7 -1
  40. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  41. package/dist/generic/runQuerySQLFilterImplementations.js +65 -140
  42. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  43. package/dist/generic/telemetryManager.d.ts +11 -0
  44. package/dist/generic/telemetryManager.d.ts.map +1 -1
  45. package/dist/generic/telemetryManager.js +40 -0
  46. package/dist/generic/telemetryManager.js.map +1 -1
  47. package/dist/generic/util.d.ts +22 -0
  48. package/dist/generic/util.d.ts.map +1 -1
  49. package/dist/generic/util.js +33 -0
  50. package/dist/generic/util.js.map +1 -1
  51. package/dist/index.d.ts +1 -0
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +1 -0
  54. package/dist/index.js.map +1 -1
  55. package/package.json +3 -3
@@ -6,7 +6,7 @@ import { ComputeRRF } from "./scoring/ReciprocalRankFusion.js";
6
6
  import { LocalCacheManager } from "./localCacheManager.js";
7
7
  import { ApplicationInfo } from "../generic/applicationInfo.js";
8
8
  import { AuditLogTypeInfo, AuthorizationInfo, AuthorizationRoleInfo, RoleInfo, RowLevelSecurityFilterInfo, UserInfo } from "./securityInfo.js";
9
- import { MJGlobal, MJEventType, NormalizeUUID, UUIDsEqual, MJLruCache, EscapeSQLString, ordinalCompare } from "@memberjunction/global";
9
+ import { MJGlobal, MJEventType, NormalizeUUID, UUIDsEqual, EscapeSQLString, ordinalCompare } from "@memberjunction/global";
10
10
  import { FindReferencedIdentifiers } from "@memberjunction/sql-dialect";
11
11
  import { TelemetryManager } from "./telemetryManager.js";
12
12
  import { LogDebug, LogError, LogStatus, LogStatusEx } from "./logging.js";
@@ -186,10 +186,6 @@ export class ProviderBase {
186
186
  this._localMetadata = new AllMetadata();
187
187
  this._entityMapByName = new Map();
188
188
  this._entityMapByID = new Map();
189
- // Bounded LRU (unlike its siblings above, this cache holds one entry per distinct
190
- // *record* touched via Load()/Save()/LoadFromData() — not per entity definition — so
191
- // it can't be reset on metadata refresh; it needs its own eviction policy.
192
- this._entityRecordNameCache = new MJLruCache({ maxSize: 10000, ttlMs: 60 * 60 * 1000 });
193
189
  this._refresh = false;
194
190
  /** Single-flight guard for the full metadata reload in {@link Config} — see the comment there. */
195
191
  this._metadataReloadInFlight = null;
@@ -231,6 +227,8 @@ export class ProviderBase {
231
227
  this._metadataMemberRefreshTimer = null;
232
228
  this._clientFingerprintMemo = new WeakMap();
233
229
  this._cachedVisibleExplorerNavigationItems = null;
230
+ /** Guard for {@link logMetadataSnapshotDisabledOnce}. */
231
+ this._metadataSnapshotSkipLogged = false;
234
232
  }
235
233
  /**
236
234
  * Whether this provider can execute a multi-record unit of work atomically, in-process.
@@ -589,124 +587,49 @@ export class ProviderBase {
589
587
  return false;
590
588
  }
591
589
  /**
592
- * Helper to generate cache key for entity record names
593
- */
594
- getCacheKey(entityName, compositeKey) {
595
- return `${entityName}|${compositeKey.ToString()}`;
596
- }
597
- /**
598
- * Asynchronous lookup of a cached entity record name. Returns the cached name if available, or undefined if not cached.
599
- * Use this for synchronous contexts (like template rendering) where you can't await GetEntityRecordName().
590
+ * Returns a record's name for synchronous display code, or `undefined` when it is not at hand.
591
+ *
592
+ * This provider keeps no record names, so without `loadIfNeeded` the answer is always
593
+ * `undefined`. A provider that serves a single user (`GraphQLDataProvider`) overrides the
594
+ * record-name methods with a cache; one shared by several users must not, since a name can be
595
+ * withheld from some of them.
596
+ *
600
597
  * @param entityName - The name of the entity
601
598
  * @param compositeKey - The primary key value(s) for the record
602
- * @param loadIfNeeded - If set to true, will load from database if not already cached
603
- * @returns The cached display name, or undefined if not in cache
599
+ * @param loadIfNeeded - Look the name up when it is not cached
600
+ * @returns The display name, or undefined if not cached and not looked up
604
601
  */
605
602
  async GetCachedRecordName(entityName, compositeKey, loadIfNeeded) {
606
- let cachedEntry = this._entityRecordNameCache.Get(this.getCacheKey(entityName, compositeKey));
607
- if (!cachedEntry && loadIfNeeded) {
608
- cachedEntry = await this.GetEntityRecordName(entityName, compositeKey);
609
- }
610
- return cachedEntry;
603
+ return loadIfNeeded ? this.GetEntityRecordName(entityName, compositeKey) : undefined;
611
604
  }
612
605
  /**
613
- * Stores a record name in the cache for later synchronous retrieval via GetCachedRecordName().
614
- * Called automatically by BaseEntity after Load(), LoadFromData(), and Save() operations.
615
- * @param entityName - The name of the entity
616
- * @param compositeKey - The primary key value(s) for the record
617
- * @param recordName - The display name to cache
606
+ * Offers a record's name for later synchronous retrieval. `BaseEntity` calls it after every
607
+ * Load(), LoadFromData() and Save(). Ignored here; see {@link GetCachedRecordName}.
618
608
  */
619
609
  SetCachedRecordName(entityName, compositeKey, recordName) {
620
- this._entityRecordNameCache.Set(this.getCacheKey(entityName, compositeKey), recordName);
610
+ // Intentionally empty: this provider keeps no record names.
621
611
  }
622
612
  /**
623
- * Gets the display name for a single entity record with caching.
613
+ * Gets the display name for a single entity record.
624
614
  * Uses the entity's IsNameField or falls back to 'Name' field if available.
625
615
  * @param entityName - The name of the entity
626
616
  * @param compositeKey - The primary key value(s) for the record
627
- * @param contextUser - Optional user context for permissions
628
- * @param forceRefresh - If true, bypasses cache and queries database
629
- * @returns The display name of the record or null if not found
617
+ * @param contextUser - The acting user; field- and row-level security are applied for them
618
+ * @param forceRefresh - Bypass any cache. This provider has none, so it always looks up.
619
+ * @returns The display name of the record, or an empty string if not found or not readable
630
620
  */
631
621
  async GetEntityRecordName(entityName, compositeKey, contextUser, forceRefresh = false) {
632
- const cacheKey = this.getCacheKey(entityName, compositeKey);
633
- // Check cache unless forceRefresh
634
- if (!forceRefresh) {
635
- const cached = this._entityRecordNameCache.Get(cacheKey);
636
- if (cached !== undefined) {
637
- return cached;
638
- }
639
- }
640
- // Fetch from database via provider-specific implementation
641
- const name = await this.InternalGetEntityRecordName(entityName, compositeKey, contextUser);
642
- if (name) {
643
- this._entityRecordNameCache.Set(cacheKey, name);
644
- }
645
- return name;
622
+ return this.InternalGetEntityRecordName(entityName, compositeKey, contextUser);
646
623
  }
647
624
  /**
648
- * Gets display names for multiple entity records in a single operation with caching.
649
- * More efficient than multiple GetEntityRecordName calls.
625
+ * Gets display names for multiple entity records in a single operation.
650
626
  * @param info - Array of entity/key pairs to lookup
651
- * @param contextUser - Optional user context for permissions
652
- * @param forceRefresh - If true, bypasses cache and queries database for all records
627
+ * @param contextUser - The acting user; field- and row-level security are applied for them
628
+ * @param forceRefresh - Bypass any cache. This provider has none, so it always looks up.
653
629
  * @returns Array of results with names and status for each requested record
654
630
  */
655
631
  async GetEntityRecordNames(info, contextUser, forceRefresh = false) {
656
- if (!forceRefresh) {
657
- // Check cache for each item, collect uncached items
658
- const results = [];
659
- const uncachedInfo = [];
660
- const uncachedIndexes = [];
661
- for (let i = 0; i < info.length; i++) {
662
- const item = info[i];
663
- const cacheKey = this.getCacheKey(item.EntityName, item.CompositeKey);
664
- const cached = this._entityRecordNameCache.Get(cacheKey);
665
- if (cached !== undefined) {
666
- // Cache hit
667
- results[i] = {
668
- EntityName: item.EntityName,
669
- CompositeKey: item.CompositeKey,
670
- Status: 'cached',
671
- Success: true,
672
- RecordName: cached
673
- };
674
- }
675
- else {
676
- // Cache miss - need to fetch
677
- uncachedInfo.push(item);
678
- uncachedIndexes.push(i);
679
- }
680
- }
681
- // Fetch uncached items from database
682
- if (uncachedInfo.length > 0) {
683
- const uncachedResults = await this.InternalGetEntityRecordNames(uncachedInfo, contextUser);
684
- // Merge results and update cache
685
- for (let i = 0; i < uncachedResults.length; i++) {
686
- const result = uncachedResults[i];
687
- const originalIndex = uncachedIndexes[i];
688
- results[originalIndex] = result;
689
- // Cache successful results
690
- if (result.Success && result.RecordName) {
691
- const cacheKey = this.getCacheKey(result.EntityName, result.CompositeKey);
692
- this._entityRecordNameCache.Set(cacheKey, result.RecordName);
693
- }
694
- }
695
- }
696
- return results;
697
- }
698
- else {
699
- // Force refresh - bypass cache entirely
700
- const results = await this.InternalGetEntityRecordNames(info, contextUser);
701
- // Update cache with fresh results
702
- for (const result of results) {
703
- if (result.Success && result.RecordName) {
704
- const cacheKey = this.getCacheKey(result.EntityName, result.CompositeKey);
705
- this._entityRecordNameCache.Set(cacheKey, result.RecordName);
706
- }
707
- }
708
- return results;
709
- }
632
+ return this.InternalGetEntityRecordNames(info, contextUser);
710
633
  }
711
634
  // ========================================================================
712
635
  // PUBLIC API METHODS - Orchestrate Pre → Cache → Internal → Post flow
@@ -1777,7 +1700,9 @@ export class ProviderBase {
1777
1700
  resolvable: true,
1778
1701
  // No user (client-side / trusted single-user context) ⇒ nothing to authorize against;
1779
1702
  // the gate only consults `authorized` when a contextUser is present.
1780
- authorized: !user || qInfo.UserCanRun(user),
1703
+ // This provider's permissions, not the global provider's: a client connection that is not
1704
+ // the global one may have no global provider at all, or one holding another server's.
1705
+ authorized: !user || qInfo.UserCanRun(user, this),
1781
1706
  categoryPath: qInfo.CategoryPath,
1782
1707
  queryName: qInfo.Name
1783
1708
  };
@@ -4966,6 +4891,12 @@ export class ProviderBase {
4966
4891
  const ls = this.LocalStorageProvider;
4967
4892
  if (!ls)
4968
4893
  return;
4894
+ // Symmetrical with the save: an in-process store can only hand back a copy of the
4895
+ // metadata this heap already holds, at the cost of rebuilding every metadata object.
4896
+ if (!this.MetadataSnapshotPersistenceEnabled) {
4897
+ this.logMetadataSnapshotDisabledOnce();
4898
+ return;
4899
+ }
4969
4900
  const overallStart = Date.now();
4970
4901
  // The metadata snapshot uses three keys (timestamps, format, AllMetadata).
4971
4902
  // Pull them in a single batched read — saves two IDB transaction round-trips
@@ -5056,6 +4987,18 @@ export class ProviderBase {
5056
4987
  * Used for compressed metadata storage/retrieval.
5057
4988
  */
5058
4989
  static base64ToArrayBuffer(base64) {
4990
+ // Node decodes natively; the atob path below allocates an intermediate string and fills the
4991
+ // array a byte at a time.
4992
+ if (typeof Buffer !== 'undefined') {
4993
+ const buf = Buffer.from(base64, 'base64');
4994
+ // Copy into an exact-size buffer: `buf.buffer` is a view into a shared pool, usually
4995
+ // larger than the payload, so returning it directly would carry unrelated bytes and a
4996
+ // wrong byteLength. It is also typed ArrayBufferLike and will not assign to the
4997
+ // declared return type.
4998
+ const exact = new ArrayBuffer(buf.byteLength);
4999
+ new Uint8Array(exact).set(buf);
5000
+ return exact;
5001
+ }
5059
5002
  const binaryString = atob(base64);
5060
5003
  const bytes = new Uint8Array(binaryString.length);
5061
5004
  for (let i = 0; i < binaryString.length; i++) {
@@ -5068,6 +5011,11 @@ export class ProviderBase {
5068
5011
  * Used for compressed metadata storage/retrieval.
5069
5012
  */
5070
5013
  static arrayBufferToBase64(buffer) {
5014
+ // Node encodes natively. The loop below concatenates one character per byte, building a rope
5015
+ // the size of the payload that then has to be flattened.
5016
+ if (typeof Buffer !== 'undefined') {
5017
+ return Buffer.from(buffer).toString('base64');
5018
+ }
5071
5019
  const bytes = new Uint8Array(buffer);
5072
5020
  let binary = '';
5073
5021
  for (let i = 0; i < bytes.byteLength; i++) {
@@ -5083,6 +5031,42 @@ export class ProviderBase {
5083
5031
  get LocalStoragePrefix() {
5084
5032
  return "";
5085
5033
  }
5034
+ /**
5035
+ * Whether anything could read the metadata snapshot back, and therefore whether saving it is
5036
+ * worth the cost.
5037
+ *
5038
+ * The snapshot lets a cold process start from a cached copy of the metadata instead of querying
5039
+ * for it, which only works if the store outlives the writer. Where it does not, both halves of
5040
+ * the round trip are pure cost: the save serializes, gzips and base64-encodes the entire
5041
+ * metadata graph, and the load parses it and rebuilds every `EntityInfo` and `EntityFieldInfo`
5042
+ * from a copy of objects the heap already holds. On a large tenant that is expensive enough to
5043
+ * exhaust the heap.
5044
+ *
5045
+ * A provider that does not declare {@link ILocalStorageProvider.SupportsCrossProcessPersistence}
5046
+ * is treated as persistent, which fails in the safer direction.
5047
+ */
5048
+ get MetadataSnapshotPersistenceEnabled() {
5049
+ const ls = this.LocalStorageProvider;
5050
+ if (!ls) {
5051
+ return false;
5052
+ }
5053
+ return ls.SupportsCrossProcessPersistence !== false;
5054
+ }
5055
+ /**
5056
+ * Explains the skip once per process. The refresh path runs every 30 seconds on a busy server,
5057
+ * so logging per call would bury the one line that matters.
5058
+ */
5059
+ logMetadataSnapshotDisabledOnce() {
5060
+ if (this._metadataSnapshotSkipLogged) {
5061
+ return;
5062
+ }
5063
+ this._metadataSnapshotSkipLogged = true;
5064
+ const name = this.LocalStorageProvider?.constructor?.name ?? 'the local storage provider';
5065
+ LogStatusEx({
5066
+ message: `[Metadata Cache] Snapshot persistence disabled: ${name} is in-process only, so a saved snapshot could never be read back. Skipping the metadata snapshot save and load.`,
5067
+ verboseOnly: false
5068
+ });
5069
+ }
5086
5070
  /**
5087
5071
  * Saves current metadata to local storage for caching.
5088
5072
  * Serializes both timestamps and full metadata collections.
@@ -5092,6 +5076,12 @@ export class ProviderBase {
5092
5076
  const ls = this.LocalStorageProvider;
5093
5077
  if (!ls)
5094
5078
  return;
5079
+ // Nothing could ever read this snapshot back, so the entire serialize/compress/encode
5080
+ // pass buys nothing. See MetadataSnapshotPersistenceEnabled.
5081
+ if (!this.MetadataSnapshotPersistenceEnabled) {
5082
+ this.logMetadataSnapshotDisabledOnce();
5083
+ return;
5084
+ }
5095
5085
  const start = Date.now();
5096
5086
  // Serialize the AllMetadata object FIRST. If this throws (or the payload write below
5097
5087
  // fails), nothing else has been written yet, so the previously stored timestamps still