@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.
- package/dist/generic/InMemoryLocalStorageProvider.d.ts +5 -0
- package/dist/generic/InMemoryLocalStorageProvider.d.ts.map +1 -1
- package/dist/generic/InMemoryLocalStorageProvider.js +5 -0
- package/dist/generic/InMemoryLocalStorageProvider.js.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +192 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts +28 -0
- package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts +95 -0
- package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts.map +1 -1
- package/dist/generic/baseEngine.d.ts +21 -1
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +15 -1
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +292 -18
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +553 -105
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts +47 -7
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +106 -29
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityConfiguration.d.ts +21 -0
- package/dist/generic/entityConfiguration.d.ts.map +1 -1
- package/dist/generic/entityConfiguration.js +34 -0
- package/dist/generic/entityConfiguration.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +60 -2
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +81 -2
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/entityRecordNameCache.d.ts +48 -0
- package/dist/generic/entityRecordNameCache.d.ts.map +1 -0
- package/dist/generic/entityRecordNameCache.js +92 -0
- package/dist/generic/entityRecordNameCache.js.map +1 -0
- package/dist/generic/interfaces.d.ts +45 -9
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/jsonFieldBinding.d.ts +196 -0
- package/dist/generic/jsonFieldBinding.d.ts.map +1 -0
- package/dist/generic/jsonFieldBinding.js +508 -0
- package/dist/generic/jsonFieldBinding.js.map +1 -0
- package/dist/generic/localCacheManager.d.ts +11 -0
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +9 -0
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/providerBase.d.ts +150 -33
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +304 -139
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/runQuery.d.ts +18 -1
- package/dist/generic/runQuery.d.ts.map +1 -1
- package/dist/generic/runQuery.js.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.d.ts +7 -1
- package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.js +65 -140
- package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
- package/dist/generic/telemetryManager.d.ts +11 -0
- package/dist/generic/telemetryManager.d.ts.map +1 -1
- package/dist/generic/telemetryManager.js +40 -0
- package/dist/generic/telemetryManager.js.map +1 -1
- package/dist/generic/util.d.ts +6 -0
- package/dist/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +20 -0
- package/dist/generic/util.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +24 -0
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +4 -0
- package/dist/views/runView.js.map +1 -1
- package/package.json +3 -3
- 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,
|
|
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
|
-
*
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
*
|
|
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 -
|
|
634
|
-
* @returns The
|
|
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
|
-
|
|
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
|
-
*
|
|
645
|
-
* @
|
|
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
|
|
693
|
+
return false;
|
|
651
694
|
}
|
|
652
695
|
/**
|
|
653
|
-
*
|
|
654
|
-
*
|
|
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
|
|
700
|
+
return undefined;
|
|
661
701
|
}
|
|
662
702
|
/**
|
|
663
|
-
*
|
|
664
|
-
*
|
|
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
|
|
707
|
+
// Intentionally empty: this provider keeps no record names.
|
|
671
708
|
}
|
|
672
709
|
/**
|
|
673
|
-
* Gets the display name for a single entity record
|
|
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 -
|
|
678
|
-
* @param forceRefresh -
|
|
679
|
-
* @returns The display name of the record or
|
|
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
|
-
|
|
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
|
|
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 -
|
|
702
|
-
* @param forceRefresh -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3967
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4549
|
-
|
|
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
|