@memberjunction/core 6.2.0-edge.1 → 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 (74) 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 +192 -0
  6. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
  7. package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts +28 -0
  8. package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts.map +1 -1
  9. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts +95 -0
  10. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts.map +1 -1
  11. package/dist/generic/baseEngine.d.ts +21 -1
  12. package/dist/generic/baseEngine.d.ts.map +1 -1
  13. package/dist/generic/baseEngine.js +15 -1
  14. package/dist/generic/baseEngine.js.map +1 -1
  15. package/dist/generic/baseEntity.d.ts +292 -18
  16. package/dist/generic/baseEntity.d.ts.map +1 -1
  17. package/dist/generic/baseEntity.js +553 -105
  18. package/dist/generic/baseEntity.js.map +1 -1
  19. package/dist/generic/databaseProviderBase.d.ts +47 -7
  20. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  21. package/dist/generic/databaseProviderBase.js +106 -29
  22. package/dist/generic/databaseProviderBase.js.map +1 -1
  23. package/dist/generic/entityConfiguration.d.ts +21 -0
  24. package/dist/generic/entityConfiguration.d.ts.map +1 -1
  25. package/dist/generic/entityConfiguration.js +34 -0
  26. package/dist/generic/entityConfiguration.js.map +1 -1
  27. package/dist/generic/entityInfo.d.ts +60 -2
  28. package/dist/generic/entityInfo.d.ts.map +1 -1
  29. package/dist/generic/entityInfo.js +81 -2
  30. package/dist/generic/entityInfo.js.map +1 -1
  31. package/dist/generic/entityRecordNameCache.d.ts +48 -0
  32. package/dist/generic/entityRecordNameCache.d.ts.map +1 -0
  33. package/dist/generic/entityRecordNameCache.js +92 -0
  34. package/dist/generic/entityRecordNameCache.js.map +1 -0
  35. package/dist/generic/interfaces.d.ts +45 -9
  36. package/dist/generic/interfaces.d.ts.map +1 -1
  37. package/dist/generic/interfaces.js.map +1 -1
  38. package/dist/generic/jsonFieldBinding.d.ts +196 -0
  39. package/dist/generic/jsonFieldBinding.d.ts.map +1 -0
  40. package/dist/generic/jsonFieldBinding.js +508 -0
  41. package/dist/generic/jsonFieldBinding.js.map +1 -0
  42. package/dist/generic/localCacheManager.d.ts +11 -0
  43. package/dist/generic/localCacheManager.d.ts.map +1 -1
  44. package/dist/generic/localCacheManager.js +9 -0
  45. package/dist/generic/localCacheManager.js.map +1 -1
  46. package/dist/generic/providerBase.d.ts +150 -33
  47. package/dist/generic/providerBase.d.ts.map +1 -1
  48. package/dist/generic/providerBase.js +304 -139
  49. package/dist/generic/providerBase.js.map +1 -1
  50. package/dist/generic/runQuery.d.ts +18 -1
  51. package/dist/generic/runQuery.d.ts.map +1 -1
  52. package/dist/generic/runQuery.js.map +1 -1
  53. package/dist/generic/runQuerySQLFilterImplementations.d.ts +7 -1
  54. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  55. package/dist/generic/runQuerySQLFilterImplementations.js +65 -140
  56. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  57. package/dist/generic/telemetryManager.d.ts +11 -0
  58. package/dist/generic/telemetryManager.d.ts.map +1 -1
  59. package/dist/generic/telemetryManager.js +40 -0
  60. package/dist/generic/telemetryManager.js.map +1 -1
  61. package/dist/generic/util.d.ts +6 -0
  62. package/dist/generic/util.d.ts.map +1 -1
  63. package/dist/generic/util.js +20 -0
  64. package/dist/generic/util.js.map +1 -1
  65. package/dist/index.d.ts +2 -0
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +2 -0
  68. package/dist/index.js.map +1 -1
  69. package/dist/views/runView.d.ts +24 -0
  70. package/dist/views/runView.d.ts.map +1 -1
  71. package/dist/views/runView.js +4 -0
  72. package/dist/views/runView.js.map +1 -1
  73. package/package.json +3 -3
  74. 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,16 +186,25 @@ 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;
196
192
  /** Set when a refresh request arrives while a reload is in flight; the reload loop reruns once. */
197
193
  this._metadataReloadQueued = false;
194
+ /** When the last SUCCESSFUL refresh check's status request completed; 0 = never. */
198
195
  this._lastRefreshCheckAt = 0;
196
+ /**
197
+ * The check currently running in {@link CheckToSeeIfRefreshNeeded}; null when none is. While
198
+ * set, a non-bypassing caller is answered `false` without a request — the owner of this check
199
+ * acts on its answer. Held as the promise (not a flag) so only its owner clears it.
200
+ */
201
+ this._refreshCheckInFlight = null;
202
+ /**
203
+ * Set when Config() adopts a snapshot it loaded from the server (which fetched the current user
204
+ * with it); consumed by the next {@link preValidateAndRefresh}, which then has nothing to check.
205
+ */
206
+ this._configLoadedFromServer = false;
207
+ this._lastMetadataLoadError = null;
199
208
  /**
200
209
  * Pending coalesced requests waiting to be flushed.
201
210
  * Each entry tracks the original params, contextUser, and a resolver
@@ -227,11 +236,19 @@ export class ProviderBase {
227
236
  * hardcoded anywhere in this mechanism.
228
237
  */
229
238
  this._metadataDatasetEntityNames = null;
239
+ /**
240
+ * True only while {@link RefreshWithinTransaction} is reloading. The metadata dataset is
241
+ * normally read on the pool (#4514); this window joins those reads to the caller's transaction
242
+ * so a push can see the metadata rows it has not committed yet (MJ#4836).
243
+ */
244
+ this._metadataReadsJoinTransaction = false;
230
245
  /** Debounce timer for {@link scheduleMetadataMemberRefresh}. */
231
246
  this._metadataMemberRefreshTimer = null;
232
247
  this._metadataMemberRefreshWaits = 0;
233
248
  this._clientFingerprintMemo = new WeakMap();
234
249
  this._cachedVisibleExplorerNavigationItems = null;
250
+ /** Guard for {@link logMetadataSnapshotDisabledOnce}. */
251
+ this._metadataSnapshotSkipLogged = false;
235
252
  }
236
253
  /**
237
254
  * Whether this provider can execute a multi-record unit of work atomically, in-process.
@@ -253,9 +270,22 @@ export class ProviderBase {
253
270
  * Minimum interval (ms) between metadata refresh checks to prevent
254
271
  * redundant network calls when Config()/RefreshIfNeeded() fire in
255
272
  * quick succession (e.g., multiple engines during startup).
273
+ * The throttle is armed only by a check whose status request succeeded — a check
274
+ * that threw or got no timestamps proved nothing, so the next caller checks again.
275
+ * A caller arriving while a check is in flight is told no refresh is needed, as the
276
+ * in-flight owner acts on the answer (see {@link CheckToSeeIfRefreshNeeded}).
256
277
  * Does NOT affect forced Refresh() calls. Default: 30 000 ms.
257
278
  */
258
279
  static { this.MinRefreshCheckIntervalMs = 30000; }
280
+ /**
281
+ * Why the most recent metadata download ({@link GetAllMetadata}) failed; null when it succeeded
282
+ * or none has run. GetAllMetadata logs and returns undefined on failure so a background refresh
283
+ * keeps the last good graph; a boot that ends with no metadata reads this to report the cause
284
+ * (e.g. a user with no roles cannot read `MJ: User Roles`) instead of a generic "nothing loaded".
285
+ */
286
+ get LastMetadataLoadError() {
287
+ return this._lastMetadataLoadError;
288
+ }
259
289
  // ── Server-Side Auto-Cache ────────────────────────────────────────
260
290
  /**
261
291
  * Maximum row count for auto-caching on the server side. When
@@ -453,6 +483,26 @@ export class ProviderBase {
453
483
  this._metadataDatasetEntityNames = names;
454
484
  this.ensureInflightViewInvalidation();
455
485
  }
486
+ /**
487
+ * Whether `entityName` is one of the entities this provider's metadata is built from.
488
+ * False when the membership set has not been loaded yet — a caller must not treat "unknown"
489
+ * as "reload". Names are compared case-insensitively, matching the set recorded by
490
+ * {@link registerMetadataDatasetMembership}.
491
+ */
492
+ IsMetadataDatasetMember(entityName) {
493
+ const name = entityName?.trim().toLowerCase();
494
+ if (!name || !this._metadataDatasetEntityNames) {
495
+ return false;
496
+ }
497
+ return this._metadataDatasetEntityNames.has(name);
498
+ }
499
+ /**
500
+ * True while {@link RefreshWithinTransaction} is running, so metadata-dataset reads join the
501
+ * ambient transaction instead of the pool.
502
+ */
503
+ get MetadataReadsJoinTransaction() {
504
+ return this._metadataReadsJoinTransaction;
505
+ }
456
506
  /**
457
507
  * Static fan-out callback: a BaseEntity save/delete (or a remote-invalidate from another
458
508
  * server) touched `lowerEntityName`. If that entity is one of the entities this provider's
@@ -620,143 +670,63 @@ export class ProviderBase {
620
670
  return false;
621
671
  }
622
672
  /**
623
- * Helper to generate cache key for entity record names
624
- */
625
- getCacheKey(entityName, compositeKey) {
626
- return `${entityName}|${compositeKey.ToString()}`;
627
- }
628
- /**
629
- * Asynchronous lookup of a cached entity record name. Returns the cached name if available, or undefined if not cached.
630
- * 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
+ *
631
680
  * @param entityName - The name of the entity
632
681
  * @param compositeKey - The primary key value(s) for the record
633
- * @param loadIfNeeded - If set to true, will load from database if not already cached
634
- * @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
635
684
  */
636
685
  async GetCachedRecordName(entityName, compositeKey, loadIfNeeded) {
637
- let cachedEntry = this._entityRecordNameCache.Get(this.getCacheKey(entityName, compositeKey));
638
- if (!cachedEntry && loadIfNeeded) {
639
- cachedEntry = await this.GetEntityRecordName(entityName, compositeKey);
640
- }
641
- return cachedEntry;
686
+ return loadIfNeeded ? this.GetEntityRecordName(entityName, compositeKey) : undefined;
642
687
  }
643
688
  /**
644
- * Checks whether an entity record name is currently available in the in-memory LRU cache.
645
- * @param entityName - The name of the entity
646
- * @param compositeKey - The primary key value(s) for the record
647
- * @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}.
648
691
  */
649
692
  HasCachedRecordName(entityName, compositeKey) {
650
- return this._entityRecordNameCache.Get(this.getCacheKey(entityName, compositeKey)) !== undefined;
693
+ return false;
651
694
  }
652
695
  /**
653
- * Retrieves an entity record name from the in-memory LRU cache if already cached.
654
- * Returns undefined immediately when not cached and will NEVER initiate a database lookup.
655
- * @param entityName - The name of the entity
656
- * @param compositeKey - The primary key value(s) for the record
657
- * @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}.
658
698
  */
659
699
  GetCachedRecordNameOnlyIfCached(entityName, compositeKey) {
660
- return this._entityRecordNameCache.Get(this.getCacheKey(entityName, compositeKey));
700
+ return undefined;
661
701
  }
662
702
  /**
663
- * Stores a record name in the cache for later synchronous retrieval via GetCachedRecordName().
664
- * Called automatically by BaseEntity after Load(), LoadFromData(), and Save() operations.
665
- * @param entityName - The name of the entity
666
- * @param compositeKey - The primary key value(s) for the record
667
- * @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}.
668
705
  */
669
706
  SetCachedRecordName(entityName, compositeKey, recordName) {
670
- this._entityRecordNameCache.Set(this.getCacheKey(entityName, compositeKey), recordName);
707
+ // Intentionally empty: this provider keeps no record names.
671
708
  }
672
709
  /**
673
- * Gets the display name for a single entity record with caching.
710
+ * Gets the display name for a single entity record.
674
711
  * Uses the entity's IsNameField or falls back to 'Name' field if available.
675
712
  * @param entityName - The name of the entity
676
713
  * @param compositeKey - The primary key value(s) for the record
677
- * @param contextUser - Optional user context for permissions
678
- * @param forceRefresh - If true, bypasses cache and queries database
679
- * @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
680
717
  */
681
718
  async GetEntityRecordName(entityName, compositeKey, contextUser, forceRefresh = false) {
682
- const cacheKey = this.getCacheKey(entityName, compositeKey);
683
- // Check cache unless forceRefresh
684
- if (!forceRefresh) {
685
- const cached = this._entityRecordNameCache.Get(cacheKey);
686
- if (cached !== undefined) {
687
- return cached;
688
- }
689
- }
690
- // Fetch from database via provider-specific implementation
691
- const name = await this.InternalGetEntityRecordName(entityName, compositeKey, contextUser);
692
- if (name) {
693
- this._entityRecordNameCache.Set(cacheKey, name);
694
- }
695
- return name;
719
+ return this.InternalGetEntityRecordName(entityName, compositeKey, contextUser);
696
720
  }
697
721
  /**
698
- * Gets display names for multiple entity records in a single operation with caching.
699
- * More efficient than multiple GetEntityRecordName calls.
722
+ * Gets display names for multiple entity records in a single operation.
700
723
  * @param info - Array of entity/key pairs to lookup
701
- * @param contextUser - Optional user context for permissions
702
- * @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.
703
726
  * @returns Array of results with names and status for each requested record
704
727
  */
705
728
  async GetEntityRecordNames(info, contextUser, forceRefresh = false) {
706
- if (!forceRefresh) {
707
- // Check cache for each item, collect uncached items
708
- const results = [];
709
- const uncachedInfo = [];
710
- const uncachedIndexes = [];
711
- for (let i = 0; i < info.length; i++) {
712
- const item = info[i];
713
- const cacheKey = this.getCacheKey(item.EntityName, item.CompositeKey);
714
- const cached = this._entityRecordNameCache.Get(cacheKey);
715
- if (cached !== undefined) {
716
- // Cache hit
717
- results[i] = {
718
- EntityName: item.EntityName,
719
- CompositeKey: item.CompositeKey,
720
- Status: 'cached',
721
- Success: true,
722
- RecordName: cached
723
- };
724
- }
725
- else {
726
- // Cache miss - need to fetch
727
- uncachedInfo.push(item);
728
- uncachedIndexes.push(i);
729
- }
730
- }
731
- // Fetch uncached items from database
732
- if (uncachedInfo.length > 0) {
733
- const uncachedResults = await this.InternalGetEntityRecordNames(uncachedInfo, contextUser);
734
- // Merge results and update cache
735
- for (let i = 0; i < uncachedResults.length; i++) {
736
- const result = uncachedResults[i];
737
- const originalIndex = uncachedIndexes[i];
738
- results[originalIndex] = result;
739
- // Cache successful results
740
- if (result.Success && result.RecordName) {
741
- const cacheKey = this.getCacheKey(result.EntityName, result.CompositeKey);
742
- this._entityRecordNameCache.Set(cacheKey, result.RecordName);
743
- }
744
- }
745
- }
746
- return results;
747
- }
748
- else {
749
- // Force refresh - bypass cache entirely
750
- const results = await this.InternalGetEntityRecordNames(info, contextUser);
751
- // Update cache with fresh results
752
- for (const result of results) {
753
- if (result.Success && result.RecordName) {
754
- const cacheKey = this.getCacheKey(result.EntityName, result.CompositeKey);
755
- this._entityRecordNameCache.Set(cacheKey, result.RecordName);
756
- }
757
- }
758
- return results;
759
- }
729
+ return this.InternalGetEntityRecordNames(info, contextUser);
760
730
  }
761
731
  // ========================================================================
762
732
  // PUBLIC API METHODS - Orchestrate Pre → Cache → Internal → Post flow
@@ -2205,8 +2175,44 @@ export class ProviderBase {
2205
2175
  * they asked for, and field security narrows per request at read time via
2206
2176
  * {@link ApplyFieldSecurityProjection}.
2207
2177
  */
2208
- ComputeRunViewFetchFields(entity) {
2209
- 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;
2210
2216
  }
2211
2217
  /**
2212
2218
  * Pre-processing hook for RunView.
@@ -2478,8 +2484,10 @@ export class ProviderBase {
2478
2484
  // field-restricted user RECEIVES is still their allowed set: the server strips denied
2479
2485
  // columns on the wire, and the missing keys mark those fields not-loaded.
2480
2486
  const widenForEntityObject = params.ResultType === 'entity_object';
2487
+ if (entity)
2488
+ this.ResolveIncludeBinaryFields(params, entity);
2481
2489
  if (entity && (willCache || widenForEntityObject)) {
2482
- params.Fields = this.ComputeRunViewFetchFields(entity);
2490
+ params.Fields = this.ComputeRunViewFetchFields(entity, params);
2483
2491
  // Platform contract: explicit Fields always include the primary key(s) —
2484
2492
  // project back down to requested ∪ PK, matching the direct SQL path.
2485
2493
  if (callerRequestedFields) {
@@ -2653,8 +2661,10 @@ export class ProviderBase {
2653
2661
  const batchWillCache = this.runViewCacheEligible(param);
2654
2662
  // Same entity_object-always-widens rule as the single-view path above.
2655
2663
  const batchWidenForEntityObject = param.ResultType === 'entity_object';
2664
+ if (batchEntity)
2665
+ this.ResolveIncludeBinaryFields(param, batchEntity);
2656
2666
  if (batchEntity && (batchWillCache || batchWidenForEntityObject)) {
2657
- param.Fields = this.ComputeRunViewFetchFields(batchEntity);
2667
+ param.Fields = this.ComputeRunViewFetchFields(batchEntity, param);
2658
2668
  // Platform contract: explicit Fields always include the primary key(s)
2659
2669
  if (callerFields) {
2660
2670
  callerFields = ProviderBase.UnionFieldsWithPrimaryKeys(callerFields, batchEntity);
@@ -2763,7 +2773,8 @@ export class ProviderBase {
2763
2773
  // provider FETCHES, not just how the slot is keyed, and I could not verify the
2764
2774
  // downstream fetch behavior tonight. The safe fix is to normalize a full-coverage
2765
2775
  // field list to `*` in the FINGERPRINT only, which cannot affect fetching.
2766
- param.Fields = entity.Fields.map(f => f.Name);
2776
+ this.ResolveIncludeBinaryFields(param, entity);
2777
+ param.Fields = this.ComputeRunViewFetchFields(entity, param);
2767
2778
  }
2768
2779
  // Gate on runViewCacheEligible (NOT raw param.CacheLocal): the smart-cache-check path is a
2769
2780
  // second, independent cache transport, and gating it on CacheLocal alone re-admits the exact
@@ -3590,7 +3601,11 @@ export class ProviderBase {
3590
3601
  const entity = this.EntityByName(params.EntityName);
3591
3602
  if (!entity)
3592
3603
  throw new Error(`Entity ${params.EntityName} not found in metadata`);
3593
- 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);
3594
3609
  }
3595
3610
  }
3596
3611
  /**
@@ -3654,7 +3669,9 @@ export class ProviderBase {
3654
3669
  if (!entity) {
3655
3670
  throw new Error(`Entity ${param.EntityName} not found in metadata`);
3656
3671
  }
3657
- 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);
3658
3675
  }
3659
3676
  }
3660
3677
  }
@@ -3879,6 +3896,9 @@ export class ProviderBase {
3879
3896
  */
3880
3897
  async Config(data, providerToUse) {
3881
3898
  this._ConfigData = data;
3899
+ // Describes THIS Config only: a flag left by an earlier Refresh() must not make the
3900
+ // pre-validation that follows a Config which loaded nothing skip its check.
3901
+ this._configLoadedFromServer = false;
3882
3902
  // Initialize LocalCacheManager early so dataset loading can use the cache.
3883
3903
  // Initialize() is idempotent — subsequent calls (e.g. from StartupManager) are no-ops.
3884
3904
  if (!LocalCacheManager.Instance.IsInitialized) {
@@ -3924,7 +3944,12 @@ export class ProviderBase {
3924
3944
  return true; // App can proceed immediately with cached metadata
3925
3945
  }
3926
3946
  }
3927
- if (hardRefresh || await this.CheckToSeeIfRefreshNeeded(providerToUse)) {
3947
+ // An empty graph has nothing to throttle: the throttle exists to stop redundant checks of a
3948
+ // snapshot we already hold. After a status check succeeded but the download that followed
3949
+ // failed, the throttle is armed and a non-bypassing retry would read "current" and load
3950
+ // nothing for the whole window (#4887).
3951
+ const graphIsEmpty = !this._localMetadata?.AllEntities?.length;
3952
+ if (hardRefresh || await this.CheckToSeeIfRefreshNeeded(providerToUse, graphIsEmpty)) {
3928
3953
  // either a hard refresh flag was set within Refresh(), or LocalMetadata is Obsolete
3929
3954
  // first, make sure we reset the flag to false so that if another call to this function happens
3930
3955
  // while we are waiting for the async call to finish, we dont do it again
@@ -3963,13 +3988,8 @@ export class ProviderBase {
3963
3988
  const end = new Date().getTime();
3964
3989
  LogStatusEx({ message: `GetAllMetadata() took ${end - start} ms`, verboseOnly: true });
3965
3990
  if (res) {
3966
- // Atomic swap via UpdateLocalMetadata: single property assignment is atomic in JavaScript
3967
- // Readers now see new metadata instead of old
3968
- // Uses UpdateLocalMetadata() to maintain consistency with LoadLocalMetadataFromStorage()
3969
- // and allow potential subclass overrides for extensibility
3970
- this.UpdateLocalMetadata(res);
3971
- this._latestLocalMetadataTimestamps = this._latestRemoteMetadataTimestamps; // update this since we just used server to get all the stuff
3972
- await this.SaveLocalMetadataToStorage();
3991
+ await this.adoptServerMetadata(res);
3992
+ this._configLoadedFromServer = true;
3973
3993
  }
3974
3994
  else {
3975
3995
  // GetAllMetadata failed - log error but keep existing metadata
@@ -3989,6 +4009,19 @@ export class ProviderBase {
3989
4009
  }
3990
4010
  return true;
3991
4011
  }
4012
+ /**
4013
+ * Adopts a metadata snapshot just loaded from the server: swaps it in, records that the
4014
+ * local copy now matches the server's timestamps, and persists it to local storage.
4015
+ *
4016
+ * The swap goes through UpdateLocalMetadata — a single property assignment, atomic in
4017
+ * JavaScript, so readers see the old snapshot until the new one is complete — which keeps
4018
+ * it consistent with LoadLocalMetadataFromStorage() and lets subclasses override it.
4019
+ */
4020
+ async adoptServerMetadata(res) {
4021
+ this.UpdateLocalMetadata(res);
4022
+ this._latestLocalMetadataTimestamps = this._latestRemoteMetadataTimestamps; // we just used the server to get all the stuff
4023
+ await this.SaveLocalMetadataToStorage();
4024
+ }
3992
4025
  /**
3993
4026
  * Background validation for the stale-while-revalidate fast-start pattern.
3994
4027
  * Checks if local metadata is still current; if stale, fetches fresh metadata
@@ -4004,9 +4037,7 @@ export class ProviderBase {
4004
4037
  const res = await this.GetAllMetadata(providerToUse, false);
4005
4038
  const elapsed = Date.now() - start;
4006
4039
  if (res) {
4007
- this.UpdateLocalMetadata(res);
4008
- this._latestLocalMetadataTimestamps = this._latestRemoteMetadataTimestamps;
4009
- await this.SaveLocalMetadataToStorage();
4040
+ await this.adoptServerMetadata(res);
4010
4041
  LogStatusEx({ message: `⚡ [Metadata Cache] Background refresh complete (${elapsed}ms) — metadata updated in place`, verboseOnly: false });
4011
4042
  }
4012
4043
  }
@@ -4037,9 +4068,22 @@ export class ProviderBase {
4037
4068
  * on RTT). On the warm-stale path we additionally pay the full metadata fetch but
4038
4069
  * avoid serving stale data to the UI in the first place.
4039
4070
  *
4071
+ * On a cold boot, where Config() just loaded the graph (and the current user) from the
4072
+ * server, the first call after that load returns immediately — no check, no current-user
4073
+ * re-fetch. Later calls, and calls after a background refresh, check as usual.
4074
+ *
4040
4075
  * Caller contract: invoke this before `StartupManager.Startup()`.
4041
4076
  */
4042
4077
  async preValidateAndRefresh(providerToUse) {
4078
+ // The snapshot Config just loaded from the server (a cold boot) is current by construction,
4079
+ // and GetAllMetadata fetched CurrentUser with it. Checking again would hit the refresh
4080
+ // throttle, read "current", and re-fetch the same user serially (#4887). One-shot: only the
4081
+ // first pre-validation after that load skips.
4082
+ if (this._configLoadedFromServer) {
4083
+ this._configLoadedFromServer = false;
4084
+ LogStatusEx({ message: `⚡ [Metadata Cache] Pre-validation: metadata was just loaded from the server — skipping`, verboseOnly: true });
4085
+ return;
4086
+ }
4043
4087
  try {
4044
4088
  const needsRefresh = await this.CheckToSeeIfRefreshNeeded(providerToUse);
4045
4089
  if (needsRefresh) {
@@ -4048,9 +4092,7 @@ export class ProviderBase {
4048
4092
  const res = await this.GetAllMetadata(providerToUse, false);
4049
4093
  const elapsed = Date.now() - start;
4050
4094
  if (res) {
4051
- this.UpdateLocalMetadata(res);
4052
- this._latestLocalMetadataTimestamps = this._latestRemoteMetadataTimestamps;
4053
- await this.SaveLocalMetadataToStorage();
4095
+ await this.adoptServerMetadata(res);
4054
4096
  LogStatusEx({ message: `⚡ [Metadata Cache] Pre-validation refresh complete (${elapsed}ms)`, verboseOnly: false });
4055
4097
  }
4056
4098
  }
@@ -4213,7 +4255,7 @@ export class ProviderBase {
4213
4255
  const entitiesItem = d?.Success ? d.Results?.find(r => r.Code === 'Entities') : undefined;
4214
4256
  const hasEntities = Array.isArray(entitiesItem?.Results) && entitiesItem.Results.length > 0;
4215
4257
  if (d && d.Success && !hasEntities) {
4216
- LogError(`GetAllMetadata() - the ${ProviderBase._mjMetadataDatasetName} dataset returned no entities; keeping the metadata already loaded`);
4258
+ this.recordMetadataLoadFailure(new Error(`GetAllMetadata() - the ${ProviderBase._mjMetadataDatasetName} dataset returned no entities; keeping the metadata already loaded`));
4217
4259
  }
4218
4260
  else if (d && d.Success) {
4219
4261
  // cache the dataset for anyone who wants to use it
@@ -4241,16 +4283,23 @@ export class ProviderBase {
4241
4283
  // build the base return type
4242
4284
  const returnMetadata = MetadataFromSimpleObjectWithoutUser(simpleMetadata, this);
4243
4285
  returnMetadata.CurrentUser = await this.GetCurrentUser();
4286
+ this._lastMetadataLoadError = null;
4244
4287
  return returnMetadata;
4245
4288
  }
4246
4289
  else {
4247
- LogError('GetAllMetadata() - Error getting metadata from server' + (d ? ': ' + d.Status : ''));
4290
+ this.recordMetadataLoadFailure(new Error('GetAllMetadata() - Error getting metadata from server' + (d ? ': ' + d.Status : '')));
4248
4291
  }
4249
4292
  }
4250
4293
  catch (e) {
4251
4294
  LogError(e);
4295
+ this._lastMetadataLoadError = e instanceof Error ? e : new Error(String(e));
4252
4296
  }
4253
4297
  }
4298
+ /** Logs a failed metadata download and keeps it for {@link LastMetadataLoadError}. */
4299
+ recordMetadataLoadFailure(error) {
4300
+ LogError(error.message);
4301
+ this._lastMetadataLoadError = error;
4302
+ }
4254
4303
  /**
4255
4304
  * Refreshes the CurrentUser from the server and updates local metadata in place.
4256
4305
  * Useful on warm boot or when user roles/permissions change dynamically without
@@ -4524,6 +4573,24 @@ export class ProviderBase {
4524
4573
  else
4525
4574
  return true; // subclass is telling us not to do any refresh ops right now
4526
4575
  }
4576
+ /**
4577
+ * The same hard reload as {@link Refresh}, but the metadata dataset's reads join the ambient
4578
+ * transaction for the duration of the call (MJ#4836).
4579
+ *
4580
+ * A timer-driven refresh must stay on the pool so it cannot land beside COMMIT (#4514).
4581
+ * The caller that owns the transaction — `mj sync push`, between its own writes — is the
4582
+ * one case that has to see its uncommitted metadata rows, and it waits for this call, so
4583
+ * the read cannot race the commit.
4584
+ */
4585
+ async RefreshWithinTransaction(providerToUse) {
4586
+ this._metadataReadsJoinTransaction = true;
4587
+ try {
4588
+ return await this.Refresh(providerToUse);
4589
+ }
4590
+ finally {
4591
+ this._metadataReadsJoinTransaction = false;
4592
+ }
4593
+ }
4527
4594
  /**
4528
4595
  * Checks if local metadata is out of date and needs refreshing.
4529
4596
  * Compares local timestamps with server timestamps.
@@ -4531,12 +4598,28 @@ export class ProviderBase {
4531
4598
  * throttle. Event-driven callers pass true: they hold positive evidence that a metadata
4532
4599
  * member entity was just written, and the throttle otherwise answers "fresh" for any check
4533
4600
  * arriving within the window of the previous one — which would silently drop the second of
4534
- * two permission changes made less than the window apart.
4601
+ * two permission changes made less than the window apart. For the same reason a bypassing
4602
+ * caller always runs its own check, even while another is in flight: that check may have
4603
+ * started before the write.
4604
+ *
4605
+ * The throttle is armed only when the status request succeeded (returned timestamps). A
4606
+ * check that throws or returns no timestamps leaves it unarmed, so a retried boot inside
4607
+ * the window really checks instead of reading "current" and loading nothing (#4887).
4608
+ * A non-bypassing caller arriving while a check is in flight is told no refresh is needed,
4609
+ * as the in-flight owner acts on the answer; that keeps N engines starting together down to
4610
+ * one request. It must not share the owner's answer: a shared `true` would send every caller
4611
+ * into its own full metadata reload (preValidateAndRefresh / backgroundValidateAndRefresh /
4612
+ * RefreshIfNeeded load outside Config's reload single-flight). For the same reason it gets
4613
+ * `false`, not the owner's rejection, when the owner's check throws.
4535
4614
  * @returns True if refresh is needed, false otherwise
4536
4615
  */
4537
4616
  async CheckToSeeIfRefreshNeeded(providerToUse, bypassMinCheckInterval) {
4538
4617
  if (!this.AllowRefresh)
4539
4618
  return false;
4619
+ if (!bypassMinCheckInterval && this._refreshCheckInFlight) {
4620
+ LogStatusEx({ message: `[RefreshCheck] Skipped — a check is already in flight; its caller acts on the answer`, verboseOnly: true });
4621
+ return false;
4622
+ }
4540
4623
  const now = Date.now();
4541
4624
  if (!bypassMinCheckInterval && (now - this._lastRefreshCheckAt) < ProviderBase.MinRefreshCheckIntervalMs) {
4542
4625
  LogStatusEx({
@@ -4545,8 +4628,25 @@ export class ProviderBase {
4545
4628
  });
4546
4629
  return false;
4547
4630
  }
4548
- this._lastRefreshCheckAt = now;
4549
- await this.RefreshRemoteMetadataTimestamps(providerToUse);
4631
+ const check = this.runRefreshCheck(providerToUse);
4632
+ this._refreshCheckInFlight = check;
4633
+ try {
4634
+ return await check;
4635
+ }
4636
+ finally {
4637
+ // A bypassing caller may have replaced the slot with its own newer check while this
4638
+ // one ran; only the check that still owns the slot clears it.
4639
+ if (this._refreshCheckInFlight === check) {
4640
+ this._refreshCheckInFlight = null;
4641
+ }
4642
+ }
4643
+ }
4644
+ /** One refresh check: fetch remote timestamps, arm the throttle only on success, compare. */
4645
+ async runRefreshCheck(providerToUse) {
4646
+ const gotTimestamps = await this.RefreshRemoteMetadataTimestamps(providerToUse);
4647
+ if (gotTimestamps) {
4648
+ this._lastRefreshCheckAt = Date.now();
4649
+ }
4550
4650
  await this.LoadLocalMetadataFromStorage();
4551
4651
  return this.LocalMetadataObsolete();
4552
4652
  }
@@ -5026,6 +5126,12 @@ export class ProviderBase {
5026
5126
  const ls = this.LocalStorageProvider;
5027
5127
  if (!ls)
5028
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
+ }
5029
5135
  const overallStart = Date.now();
5030
5136
  // The metadata snapshot uses three keys (timestamps, format, AllMetadata).
5031
5137
  // Pull them in a single batched read — saves two IDB transaction round-trips
@@ -5116,6 +5222,18 @@ export class ProviderBase {
5116
5222
  * Used for compressed metadata storage/retrieval.
5117
5223
  */
5118
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
+ }
5119
5237
  const binaryString = atob(base64);
5120
5238
  const bytes = new Uint8Array(binaryString.length);
5121
5239
  for (let i = 0; i < binaryString.length; i++) {
@@ -5128,6 +5246,11 @@ export class ProviderBase {
5128
5246
  * Used for compressed metadata storage/retrieval.
5129
5247
  */
5130
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
+ }
5131
5254
  const bytes = new Uint8Array(buffer);
5132
5255
  let binary = '';
5133
5256
  for (let i = 0; i < bytes.byteLength; i++) {
@@ -5143,6 +5266,42 @@ export class ProviderBase {
5143
5266
  get LocalStoragePrefix() {
5144
5267
  return "";
5145
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
+ }
5146
5305
  /**
5147
5306
  * Saves current metadata to local storage for caching.
5148
5307
  * Serializes both timestamps and full metadata collections.
@@ -5152,6 +5311,12 @@ export class ProviderBase {
5152
5311
  const ls = this.LocalStorageProvider;
5153
5312
  if (!ls)
5154
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
+ }
5155
5320
  const start = Date.now();
5156
5321
  // Serialize the AllMetadata object FIRST. If this throws (or the payload write below
5157
5322
  // fails), nothing else has been written yet, so the previously stored timestamps still