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

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 (72) 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 +12 -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/baseEngine.d.ts +21 -1
  10. package/dist/generic/baseEngine.d.ts.map +1 -1
  11. package/dist/generic/baseEngine.js +15 -1
  12. package/dist/generic/baseEngine.js.map +1 -1
  13. package/dist/generic/baseEntity.d.ts +91 -7
  14. package/dist/generic/baseEntity.d.ts.map +1 -1
  15. package/dist/generic/baseEntity.js +161 -16
  16. package/dist/generic/baseEntity.js.map +1 -1
  17. package/dist/generic/databaseProviderBase.d.ts +34 -0
  18. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  19. package/dist/generic/databaseProviderBase.js +88 -20
  20. package/dist/generic/databaseProviderBase.js.map +1 -1
  21. package/dist/generic/entityConfiguration.d.ts +20 -0
  22. package/dist/generic/entityConfiguration.d.ts.map +1 -1
  23. package/dist/generic/entityConfiguration.js +34 -0
  24. package/dist/generic/entityConfiguration.js.map +1 -1
  25. package/dist/generic/entityInfo.d.ts +32 -1
  26. package/dist/generic/entityInfo.d.ts.map +1 -1
  27. package/dist/generic/entityInfo.js +44 -2
  28. package/dist/generic/entityInfo.js.map +1 -1
  29. package/dist/generic/entityRecordNameCache.d.ts +48 -0
  30. package/dist/generic/entityRecordNameCache.d.ts.map +1 -0
  31. package/dist/generic/entityRecordNameCache.js +92 -0
  32. package/dist/generic/entityRecordNameCache.js.map +1 -0
  33. package/dist/generic/interfaces.d.ts +33 -6
  34. package/dist/generic/interfaces.d.ts.map +1 -1
  35. package/dist/generic/interfaces.js.map +1 -1
  36. package/dist/generic/jsonFieldBinding.d.ts +196 -0
  37. package/dist/generic/jsonFieldBinding.d.ts.map +1 -0
  38. package/dist/generic/jsonFieldBinding.js +508 -0
  39. package/dist/generic/jsonFieldBinding.js.map +1 -0
  40. package/dist/generic/localCacheManager.d.ts +11 -0
  41. package/dist/generic/localCacheManager.d.ts.map +1 -1
  42. package/dist/generic/localCacheManager.js +9 -0
  43. package/dist/generic/localCacheManager.js.map +1 -1
  44. package/dist/generic/providerBase.d.ts +68 -32
  45. package/dist/generic/providerBase.d.ts.map +1 -1
  46. package/dist/generic/providerBase.js +150 -120
  47. package/dist/generic/providerBase.js.map +1 -1
  48. package/dist/generic/runQuery.d.ts +18 -1
  49. package/dist/generic/runQuery.d.ts.map +1 -1
  50. package/dist/generic/runQuery.js.map +1 -1
  51. package/dist/generic/runQuerySQLFilterImplementations.d.ts +7 -1
  52. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  53. package/dist/generic/runQuerySQLFilterImplementations.js +65 -140
  54. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  55. package/dist/generic/telemetryManager.d.ts +11 -0
  56. package/dist/generic/telemetryManager.d.ts.map +1 -1
  57. package/dist/generic/telemetryManager.js +40 -0
  58. package/dist/generic/telemetryManager.js.map +1 -1
  59. package/dist/generic/util.d.ts +6 -0
  60. package/dist/generic/util.d.ts.map +1 -1
  61. package/dist/generic/util.js +20 -0
  62. package/dist/generic/util.js.map +1 -1
  63. package/dist/index.d.ts +2 -0
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +2 -0
  66. package/dist/index.js.map +1 -1
  67. package/dist/views/runView.d.ts +24 -0
  68. package/dist/views/runView.d.ts.map +1 -1
  69. package/dist/views/runView.js +4 -0
  70. package/dist/views/runView.js.map +1 -1
  71. package/package.json +3 -3
  72. package/readme.md +29 -2
@@ -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;
@@ -251,6 +247,8 @@ export class ProviderBase {
251
247
  this._metadataMemberRefreshWaits = 0;
252
248
  this._clientFingerprintMemo = new WeakMap();
253
249
  this._cachedVisibleExplorerNavigationItems = null;
250
+ /** Guard for {@link logMetadataSnapshotDisabledOnce}. */
251
+ this._metadataSnapshotSkipLogged = false;
254
252
  }
255
253
  /**
256
254
  * Whether this provider can execute a multi-record unit of work atomically, in-process.
@@ -672,143 +670,63 @@ export class ProviderBase {
672
670
  return false;
673
671
  }
674
672
  /**
675
- * Helper to generate cache key for entity record names
676
- */
677
- getCacheKey(entityName, compositeKey) {
678
- return `${entityName}|${compositeKey.ToString()}`;
679
- }
680
- /**
681
- * Asynchronous lookup of a cached entity record name. Returns the cached name if available, or undefined if not cached.
682
- * Use this for synchronous contexts (like template rendering) where you can't await GetEntityRecordName().
673
+ * Returns a record's name for synchronous display code, or `undefined` when it is not at hand.
674
+ *
675
+ * This provider keeps no record names, so without `loadIfNeeded` the answer is always
676
+ * `undefined`. A provider that serves a single user (`GraphQLDataProvider`) overrides the
677
+ * record-name methods with a cache; one shared by several users must not, since a name can be
678
+ * withheld from some of them.
679
+ *
683
680
  * @param entityName - The name of the entity
684
681
  * @param compositeKey - The primary key value(s) for the record
685
- * @param loadIfNeeded - If set to true, will load from database if not already cached
686
- * @returns The cached display name, or undefined if not in cache
682
+ * @param loadIfNeeded - Look the name up when it is not cached
683
+ * @returns The display name, or undefined if not cached and not looked up
687
684
  */
688
685
  async GetCachedRecordName(entityName, compositeKey, loadIfNeeded) {
689
- let cachedEntry = this._entityRecordNameCache.Get(this.getCacheKey(entityName, compositeKey));
690
- if (!cachedEntry && loadIfNeeded) {
691
- cachedEntry = await this.GetEntityRecordName(entityName, compositeKey);
692
- }
693
- return cachedEntry;
686
+ return loadIfNeeded ? this.GetEntityRecordName(entityName, compositeKey) : undefined;
694
687
  }
695
688
  /**
696
- * Checks whether an entity record name is currently available in the in-memory LRU cache.
697
- * @param entityName - The name of the entity
698
- * @param compositeKey - The primary key value(s) for the record
699
- * @returns True if the record name is cached in memory, false otherwise
689
+ * Whether a record's name is cached for synchronous retrieval. Always false here; see
690
+ * {@link GetCachedRecordName}.
700
691
  */
701
692
  HasCachedRecordName(entityName, compositeKey) {
702
- return this._entityRecordNameCache.Get(this.getCacheKey(entityName, compositeKey)) !== undefined;
693
+ return false;
703
694
  }
704
695
  /**
705
- * Retrieves an entity record name from the in-memory LRU cache if already cached.
706
- * Returns undefined immediately when not cached and will NEVER initiate a database lookup.
707
- * @param entityName - The name of the entity
708
- * @param compositeKey - The primary key value(s) for the record
709
- * @returns The cached display name, or undefined if not in cache
696
+ * A record's cached name, without ever starting a lookup. Always undefined here; see
697
+ * {@link GetCachedRecordName}.
710
698
  */
711
699
  GetCachedRecordNameOnlyIfCached(entityName, compositeKey) {
712
- return this._entityRecordNameCache.Get(this.getCacheKey(entityName, compositeKey));
700
+ return undefined;
713
701
  }
714
702
  /**
715
- * Stores a record name in the cache for later synchronous retrieval via GetCachedRecordName().
716
- * Called automatically by BaseEntity after Load(), LoadFromData(), and Save() operations.
717
- * @param entityName - The name of the entity
718
- * @param compositeKey - The primary key value(s) for the record
719
- * @param recordName - The display name to cache
703
+ * Offers a record's name for later synchronous retrieval. `BaseEntity` calls it after every
704
+ * Load(), LoadFromData() and Save(). Ignored here; see {@link GetCachedRecordName}.
720
705
  */
721
706
  SetCachedRecordName(entityName, compositeKey, recordName) {
722
- this._entityRecordNameCache.Set(this.getCacheKey(entityName, compositeKey), recordName);
707
+ // Intentionally empty: this provider keeps no record names.
723
708
  }
724
709
  /**
725
- * Gets the display name for a single entity record with caching.
710
+ * Gets the display name for a single entity record.
726
711
  * Uses the entity's IsNameField or falls back to 'Name' field if available.
727
712
  * @param entityName - The name of the entity
728
713
  * @param compositeKey - The primary key value(s) for the record
729
- * @param contextUser - Optional user context for permissions
730
- * @param forceRefresh - If true, bypasses cache and queries database
731
- * @returns The display name of the record or null if not found
714
+ * @param contextUser - The acting user; field- and row-level security are applied for them
715
+ * @param forceRefresh - Bypass any cache. This provider has none, so it always looks up.
716
+ * @returns The display name of the record, or an empty string if not found or not readable
732
717
  */
733
718
  async GetEntityRecordName(entityName, compositeKey, contextUser, forceRefresh = false) {
734
- const cacheKey = this.getCacheKey(entityName, compositeKey);
735
- // Check cache unless forceRefresh
736
- if (!forceRefresh) {
737
- const cached = this._entityRecordNameCache.Get(cacheKey);
738
- if (cached !== undefined) {
739
- return cached;
740
- }
741
- }
742
- // Fetch from database via provider-specific implementation
743
- const name = await this.InternalGetEntityRecordName(entityName, compositeKey, contextUser);
744
- if (name) {
745
- this._entityRecordNameCache.Set(cacheKey, name);
746
- }
747
- return name;
719
+ return this.InternalGetEntityRecordName(entityName, compositeKey, contextUser);
748
720
  }
749
721
  /**
750
- * Gets display names for multiple entity records in a single operation with caching.
751
- * More efficient than multiple GetEntityRecordName calls.
722
+ * Gets display names for multiple entity records in a single operation.
752
723
  * @param info - Array of entity/key pairs to lookup
753
- * @param contextUser - Optional user context for permissions
754
- * @param forceRefresh - If true, bypasses cache and queries database for all records
724
+ * @param contextUser - The acting user; field- and row-level security are applied for them
725
+ * @param forceRefresh - Bypass any cache. This provider has none, so it always looks up.
755
726
  * @returns Array of results with names and status for each requested record
756
727
  */
757
728
  async GetEntityRecordNames(info, contextUser, forceRefresh = false) {
758
- if (!forceRefresh) {
759
- // Check cache for each item, collect uncached items
760
- const results = [];
761
- const uncachedInfo = [];
762
- const uncachedIndexes = [];
763
- for (let i = 0; i < info.length; i++) {
764
- const item = info[i];
765
- const cacheKey = this.getCacheKey(item.EntityName, item.CompositeKey);
766
- const cached = this._entityRecordNameCache.Get(cacheKey);
767
- if (cached !== undefined) {
768
- // Cache hit
769
- results[i] = {
770
- EntityName: item.EntityName,
771
- CompositeKey: item.CompositeKey,
772
- Status: 'cached',
773
- Success: true,
774
- RecordName: cached
775
- };
776
- }
777
- else {
778
- // Cache miss - need to fetch
779
- uncachedInfo.push(item);
780
- uncachedIndexes.push(i);
781
- }
782
- }
783
- // Fetch uncached items from database
784
- if (uncachedInfo.length > 0) {
785
- const uncachedResults = await this.InternalGetEntityRecordNames(uncachedInfo, contextUser);
786
- // Merge results and update cache
787
- for (let i = 0; i < uncachedResults.length; i++) {
788
- const result = uncachedResults[i];
789
- const originalIndex = uncachedIndexes[i];
790
- results[originalIndex] = result;
791
- // Cache successful results
792
- if (result.Success && result.RecordName) {
793
- const cacheKey = this.getCacheKey(result.EntityName, result.CompositeKey);
794
- this._entityRecordNameCache.Set(cacheKey, result.RecordName);
795
- }
796
- }
797
- }
798
- return results;
799
- }
800
- else {
801
- // Force refresh - bypass cache entirely
802
- const results = await this.InternalGetEntityRecordNames(info, contextUser);
803
- // Update cache with fresh results
804
- for (const result of results) {
805
- if (result.Success && result.RecordName) {
806
- const cacheKey = this.getCacheKey(result.EntityName, result.CompositeKey);
807
- this._entityRecordNameCache.Set(cacheKey, result.RecordName);
808
- }
809
- }
810
- return results;
811
- }
729
+ return this.InternalGetEntityRecordNames(info, contextUser);
812
730
  }
813
731
  // ========================================================================
814
732
  // PUBLIC API METHODS - Orchestrate Pre → Cache → Internal → Post flow
@@ -2257,8 +2175,44 @@ export class ProviderBase {
2257
2175
  * they asked for, and field security narrows per request at read time via
2258
2176
  * {@link ApplyFieldSecurityProjection}.
2259
2177
  */
2260
- ComputeRunViewFetchFields(entity) {
2261
- return entity.Fields.map(f => f.Name);
2178
+ ComputeRunViewFetchFields(entity, params) {
2179
+ const includeBinary = params?.IncludeBinaryFields === true;
2180
+ return entity.Fields.filter(f => includeBinary || !f.IsBinaryFieldType).map(f => f.Name);
2181
+ }
2182
+ /**
2183
+ * True when `fieldNames` names at least one of the entity's binary fields (case-insensitive,
2184
+ * whitespace-trimmed).
2185
+ *
2186
+ * @param entity - The entity being queried.
2187
+ * @param fieldNames - Field names as a caller supplied them, e.g. `RunViewParams.Fields`.
2188
+ */
2189
+ static NamesAnyBinaryField(entity, fieldNames) {
2190
+ if (!fieldNames || fieldNames.length === 0)
2191
+ return false;
2192
+ const binaryNames = new Set(entity.Fields.filter(f => f.IsBinaryFieldType).map(f => f.Name.toLowerCase()));
2193
+ if (binaryNames.size === 0)
2194
+ return false;
2195
+ return fieldNames.some(name => typeof name === 'string' && binaryNames.has(name.trim().toLowerCase()));
2196
+ }
2197
+ /**
2198
+ * Decides whether a RunView returns the entity's binary fields, and records the decision on
2199
+ * `params.IncludeBinaryFields` so every later step (field widening, cache fingerprint,
2200
+ * transport) sees one answer.
2201
+ *
2202
+ * Binary fields are returned when the caller sets `IncludeBinaryFields`, or names a binary
2203
+ * field in `Fields` — asking for a column by name is asking for it. Otherwise they are left
2204
+ * out, which keeps result sets, engine caches and the RunView caches free of large values
2205
+ * nobody reads. Must run before `Fields` is widened, since widening replaces the caller's list.
2206
+ *
2207
+ * @param params - The view parameters; `IncludeBinaryFields` is set to true when applicable.
2208
+ * @param entity - The entity being queried.
2209
+ * @returns True when binary fields will be returned.
2210
+ */
2211
+ ResolveIncludeBinaryFields(params, entity) {
2212
+ if (params.IncludeBinaryFields !== true && ProviderBase.NamesAnyBinaryField(entity, params.Fields)) {
2213
+ params.IncludeBinaryFields = true;
2214
+ }
2215
+ return params.IncludeBinaryFields === true;
2262
2216
  }
2263
2217
  /**
2264
2218
  * Pre-processing hook for RunView.
@@ -2530,8 +2484,10 @@ export class ProviderBase {
2530
2484
  // field-restricted user RECEIVES is still their allowed set: the server strips denied
2531
2485
  // columns on the wire, and the missing keys mark those fields not-loaded.
2532
2486
  const widenForEntityObject = params.ResultType === 'entity_object';
2487
+ if (entity)
2488
+ this.ResolveIncludeBinaryFields(params, entity);
2533
2489
  if (entity && (willCache || widenForEntityObject)) {
2534
- params.Fields = this.ComputeRunViewFetchFields(entity);
2490
+ params.Fields = this.ComputeRunViewFetchFields(entity, params);
2535
2491
  // Platform contract: explicit Fields always include the primary key(s) —
2536
2492
  // project back down to requested ∪ PK, matching the direct SQL path.
2537
2493
  if (callerRequestedFields) {
@@ -2705,8 +2661,10 @@ export class ProviderBase {
2705
2661
  const batchWillCache = this.runViewCacheEligible(param);
2706
2662
  // Same entity_object-always-widens rule as the single-view path above.
2707
2663
  const batchWidenForEntityObject = param.ResultType === 'entity_object';
2664
+ if (batchEntity)
2665
+ this.ResolveIncludeBinaryFields(param, batchEntity);
2708
2666
  if (batchEntity && (batchWillCache || batchWidenForEntityObject)) {
2709
- param.Fields = this.ComputeRunViewFetchFields(batchEntity);
2667
+ param.Fields = this.ComputeRunViewFetchFields(batchEntity, param);
2710
2668
  // Platform contract: explicit Fields always include the primary key(s)
2711
2669
  if (callerFields) {
2712
2670
  callerFields = ProviderBase.UnionFieldsWithPrimaryKeys(callerFields, batchEntity);
@@ -2815,7 +2773,8 @@ export class ProviderBase {
2815
2773
  // provider FETCHES, not just how the slot is keyed, and I could not verify the
2816
2774
  // downstream fetch behavior tonight. The safe fix is to normalize a full-coverage
2817
2775
  // field list to `*` in the FINGERPRINT only, which cannot affect fetching.
2818
- param.Fields = entity.Fields.map(f => f.Name);
2776
+ this.ResolveIncludeBinaryFields(param, entity);
2777
+ param.Fields = this.ComputeRunViewFetchFields(entity, param);
2819
2778
  }
2820
2779
  // Gate on runViewCacheEligible (NOT raw param.CacheLocal): the smart-cache-check path is a
2821
2780
  // second, independent cache transport, and gating it on CacheLocal alone re-admits the exact
@@ -3642,7 +3601,11 @@ export class ProviderBase {
3642
3601
  const entity = this.EntityByName(params.EntityName);
3643
3602
  if (!entity)
3644
3603
  throw new Error(`Entity ${params.EntityName} not found in metadata`);
3645
- params.Fields = entity.Fields.map(f => f.Name); // just override whatever was passed in with all the fields - or if nothing was passed in, we set it. For loading the entity object, we need ALL the fields.
3604
+ // Override whatever was passed in with every field the entity object hydrates from.
3605
+ // Binary fields are included only when requested (ResolveIncludeBinaryFields); a
3606
+ // missing binary field hydrates as not-loaded and is never written back on Save.
3607
+ this.ResolveIncludeBinaryFields(params, entity);
3608
+ params.Fields = this.ComputeRunViewFetchFields(entity, params);
3646
3609
  }
3647
3610
  }
3648
3611
  /**
@@ -3706,7 +3669,9 @@ export class ProviderBase {
3706
3669
  if (!entity) {
3707
3670
  throw new Error(`Entity ${param.EntityName} not found in metadata`);
3708
3671
  }
3709
- param.Fields = entity.Fields.map(f => f.Name); // just override whatever was passed in with all the fields - or if nothing was passed in, we set it. For loading the entity object, we need ALL the fields.
3672
+ // Same rule as PreProcessRunView: every field, binary fields only on request.
3673
+ this.ResolveIncludeBinaryFields(param, entity);
3674
+ param.Fields = this.ComputeRunViewFetchFields(entity, param);
3710
3675
  }
3711
3676
  }
3712
3677
  }
@@ -5161,6 +5126,12 @@ export class ProviderBase {
5161
5126
  const ls = this.LocalStorageProvider;
5162
5127
  if (!ls)
5163
5128
  return;
5129
+ // Symmetrical with the save: an in-process store can only hand back a copy of the
5130
+ // metadata this heap already holds, at the cost of rebuilding every metadata object.
5131
+ if (!this.MetadataSnapshotPersistenceEnabled) {
5132
+ this.logMetadataSnapshotDisabledOnce();
5133
+ return;
5134
+ }
5164
5135
  const overallStart = Date.now();
5165
5136
  // The metadata snapshot uses three keys (timestamps, format, AllMetadata).
5166
5137
  // Pull them in a single batched read — saves two IDB transaction round-trips
@@ -5251,6 +5222,18 @@ export class ProviderBase {
5251
5222
  * Used for compressed metadata storage/retrieval.
5252
5223
  */
5253
5224
  static base64ToArrayBuffer(base64) {
5225
+ // Node decodes natively; the atob path below allocates an intermediate string and fills the
5226
+ // array a byte at a time.
5227
+ if (typeof Buffer !== 'undefined') {
5228
+ const buf = Buffer.from(base64, 'base64');
5229
+ // Copy into an exact-size buffer: `buf.buffer` is a view into a shared pool, usually
5230
+ // larger than the payload, so returning it directly would carry unrelated bytes and a
5231
+ // wrong byteLength. It is also typed ArrayBufferLike and will not assign to the
5232
+ // declared return type.
5233
+ const exact = new ArrayBuffer(buf.byteLength);
5234
+ new Uint8Array(exact).set(buf);
5235
+ return exact;
5236
+ }
5254
5237
  const binaryString = atob(base64);
5255
5238
  const bytes = new Uint8Array(binaryString.length);
5256
5239
  for (let i = 0; i < binaryString.length; i++) {
@@ -5263,6 +5246,11 @@ export class ProviderBase {
5263
5246
  * Used for compressed metadata storage/retrieval.
5264
5247
  */
5265
5248
  static arrayBufferToBase64(buffer) {
5249
+ // Node encodes natively. The loop below concatenates one character per byte, building a rope
5250
+ // the size of the payload that then has to be flattened.
5251
+ if (typeof Buffer !== 'undefined') {
5252
+ return Buffer.from(buffer).toString('base64');
5253
+ }
5266
5254
  const bytes = new Uint8Array(buffer);
5267
5255
  let binary = '';
5268
5256
  for (let i = 0; i < bytes.byteLength; i++) {
@@ -5278,6 +5266,42 @@ export class ProviderBase {
5278
5266
  get LocalStoragePrefix() {
5279
5267
  return "";
5280
5268
  }
5269
+ /**
5270
+ * Whether anything could read the metadata snapshot back, and therefore whether saving it is
5271
+ * worth the cost.
5272
+ *
5273
+ * The snapshot lets a cold process start from a cached copy of the metadata instead of querying
5274
+ * for it, which only works if the store outlives the writer. Where it does not, both halves of
5275
+ * the round trip are pure cost: the save serializes, gzips and base64-encodes the entire
5276
+ * metadata graph, and the load parses it and rebuilds every `EntityInfo` and `EntityFieldInfo`
5277
+ * from a copy of objects the heap already holds. On a large tenant that is expensive enough to
5278
+ * exhaust the heap.
5279
+ *
5280
+ * A provider that does not declare {@link ILocalStorageProvider.SupportsCrossProcessPersistence}
5281
+ * is treated as persistent, which fails in the safer direction.
5282
+ */
5283
+ get MetadataSnapshotPersistenceEnabled() {
5284
+ const ls = this.LocalStorageProvider;
5285
+ if (!ls) {
5286
+ return false;
5287
+ }
5288
+ return ls.SupportsCrossProcessPersistence !== false;
5289
+ }
5290
+ /**
5291
+ * Explains the skip once per process. The refresh path runs every 30 seconds on a busy server,
5292
+ * so logging per call would bury the one line that matters.
5293
+ */
5294
+ logMetadataSnapshotDisabledOnce() {
5295
+ if (this._metadataSnapshotSkipLogged) {
5296
+ return;
5297
+ }
5298
+ this._metadataSnapshotSkipLogged = true;
5299
+ const name = this.LocalStorageProvider?.constructor?.name ?? 'the local storage provider';
5300
+ LogStatusEx({
5301
+ 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.`,
5302
+ verboseOnly: false
5303
+ });
5304
+ }
5281
5305
  /**
5282
5306
  * Saves current metadata to local storage for caching.
5283
5307
  * Serializes both timestamps and full metadata collections.
@@ -5287,6 +5311,12 @@ export class ProviderBase {
5287
5311
  const ls = this.LocalStorageProvider;
5288
5312
  if (!ls)
5289
5313
  return;
5314
+ // Nothing could ever read this snapshot back, so the entire serialize/compress/encode
5315
+ // pass buys nothing. See MetadataSnapshotPersistenceEnabled.
5316
+ if (!this.MetadataSnapshotPersistenceEnabled) {
5317
+ this.logMetadataSnapshotDisabledOnce();
5318
+ return;
5319
+ }
5290
5320
  const start = Date.now();
5291
5321
  // Serialize the AllMetadata object FIRST. If this throws (or the payload write below
5292
5322
  // fails), nothing else has been written yet, so the previously stored timestamps still