@memberjunction/core 6.1.0-edge.5 → 6.1.0-edge.7
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/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts +27 -0
- package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts.map +1 -0
- package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.js +6 -0
- package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.js.map +1 -0
- package/dist/generic/baseEngine.d.ts +52 -1
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +95 -7
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +292 -0
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +878 -98
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/baseInfo.d.ts +6 -0
- package/dist/generic/baseInfo.d.ts.map +1 -1
- package/dist/generic/baseInfo.js +19 -1
- package/dist/generic/baseInfo.js.map +1 -1
- package/dist/generic/compositeKey.d.ts +108 -0
- package/dist/generic/compositeKey.d.ts.map +1 -1
- package/dist/generic/compositeKey.js +225 -10
- package/dist/generic/compositeKey.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts +49 -0
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +90 -11
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/embeddedRecord.d.ts +2 -1
- package/dist/generic/embeddedRecord.d.ts.map +1 -1
- package/dist/generic/embeddedRecord.js +9 -2
- package/dist/generic/embeddedRecord.js.map +1 -1
- package/dist/generic/entityCompanion.d.ts +6 -1
- package/dist/generic/entityCompanion.d.ts.map +1 -1
- package/dist/generic/entityCompanion.js +7 -0
- package/dist/generic/entityCompanion.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +550 -8
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +695 -20
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/entitySubtypeResolver.d.ts +19 -0
- package/dist/generic/entitySubtypeResolver.d.ts.map +1 -0
- package/dist/generic/entitySubtypeResolver.js +24 -0
- package/dist/generic/entitySubtypeResolver.js.map +1 -0
- package/dist/generic/entityTransactionScope.d.ts +6 -3
- package/dist/generic/entityTransactionScope.d.ts.map +1 -1
- package/dist/generic/entityTransactionScope.js +6 -3
- package/dist/generic/entityTransactionScope.js.map +1 -1
- package/dist/generic/extendedTypeValue.d.ts +47 -0
- package/dist/generic/extendedTypeValue.d.ts.map +1 -0
- package/dist/generic/extendedTypeValue.js +152 -0
- package/dist/generic/extendedTypeValue.js.map +1 -0
- package/dist/generic/filters/compositeFilter.d.ts +77 -0
- package/dist/generic/filters/compositeFilter.d.ts.map +1 -0
- package/dist/generic/filters/compositeFilter.js +324 -0
- package/dist/generic/filters/compositeFilter.js.map +1 -0
- package/dist/generic/filters/filter.types.d.ts +29 -0
- package/dist/generic/filters/filter.types.d.ts.map +1 -0
- package/dist/generic/filters/filter.types.js +28 -0
- package/dist/generic/filters/filter.types.js.map +1 -0
- package/dist/generic/filters/index.d.ts +3 -0
- package/dist/generic/filters/index.d.ts.map +1 -0
- package/dist/generic/filters/index.js +3 -0
- package/dist/generic/filters/index.js.map +1 -0
- package/dist/generic/geoFields.d.ts +64 -0
- package/dist/generic/geoFields.d.ts.map +1 -0
- package/dist/generic/geoFields.js +108 -0
- package/dist/generic/geoFields.js.map +1 -0
- package/dist/generic/interfaces.d.ts +16 -2
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +34 -3
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +53 -4
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/providerBase.d.ts +282 -5
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +754 -93
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/recordChangeFieldSecurity.d.ts +164 -0
- package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -0
- package/dist/generic/recordChangeFieldSecurity.js +279 -0
- package/dist/generic/recordChangeFieldSecurity.js.map +1 -0
- package/dist/generic/relatedRecordBatchLoader.js +2 -2
- package/dist/generic/relatedRecordBatchLoader.js.map +1 -1
- package/dist/generic/relatedRecordCollection.d.ts +2 -1
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
- package/dist/generic/relatedRecordCollection.js +16 -4
- package/dist/generic/relatedRecordCollection.js.map +1 -1
- package/dist/generic/saveEntityGraphOperation.d.ts +10 -0
- package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
- package/dist/generic/saveEntityGraphOperation.js +2 -1
- package/dist/generic/saveEntityGraphOperation.js.map +1 -1
- package/dist/generic/wellKnownUserSource.d.ts +70 -0
- package/dist/generic/wellKnownUserSource.d.ts.map +1 -0
- package/dist/generic/wellKnownUserSource.js +82 -0
- package/dist/generic/wellKnownUserSource.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { BaseEntity } from "./baseEntity.js";
|
|
2
|
-
import { EntityDocumentTypeInfo, EntityFieldTSType, EntityInfo, EntityPermissionType } from "./entityInfo.js";
|
|
2
|
+
import { EntityDocumentTypeInfo, EntityFieldTSType, EntityInfo, EntityPermissionType, FieldSecurityDenialMessage, FieldSecurityError } from "./entityInfo.js";
|
|
3
3
|
import { AllMetadata } from "./interfaces.js";
|
|
4
|
+
import { RecordChangeFieldSecurityProjector } from "./recordChangeFieldSecurity.js";
|
|
4
5
|
import { ComputeRRF } from "./scoring/ReciprocalRankFusion.js";
|
|
5
6
|
import { LocalCacheManager } from "./localCacheManager.js";
|
|
6
7
|
import { ApplicationInfo } from "../generic/applicationInfo.js";
|
|
7
8
|
import { AuditLogTypeInfo, AuthorizationInfo, AuthorizationRoleInfo, RoleInfo, RowLevelSecurityFilterInfo, UserInfo } from "./securityInfo.js";
|
|
8
|
-
import { MJGlobal, MJEventType, NormalizeUUID, UUIDsEqual, MJLruCache } from "@memberjunction/global";
|
|
9
|
+
import { MJGlobal, MJEventType, NormalizeUUID, UUIDsEqual, MJLruCache, EscapeSQLString, ordinalCompare } from "@memberjunction/global";
|
|
10
|
+
import { FindReferencedIdentifiers } from "@memberjunction/sql-dialect";
|
|
9
11
|
import { TelemetryManager } from "./telemetryManager.js";
|
|
10
|
-
import { LogError, LogStatus, LogStatusEx } from "./logging.js";
|
|
12
|
+
import { LogDebug, LogError, LogStatus, LogStatusEx } from "./logging.js";
|
|
11
13
|
import { QueryCategoryInfo, QueryFieldInfo, QueryInfo, QueryPermissionInfo, QueryEntityInfo, QueryParameterInfo, QueryDependencyInfo, SQLDialectInfo, QuerySQLInfo } from "./queryInfo.js";
|
|
12
14
|
import { LibraryInfo } from "./libraryInfo.js";
|
|
13
15
|
import { CompositeKey } from "./compositeKey.js";
|
|
@@ -189,6 +191,10 @@ export class ProviderBase {
|
|
|
189
191
|
// it can't be reset on metadata refresh; it needs its own eviction policy.
|
|
190
192
|
this._entityRecordNameCache = new MJLruCache({ maxSize: 10000, ttlMs: 60 * 60 * 1000 });
|
|
191
193
|
this._refresh = false;
|
|
194
|
+
/** Single-flight guard for the full metadata reload in {@link Config} — see the comment there. */
|
|
195
|
+
this._metadataReloadInFlight = null;
|
|
196
|
+
/** Set when a refresh request arrives while a reload is in flight; the reload loop reruns once. */
|
|
197
|
+
this._metadataReloadQueued = false;
|
|
192
198
|
this._lastRefreshCheckAt = 0;
|
|
193
199
|
/**
|
|
194
200
|
* Pending coalesced requests waiting to be flushed.
|
|
@@ -213,6 +219,16 @@ export class ProviderBase {
|
|
|
213
219
|
*/
|
|
214
220
|
this._inflightViews = new Map();
|
|
215
221
|
this._lingerInvalidationWired = false;
|
|
222
|
+
/**
|
|
223
|
+
* Lowercased names of the entities whose rows COMPOSE this provider's metadata — the
|
|
224
|
+
* MJ_Metadata dataset's item entities, recorded each time the dataset is loaded (and restored
|
|
225
|
+
* from local storage on warm boot). Membership is DATA: adding a `DatasetItem` row to
|
|
226
|
+
* MJ_Metadata extends refresh coverage with no code change, which is why no entity names are
|
|
227
|
+
* hardcoded anywhere in this mechanism.
|
|
228
|
+
*/
|
|
229
|
+
this._metadataDatasetEntityNames = null;
|
|
230
|
+
/** Debounce timer for {@link scheduleMetadataMemberRefresh}. */
|
|
231
|
+
this._metadataMemberRefreshTimer = null;
|
|
216
232
|
this._clientFingerprintMemo = new WeakMap();
|
|
217
233
|
this._cachedVisibleExplorerNavigationItems = null;
|
|
218
234
|
}
|
|
@@ -355,6 +371,7 @@ export class ProviderBase {
|
|
|
355
371
|
continue;
|
|
356
372
|
}
|
|
357
373
|
instance.invalidateInflightViewsForEntity(entityName);
|
|
374
|
+
instance.handleMetadataMemberEntityEvent(entityName, entityEvent);
|
|
358
375
|
}
|
|
359
376
|
});
|
|
360
377
|
}
|
|
@@ -405,6 +422,155 @@ export class ProviderBase {
|
|
|
405
422
|
}
|
|
406
423
|
return names;
|
|
407
424
|
}
|
|
425
|
+
// ── Metadata-Dataset Membership Refresh ────────────────────────────
|
|
426
|
+
/**
|
|
427
|
+
* Coalescing window, in milliseconds, for metadata refreshes triggered by writes to the
|
|
428
|
+
* entities that compose this provider's metadata. A single administrative action produces a
|
|
429
|
+
* burst (enabling field security writes one permission row per field/role pair, each raising
|
|
430
|
+
* its own event) — one refresh per burst, not one per row. The window also gives an enclosing
|
|
431
|
+
* entity transaction time to COMMIT before the re-read: every event in the burst re-arms the
|
|
432
|
+
* timer, so the refresh runs no earlier than this long after the LAST write.
|
|
433
|
+
*/
|
|
434
|
+
static { this.MetadataDatasetRefreshDebounceMs = 500; }
|
|
435
|
+
/**
|
|
436
|
+
* Records which entities compose this provider's metadata, from the loaded MJ_Metadata
|
|
437
|
+
* dataset result, and registers this instance with the static event fan-out so writes to any
|
|
438
|
+
* of them schedule a debounced metadata refresh. Called from {@link GetAllMetadata} on every
|
|
439
|
+
* successful load, so the set tracks the dataset definition as it changes.
|
|
440
|
+
*/
|
|
441
|
+
registerMetadataDatasetMembership(dataset) {
|
|
442
|
+
const names = new Set();
|
|
443
|
+
for (const item of dataset.Results ?? []) {
|
|
444
|
+
const n = item.EntityName?.trim().toLowerCase();
|
|
445
|
+
if (n) {
|
|
446
|
+
names.add(n);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (names.size === 0) {
|
|
450
|
+
return; // a failed/empty load must not erase a previously recorded set
|
|
451
|
+
}
|
|
452
|
+
this._metadataDatasetEntityNames = names;
|
|
453
|
+
this.ensureInflightViewInvalidation();
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Static fan-out callback: a BaseEntity save/delete (or a remote-invalidate from another
|
|
457
|
+
* server) touched `lowerEntityName`. If that entity is one of the entities this provider's
|
|
458
|
+
* metadata is BUILT FROM, the metadata this provider is serving — and, on the server, the
|
|
459
|
+
* metadata every per-request provider adopts from it — is now stale, so schedule a debounced
|
|
460
|
+
* refresh. Permission metadata is the load-bearing case: field-level security is enforced
|
|
461
|
+
* FROM metadata at every enforcement point, so a rule an administrator just tightened is
|
|
462
|
+
* simply not enforced until this re-read happens.
|
|
463
|
+
*/
|
|
464
|
+
handleMetadataMemberEntityEvent(lowerEntityName, entityEvent) {
|
|
465
|
+
if (!this._metadataDatasetEntityNames?.has(lowerEntityName)) {
|
|
466
|
+
return; // the overwhelming majority of writes
|
|
467
|
+
}
|
|
468
|
+
if (!this.eventTargetsThisProviderBackend(entityEvent)) {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
this.scheduleMetadataMemberRefresh();
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Whether the write described by `entityEvent` happened against the backend THIS provider's
|
|
475
|
+
* metadata comes from. In a multi-provider process (a client connected to several MJ servers,
|
|
476
|
+
* a server connected to several databases) a write on one backend must not refresh another's
|
|
477
|
+
* metadata. Deliberately fails OPEN — when the event does not identify its provider, or a
|
|
478
|
+
* connection string is unavailable, the answer is "yes": a spurious refresh is a bounded
|
|
479
|
+
* cost, a suppressed one is a stale-permissions window.
|
|
480
|
+
*/
|
|
481
|
+
eventTargetsThisProviderBackend(entityEvent) {
|
|
482
|
+
try {
|
|
483
|
+
if (entityEvent.type === 'remote-invalidate') {
|
|
484
|
+
// The transport that received the message attaches itself; other providers in the
|
|
485
|
+
// same process are connected to other servers.
|
|
486
|
+
return !entityEvent.provider || entityEvent.provider === this;
|
|
487
|
+
}
|
|
488
|
+
const self = this;
|
|
489
|
+
const saveProvider = entityEvent.baseEntity?.ProviderToUse;
|
|
490
|
+
if (saveProvider instanceof ProviderBase && saveProvider !== self) {
|
|
491
|
+
const theirs = saveProvider.InstanceConnectionString;
|
|
492
|
+
const mine = this.InstanceConnectionString;
|
|
493
|
+
if (theirs && mine && theirs !== mine) {
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return true;
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
return true; // a connection-string getter with no live pool must not veto the refresh
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* How long a member-entity write waits before this provider's refresh runs. The base value
|
|
505
|
+
* is the short debounce window — right for the server, where the writer is the refresher and
|
|
506
|
+
* the delay only exists to coalesce a burst and let the enclosing transaction commit.
|
|
507
|
+
* Transport providers override this with a much longer, RANDOMIZED window: every browser
|
|
508
|
+
* receives every write broadcast, so the delay is what turns "N clients each re-pull the
|
|
509
|
+
* metadata graph within the same half-second of any member write" into "each client pays at
|
|
510
|
+
* most one staleness check per window, at a moment no other client shares".
|
|
511
|
+
*/
|
|
512
|
+
get MetadataMemberRefreshDelayMs() {
|
|
513
|
+
return ProviderBase.MetadataDatasetRefreshDebounceMs;
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Whether a member-entity write arriving while the refresh timer is already armed RESTARTS
|
|
517
|
+
* the timer (debounce) or joins the pending window (coalesce/throttle).
|
|
518
|
+
*
|
|
519
|
+
* The base is a true debounce (`true`): the server's refresh must run AFTER the last write
|
|
520
|
+
* of the unit of work, so every event pushes the timer out — a burst costs one refresh, run
|
|
521
|
+
* once the burst ends. Transport providers return `false`: with a long window, re-arming
|
|
522
|
+
* would let steady org-wide write activity postpone the refresh indefinitely (starvation);
|
|
523
|
+
* joining the armed window guarantees at most one refresh per window regardless of write
|
|
524
|
+
* rate, which is the whole point of the window.
|
|
525
|
+
*/
|
|
526
|
+
get MetadataMemberRefreshRearmsOnNewEvents() {
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Schedules this provider's metadata refresh after a write to a metadata member entity.
|
|
531
|
+
* Delay and re-arm semantics come from {@link MetadataMemberRefreshDelayMs} and
|
|
532
|
+
* {@link MetadataMemberRefreshRearmsOnNewEvents} — debounce on the server, long jittered
|
|
533
|
+
* coalescing window on clients. The refresh targets THIS instance — the provider that loaded
|
|
534
|
+
* the dataset owns the metadata built from it; short-lived per-request providers never load
|
|
535
|
+
* the dataset (they adopt the global's metadata as a shared shell), so on the server only
|
|
536
|
+
* the process-global provider ever gets here.
|
|
537
|
+
*/
|
|
538
|
+
scheduleMetadataMemberRefresh() {
|
|
539
|
+
if (this._metadataMemberRefreshTimer) {
|
|
540
|
+
if (!this.MetadataMemberRefreshRearmsOnNewEvents) {
|
|
541
|
+
return; // coalesce: this write joins the already-armed window
|
|
542
|
+
}
|
|
543
|
+
clearTimeout(this._metadataMemberRefreshTimer);
|
|
544
|
+
}
|
|
545
|
+
this._metadataMemberRefreshTimer = setTimeout(() => {
|
|
546
|
+
this._metadataMemberRefreshTimer = null;
|
|
547
|
+
this.RefreshAfterMetadataMemberChange().catch((e) => {
|
|
548
|
+
LogError(`Metadata refresh after a member-entity change failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
549
|
+
});
|
|
550
|
+
}, this.MetadataMemberRefreshDelayMs);
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* How this provider refreshes after a metadata member entity changed. The base behavior is a
|
|
554
|
+
* hard {@link Refresh} — correct for database providers, where the process that PERFORMED the
|
|
555
|
+
* write is the one refreshing, so re-checking staleness first is wasted work and the re-read
|
|
556
|
+
* must bypass every cache layer. Transport providers (GraphQL) override this with a staleness
|
|
557
|
+
* check so a browser doesn't re-pull the full metadata graph for a change the server-side
|
|
558
|
+
* timestamp comparison can disconfirm.
|
|
559
|
+
*/
|
|
560
|
+
async RefreshAfterMetadataMemberChange() {
|
|
561
|
+
return this.Refresh();
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Cancels any pending debounced metadata refresh. Call during teardown (logout, provider
|
|
565
|
+
* disposal) so a timer armed just before teardown doesn't fire a refresh against a
|
|
566
|
+
* connection that no longer has a valid session.
|
|
567
|
+
*/
|
|
568
|
+
CancelPendingMetadataMemberRefresh() {
|
|
569
|
+
if (this._metadataMemberRefreshTimer) {
|
|
570
|
+
clearTimeout(this._metadataMemberRefreshTimer);
|
|
571
|
+
this._metadataMemberRefreshTimer = null;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
408
574
|
/******** ABSTRACT SECTION ****************************************************************** */
|
|
409
575
|
/**
|
|
410
576
|
* When true, cached RunView/RunQuery results are returned immediately on a
|
|
@@ -932,7 +1098,10 @@ export class ProviderBase {
|
|
|
932
1098
|
const record = result.Results[j];
|
|
933
1099
|
allResults.push({
|
|
934
1100
|
EntityName: entity.Name,
|
|
935
|
-
|
|
1101
|
+
// Compact CompositeKey segment: the bare value for a single-column key (any
|
|
1102
|
+
// column name), "F1|v1||F2|v2" for a composite key — what
|
|
1103
|
+
// CompositeKey.FromURLSegment(entity, RecordID) reads back.
|
|
1104
|
+
RecordID: CompositeKey.FromEntityRecord(entity, record).ToCompactURLSegment(),
|
|
936
1105
|
Title: String(record[titleField] ?? 'Untitled'),
|
|
937
1106
|
Snippet: String(record[snippetField] ?? '').substring(0, 200),
|
|
938
1107
|
Score: 1.0 / (j + 1) // Rank-based scoring for RRF compatibility
|
|
@@ -987,7 +1156,7 @@ export class ProviderBase {
|
|
|
987
1156
|
}
|
|
988
1157
|
// Fallback to first text field
|
|
989
1158
|
const textField = entity.Fields.find(f => f.Type.toLowerCase().includes('varchar') || f.Type.toLowerCase().includes('text'));
|
|
990
|
-
return textField?.Name ?? entity.FirstPrimaryKey
|
|
1159
|
+
return textField?.Name ?? entity.FirstPrimaryKey.Name; // first-pk-ok: display-column fallback for FTS results, not a key construction
|
|
991
1160
|
}
|
|
992
1161
|
async RunViewsUncoalesced(params, contextUser) {
|
|
993
1162
|
const key = this.GenerateDedupKey(params, contextUser);
|
|
@@ -1077,6 +1246,8 @@ export class ProviderBase {
|
|
|
1077
1246
|
param.ResultType !== 'count_only' &&
|
|
1078
1247
|
(param.CacheLocal === true || this.TrustLocalCacheCompletely) &&
|
|
1079
1248
|
this.IsServerCacheAllowedForEntity(param);
|
|
1249
|
+
// Deliberately not user-dependent: server slots are full-width and shared, so field
|
|
1250
|
+
// security never changes whether a query is cacheable, only what is projected out of it.
|
|
1080
1251
|
}
|
|
1081
1252
|
/** True when the entity is a CodeGen materialized-query wrapper (materialized_vw*) whose snapshot is
|
|
1082
1253
|
* refreshed out-of-band — the same one IsServerCacheAllowedForEntity excludes from the server cache. */
|
|
@@ -1221,7 +1392,13 @@ export class ProviderBase {
|
|
|
1221
1392
|
if (memoized) {
|
|
1222
1393
|
return memoized;
|
|
1223
1394
|
}
|
|
1224
|
-
|
|
1395
|
+
// The fls: segment participates in CLIENT slot identity too, keyed on the ALLOWED list
|
|
1396
|
+
// (see ComputeClientFLSAllowedKey). Without it, a user whose field access is TIGHTENED
|
|
1397
|
+
// keeps being served their persisted IndexedDB slot: the currency check compares only
|
|
1398
|
+
// maxUpdatedAt and rowCount, neither of which notices a column, so the server answers
|
|
1399
|
+
// "current" and the browser shows a column that was just taken away — until the rows
|
|
1400
|
+
// change. Empty for unrestricted users, whose fingerprints are unchanged.
|
|
1401
|
+
const base = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString, undefined, this.ComputeClientFLSAllowedKey(param));
|
|
1225
1402
|
// Normalize a FULL-COVERAGE field list to '*' — in the FINGERPRINT only (B44).
|
|
1226
1403
|
//
|
|
1227
1404
|
// entity_object params get widened to an explicit list of every entity field
|
|
@@ -1456,7 +1633,9 @@ export class ProviderBase {
|
|
|
1456
1633
|
const nameField = entity.NameField?.Name ?? entity.Fields.find(f => f.IsNameField)?.Name ?? null;
|
|
1457
1634
|
const out = [];
|
|
1458
1635
|
for (const row of (r.Results ?? [])) {
|
|
1459
|
-
|
|
1636
|
+
// The entity is arbitrary: read the key off its primary-key metadata, not a hardcoded
|
|
1637
|
+
// `ID` column. Compact segment so single-column keys stay the raw value.
|
|
1638
|
+
const id = CompositeKey.FromEntityRecord(entity, row).ToCompactURLSegment();
|
|
1460
1639
|
if (!id)
|
|
1461
1640
|
continue;
|
|
1462
1641
|
const nameVal = nameField ? String(row[nameField] ?? '').toLowerCase() : '';
|
|
@@ -1495,17 +1674,23 @@ export class ProviderBase {
|
|
|
1495
1674
|
async searchEntitiesFilterByPermission(entity, ids, contextUser) {
|
|
1496
1675
|
if (ids.length === 0)
|
|
1497
1676
|
return new Set();
|
|
1498
|
-
|
|
1677
|
+
// `ids` are compact CompositeKey segments (see searchEntitiesLexicalPass): a single-column
|
|
1678
|
+
// key — whatever the column is called — uses one IN(); a composite key needs one
|
|
1679
|
+
// (F1=.. AND F2=..) term per record. Hardcoding `ID` here returned nothing for every entity
|
|
1680
|
+
// whose key isn't named ID, so SearchEntity silently produced zero results for them.
|
|
1681
|
+
const filter = entity.PrimaryKeys.length === 1
|
|
1682
|
+
? `${entity.FirstPrimaryKey.Name} IN (${ids.map(id => `'${EscapeSQLString(id)}'`).join(',')})` // first-pk-ok: guarded by PrimaryKeys.length === 1
|
|
1683
|
+
: ids.map(id => `(${CompositeKey.FromURLSegment(entity, id).ToWhereClause()})`).join(' OR ');
|
|
1499
1684
|
const r = await this.RunView({
|
|
1500
1685
|
EntityName: entity.Name,
|
|
1501
|
-
ExtraFilter:
|
|
1502
|
-
Fields:
|
|
1686
|
+
ExtraFilter: filter,
|
|
1687
|
+
Fields: entity.PrimaryKeys.map(pk => pk.Name),
|
|
1503
1688
|
ResultType: 'simple',
|
|
1504
1689
|
MaxRows: ids.length,
|
|
1505
1690
|
}, contextUser);
|
|
1506
1691
|
if (!r.Success)
|
|
1507
1692
|
return new Set();
|
|
1508
|
-
return new Set((r.Results ?? []).map(row => row.
|
|
1693
|
+
return new Set((r.Results ?? []).map(row => CompositeKey.FromEntityRecord(entity, row).ToCompactURLSegment()));
|
|
1509
1694
|
}
|
|
1510
1695
|
/**
|
|
1511
1696
|
* Returns true if any param in the batch has SaveViewResults set,
|
|
@@ -1909,6 +2094,70 @@ export class ProviderBase {
|
|
|
1909
2094
|
return '';
|
|
1910
2095
|
return entity.GetEffectiveRowFilterWhereClause(user, EntityPermissionType.Read, '');
|
|
1911
2096
|
}
|
|
2097
|
+
/**
|
|
2098
|
+
* The CLIENT's field-security cache key: a canonical list of the fields this user MAY read.
|
|
2099
|
+
*
|
|
2100
|
+
* Keyed on the ALLOWED set rather than the denied set for two reasons. Once metadata ships
|
|
2101
|
+
* to browsers filtered to what a user may see ([#3485](https://github.com/MemberJunction/MJ/issues/3485)),
|
|
2102
|
+
* a denied field will not appear in the client's field list at all — so a denied-set key
|
|
2103
|
+
* would be empty and would silently stop segmenting. The allowed list also resolves the
|
|
2104
|
+
* `f:*` ambiguity in the client's projection segment, where "full width" means different
|
|
2105
|
+
* columns for different users.
|
|
2106
|
+
*
|
|
2107
|
+
* Returns '' when the entity has field security off or the user is denied nothing, so
|
|
2108
|
+
* unrestricted users keep byte-identical fingerprints and shared slots.
|
|
2109
|
+
*
|
|
2110
|
+
* Only takes effect once the client's metadata refreshes. A client on stale metadata
|
|
2111
|
+
* computes a stale key; the backstop is that the server strips denied columns from every
|
|
2112
|
+
* fresh fetch regardless.
|
|
2113
|
+
*/
|
|
2114
|
+
ComputeClientFLSAllowedKey(params) {
|
|
2115
|
+
const user = this.CurrentUser;
|
|
2116
|
+
if (!user || !params.EntityName)
|
|
2117
|
+
return '';
|
|
2118
|
+
const entity = this.EntityByName(params.EntityName);
|
|
2119
|
+
if (!entity?.EnableFieldLevelSecurity)
|
|
2120
|
+
return '';
|
|
2121
|
+
const denied = entity.GetDeniedReadFields(user);
|
|
2122
|
+
if (denied.size === 0)
|
|
2123
|
+
return '';
|
|
2124
|
+
return entity.Fields
|
|
2125
|
+
.map(f => f.Name.trim().toLowerCase())
|
|
2126
|
+
.filter(n => !denied.has(n))
|
|
2127
|
+
.sort()
|
|
2128
|
+
.join(',');
|
|
2129
|
+
}
|
|
2130
|
+
/**
|
|
2131
|
+
* The field-security segment for a {@link LocalCacheManager} RunView fingerprint — the one
|
|
2132
|
+
* place the client/server asymmetry is decided, so the two tiers cannot drift.
|
|
2133
|
+
*
|
|
2134
|
+
* **SERVER → no segment.** Its slots are full-width and shared by every user; per-request
|
|
2135
|
+
* narrowing happens at read time in {@link ApplyFieldSecurityProjection}, which runs on
|
|
2136
|
+
* every hit and every miss. A segment here would fragment one shared slot into one per
|
|
2137
|
+
* permission class and protect nothing the projection does not already handle.
|
|
2138
|
+
*
|
|
2139
|
+
* **CLIENT → the allowed-list key.** Its slots are stored exactly as the server returned
|
|
2140
|
+
* them (already narrowed on the wire) and are not projected on read, so slot identity has to
|
|
2141
|
+
* carry the field set. Without it, a user whose access is tightened keeps being served their
|
|
2142
|
+
* persisted IndexedDB slot: the currency check compares `maxUpdatedAt` and `rowCount` only,
|
|
2143
|
+
* neither of which notices a column, so the server answers "current" and the browser keeps
|
|
2144
|
+
* showing a column that was just taken away.
|
|
2145
|
+
*
|
|
2146
|
+
* Empty/undefined for unrestricted users on both tiers, so their fingerprints stay
|
|
2147
|
+
* byte-identical and keep sharing slots.
|
|
2148
|
+
*/
|
|
2149
|
+
ComputeRunViewFLSFingerprintKey(params) {
|
|
2150
|
+
return this.TrustLocalCacheCompletely ? undefined : this.ComputeClientFLSAllowedKey(params);
|
|
2151
|
+
}
|
|
2152
|
+
/**
|
|
2153
|
+
* The fetch-widening field list for a cache-eligible request: always every entity field.
|
|
2154
|
+
* One slot per (entity, filter, order) serves every caller regardless of the field subset
|
|
2155
|
+
* they asked for, and field security narrows per request at read time via
|
|
2156
|
+
* {@link ApplyFieldSecurityProjection}.
|
|
2157
|
+
*/
|
|
2158
|
+
ComputeRunViewFetchFields(entity) {
|
|
2159
|
+
return entity.Fields.map(f => f.Name);
|
|
2160
|
+
}
|
|
1912
2161
|
/**
|
|
1913
2162
|
* Pre-processing hook for RunView.
|
|
1914
2163
|
* Handles telemetry, validation, entity status check, and cache lookup.
|
|
@@ -1916,6 +2165,210 @@ export class ProviderBase {
|
|
|
1916
2165
|
* @param contextUser - Optional user context
|
|
1917
2166
|
* @returns Pre-processing result with cache status and optional cached result
|
|
1918
2167
|
*/
|
|
2168
|
+
// ========================================================================
|
|
2169
|
+
// FIELD-LEVEL SECURITY
|
|
2170
|
+
// ========================================================================
|
|
2171
|
+
/**
|
|
2172
|
+
* Rejects a RunView whose `ExtraFilter`, `OrderBy`, or `Aggregates` expressions reference a
|
|
2173
|
+
* field the user cannot read.
|
|
2174
|
+
*
|
|
2175
|
+
* Output projection alone is security theater. A user denied `Salary` can send
|
|
2176
|
+
* `ExtraFilter: "Salary > 200000"` or `OrderBy: "Salary DESC"` and reconstruct the values
|
|
2177
|
+
* from which rows come back and in what order — the column never appears in a result, so
|
|
2178
|
+
* every output-stripping point reports "secure." Aggregates are the same channel in a purer
|
|
2179
|
+
* form: `Aggregates: [{expression: 'MIN(Salary)'}]` under a narrow filter returns a denied
|
|
2180
|
+
* field's exact values directly. Predicate validation is a first-class enforcement point,
|
|
2181
|
+
* not a belt-and-braces afterthought. Together these cover every caller-authored expression
|
|
2182
|
+
* surface (`UserSearchString` is handled by excluding denied fields from the searched set,
|
|
2183
|
+
* not by rejection — see below).
|
|
2184
|
+
*
|
|
2185
|
+
* Lives at the provider layer rather than in the GraphQL resolver (where the plan first
|
|
2186
|
+
* placed it) because every RunView funnels through here — the batch path, server-internal
|
|
2187
|
+
* agents and actions running under a restricted `contextUser`, and the resolver alike. One
|
|
2188
|
+
* gate, no path left uncovered.
|
|
2189
|
+
*
|
|
2190
|
+
* The error deliberately does not say whether the field is missing or merely forbidden —
|
|
2191
|
+
* see {@link ProviderBase.FieldSecurityDenialMessage}.
|
|
2192
|
+
*/
|
|
2193
|
+
AssertPredicatesRespectFieldSecurity(params, contextUser) {
|
|
2194
|
+
if (!contextUser || !params.EntityName) {
|
|
2195
|
+
return;
|
|
2196
|
+
}
|
|
2197
|
+
const entity = this.EntityByName(params.EntityName);
|
|
2198
|
+
if (!entity?.EnableFieldLevelSecurity) {
|
|
2199
|
+
return; // the overwhelmingly common case — one boolean, no allocation
|
|
2200
|
+
}
|
|
2201
|
+
const denied = entity.GetDeniedReadFields(contextUser);
|
|
2202
|
+
if (denied.size === 0) {
|
|
2203
|
+
return;
|
|
2204
|
+
}
|
|
2205
|
+
// Map lowercased denied names back to their real casing for the message.
|
|
2206
|
+
const deniedByName = new Map();
|
|
2207
|
+
for (const field of entity.Fields) {
|
|
2208
|
+
if (denied.has(field.Name.trim().toLowerCase())) {
|
|
2209
|
+
deniedByName.set(field.Name.trim().toLowerCase(), field.Name);
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
// Aliases are deliberately not scanned — an alias names the output column, no data
|
|
2213
|
+
// flows through it.
|
|
2214
|
+
const clauses = [
|
|
2215
|
+
params.ExtraFilter,
|
|
2216
|
+
params.OrderBy,
|
|
2217
|
+
...(params.Aggregates ?? []).map(a => a?.expression),
|
|
2218
|
+
];
|
|
2219
|
+
for (const clause of clauses) {
|
|
2220
|
+
if (typeof clause !== 'string' || clause.length === 0) {
|
|
2221
|
+
continue;
|
|
2222
|
+
}
|
|
2223
|
+
const hits = FindReferencedIdentifiers(clause, deniedByName.values());
|
|
2224
|
+
if (hits.length > 0) {
|
|
2225
|
+
LogDebug(`[FieldSecurity] Rejected RunView on '${entity.Name}' for user ${contextUser.Email}: ` +
|
|
2226
|
+
`predicate references denied field(s) ${hits.join(', ')}`);
|
|
2227
|
+
throw new FieldSecurityError(hits[0], entity.Name);
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
// UserSearchString is not a predicate the caller authored — it is a search term the
|
|
2231
|
+
// platform expands across IncludeInUserSearchAPI fields. There is nothing to reject;
|
|
2232
|
+
// the correct handling is to not search the denied fields, which the SQL builder does
|
|
2233
|
+
// by consulting the same denied set. Rejecting here would make the search box fail
|
|
2234
|
+
// for a term that merely happens to match a restricted field's name.
|
|
2235
|
+
}
|
|
2236
|
+
/**
|
|
2237
|
+
* The single wording for "you cannot use this field," modeled on SQL Server's posture of
|
|
2238
|
+
* never disclosing whether an object is missing or merely inaccessible.
|
|
2239
|
+
*
|
|
2240
|
+
* Naming the field is safe — the caller supplied it, so it tells them nothing they did not
|
|
2241
|
+
* already know. Naming the REASON is not: confirming "this field exists and is restricted"
|
|
2242
|
+
* turns any predicate into an oracle for probing which columns a deployment considers
|
|
2243
|
+
* sensitive. The ambiguity also keeps this message correct after
|
|
2244
|
+
* [#3485](https://github.com/MemberJunction/MJ/issues/3485) tiers metadata and restricted
|
|
2245
|
+
* fields stop shipping to clients at all, at which point "does not exist" becomes literally
|
|
2246
|
+
* true from the client's vantage point.
|
|
2247
|
+
*/
|
|
2248
|
+
static FieldSecurityDenialMessage(fieldName, entityName) {
|
|
2249
|
+
return FieldSecurityDenialMessage(fieldName, entityName);
|
|
2250
|
+
}
|
|
2251
|
+
/**
|
|
2252
|
+
* Strips fields the user cannot read from plain-object result rows.
|
|
2253
|
+
*
|
|
2254
|
+
* This is the primary read-time control for list results, and it runs on BOTH cache hits
|
|
2255
|
+
* and cache misses — the property the rest of the cache design is arranged around. Because
|
|
2256
|
+
* it reads live metadata, a permission change takes effect on the next metadata refresh
|
|
2257
|
+
* with no result-cache invalidation: the cached full-width superset stays valid and only
|
|
2258
|
+
* the projection changes.
|
|
2259
|
+
*
|
|
2260
|
+
* It composes with two per-request, never-cached narrowings that avoid pulling columns a
|
|
2261
|
+
* restricted service account has no business holding: the `simple`-path SELECT-list
|
|
2262
|
+
* intersection, and `Load()`'s allowed-column SELECT.
|
|
2263
|
+
*
|
|
2264
|
+
* NEVER applied to `entity_object` results. Those become `BaseEntity` instances whose
|
|
2265
|
+
* fields round-trip through `GenerateSaveSQL`, which iterates ALL `IsSPParameter` fields
|
|
2266
|
+
* reading `field.Value` — not just dirty ones. A stripped field would therefore be written
|
|
2267
|
+
* back as a real NULL on the user's next save: silent data loss. Entity objects keep their
|
|
2268
|
+
* values in server memory exactly as encrypted fields do; the trust boundary is the API
|
|
2269
|
+
* output, which the GraphQL layer enforces separately.
|
|
2270
|
+
*/
|
|
2271
|
+
ApplyFieldSecurityProjection(rows, params, contextUser) {
|
|
2272
|
+
if (!rows?.length || !contextUser || !params.EntityName || params.ResultType === 'entity_object') {
|
|
2273
|
+
return rows;
|
|
2274
|
+
}
|
|
2275
|
+
const entity = this.EntityByName(params.EntityName);
|
|
2276
|
+
if (!entity?.EnableFieldLevelSecurity) {
|
|
2277
|
+
return rows;
|
|
2278
|
+
}
|
|
2279
|
+
const denied = entity.GetDeniedReadFields(contextUser);
|
|
2280
|
+
if (denied.size === 0) {
|
|
2281
|
+
return rows;
|
|
2282
|
+
}
|
|
2283
|
+
return ProviderBase.OmitFieldsFromRows(rows, denied);
|
|
2284
|
+
}
|
|
2285
|
+
/**
|
|
2286
|
+
* Row-loop half of {@link ApplyFieldSecurityProjection}, split out so the policy above reads
|
|
2287
|
+
* as policy. Mirrors {@link ProjectRowsToFields}' matching semantics (trim + lowercase) and
|
|
2288
|
+
* its no-op probe: when the first row carries none of the denied keys, the whole result is
|
|
2289
|
+
* returned untouched rather than rebuilt row by row.
|
|
2290
|
+
*
|
|
2291
|
+
* @param deniedLowercase field names to omit, already lowercased
|
|
2292
|
+
*/
|
|
2293
|
+
static OmitFieldsFromRows(rows, deniedLowercase) {
|
|
2294
|
+
const probe = rows[0];
|
|
2295
|
+
if (!probe || typeof probe !== 'object') {
|
|
2296
|
+
return rows;
|
|
2297
|
+
}
|
|
2298
|
+
const keysToOmit = Object.keys(probe).filter(k => deniedLowercase.has(k.trim().toLowerCase()));
|
|
2299
|
+
if (keysToOmit.length === 0) {
|
|
2300
|
+
return rows;
|
|
2301
|
+
}
|
|
2302
|
+
const omit = new Set(keysToOmit);
|
|
2303
|
+
return rows.map((row) => {
|
|
2304
|
+
const source = row;
|
|
2305
|
+
const filtered = {};
|
|
2306
|
+
for (const key of Object.keys(source)) {
|
|
2307
|
+
if (!omit.has(key)) {
|
|
2308
|
+
filtered[key] = source[key];
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
return filtered;
|
|
2312
|
+
});
|
|
2313
|
+
}
|
|
2314
|
+
/**
|
|
2315
|
+
* Strips or narrows the `MJ: Record Changes` payload columns a user may not read, using the
|
|
2316
|
+
* denied set of the entity each row is ABOUT rather than of Record Changes itself.
|
|
2317
|
+
*
|
|
2318
|
+
* A sibling of {@link ApplyFieldSecurityProjection} rather than part of it, because that
|
|
2319
|
+
* method short-circuits on `EnableFieldLevelSecurity` for the entity named in the RunView
|
|
2320
|
+
* params — which here is `MJ: Record Changes`, whose flag is off in every default deployment.
|
|
2321
|
+
* Everything about the per-row denied set, the payload treatment, and the fail-closed decision
|
|
2322
|
+
* lives in {@link RecordChangeFieldSecurityProjector}; this is only the RunView wiring.
|
|
2323
|
+
*
|
|
2324
|
+
* Runs at all four RunView projection points, matching the main projection: both cache-hit
|
|
2325
|
+
* paths and both cache-miss paths. The cache-hit path is not optional — it is the exact path
|
|
2326
|
+
* the original cross-user leak runs through, an unrestricted user warming a full-width slot
|
|
2327
|
+
* that a restricted user then hits.
|
|
2328
|
+
*
|
|
2329
|
+
* NEVER applied to `entity_object` results, for the reason the main projection is exempt plus
|
|
2330
|
+
* one specific to this one. The main reason transfers directly: an entity object's fields
|
|
2331
|
+
* round-trip through `GenerateSaveSQL`, which reads EVERY `IsSPParameter` field's value rather
|
|
2332
|
+
* than only dirty ones, so a withheld `ChangesDescription` would be written back as a real
|
|
2333
|
+
* NULL and a narrowed `ChangesJSON` as the narrowed payload — destroying audit history instead
|
|
2334
|
+
* of merely hiding it. Record Changes rows genuinely are saved through the entity layer
|
|
2335
|
+
* (replay writes `Status`/`ErrorLog`, users write `Comments`), so this is not hypothetical.
|
|
2336
|
+
* The additional reason is that the exemption cannot become a hole: the GraphQL RunView
|
|
2337
|
+
* resolver coerces `entity_object` to `simple` on the wire, so an `entity_object` Record
|
|
2338
|
+
* Changes result is by construction server-internal — and server-internal code holding full
|
|
2339
|
+
* values in memory is the documented trust boundary (FLS guide §3.4), exactly as for
|
|
2340
|
+
* encrypted fields.
|
|
2341
|
+
*/
|
|
2342
|
+
ApplyRecordChangeFieldSecurityProjection(rows, params, contextUser) {
|
|
2343
|
+
if (!rows?.length || !contextUser || params.ResultType === 'entity_object') {
|
|
2344
|
+
return rows;
|
|
2345
|
+
}
|
|
2346
|
+
if (!RecordChangeFieldSecurityProjector.IsRecordChangesEntity(this.ResolveRunViewEntitySync(params))) {
|
|
2347
|
+
return rows;
|
|
2348
|
+
}
|
|
2349
|
+
return new RecordChangeFieldSecurityProjector(this, contextUser).ProjectRows(rows);
|
|
2350
|
+
}
|
|
2351
|
+
/**
|
|
2352
|
+
* The entity a RunView targets, resolved with NO I/O — for gates that run on the result path,
|
|
2353
|
+
* where an async `RunView.GetEntityNameFromRunViewParams` (which issues a User Views query for
|
|
2354
|
+
* a bare `ViewID`) would be a query per result set.
|
|
2355
|
+
*
|
|
2356
|
+
* Covers the two shapes every caller in this repository uses: an explicit `EntityName`, and a
|
|
2357
|
+
* loaded `ViewEntity`. A request carrying only `ViewID`/`ViewName` with neither is not
|
|
2358
|
+
* resolvable here and returns undefined — the same shape `RunView`'s own row normalization
|
|
2359
|
+
* already declines to handle for the same reason, and the same one
|
|
2360
|
+
* {@link cacheDeniedForViewOnlyRequest} exists to fail closed for on the cache path.
|
|
2361
|
+
*/
|
|
2362
|
+
ResolveRunViewEntitySync(params) {
|
|
2363
|
+
if (params.EntityName) {
|
|
2364
|
+
return this.EntityByName(params.EntityName);
|
|
2365
|
+
}
|
|
2366
|
+
// Weak typing on ViewEntity is deliberate and pre-existing: this is MJCore, so it cannot
|
|
2367
|
+
// import the UserView subclass from core-entities without a circular dependency. Same
|
|
2368
|
+
// idiom as cacheDeniedForViewOnlyRequest and RunView.GetEntityNameFromRunViewParams.
|
|
2369
|
+
const viewEntityID = params.ViewEntity?.Get('EntityID');
|
|
2370
|
+
return typeof viewEntityID === 'string' ? this.EntityByID(viewEntityID) : undefined;
|
|
2371
|
+
}
|
|
1919
2372
|
async PreRunView(params, contextUser) {
|
|
1920
2373
|
const preViewStart = performance.now();
|
|
1921
2374
|
// Resolve any PlatformSQL values to plain strings for the active platform
|
|
@@ -1924,6 +2377,10 @@ export class ProviderBase {
|
|
|
1924
2377
|
// Hooks run after PlatformSQL resolution so they see plain-string filters,
|
|
1925
2378
|
// and before cache fingerprinting so injected filters affect the cache key.
|
|
1926
2379
|
params = await this.RunPreRunViewHooks(params, contextUser);
|
|
2380
|
+
// Field-level security: reject predicates referencing unreadable fields. Runs AFTER
|
|
2381
|
+
// hooks so injected filters are scanned too, and BEFORE the cache lookup so a rejected
|
|
2382
|
+
// request never consults (or warms) a cache slot.
|
|
2383
|
+
this.AssertPredicatesRespectFieldSecurity(params, contextUser);
|
|
1927
2384
|
// Start telemetry tracking
|
|
1928
2385
|
const telemetryStart = performance.now();
|
|
1929
2386
|
// After ResolvePlatformSQLInParams, ExtraFilter/OrderBy are guaranteed to be strings
|
|
@@ -1956,13 +2413,23 @@ export class ProviderBase {
|
|
|
1956
2413
|
let callerRequestedFields = params.Fields && params.Fields.length > 0
|
|
1957
2414
|
? params.Fields.map(f => f.trim().toLowerCase())
|
|
1958
2415
|
: null; // null = caller wants all fields
|
|
1959
|
-
// Only override Fields to
|
|
2416
|
+
// Only override Fields to the widened fetch set when caching will actually happen
|
|
1960
2417
|
// for this call. For non-cached calls we respect the caller's narrow Fields
|
|
1961
|
-
// end-to-end — there's no cache-coherence concern to preserve.
|
|
2418
|
+
// end-to-end — there's no cache-coherence concern to preserve. The widened set is
|
|
2419
|
+
// ALL entity fields, for every user: the server cache holds full-width slots and
|
|
2420
|
+
// narrows per request at read time (ApplyFieldSecurityProjection).
|
|
1962
2421
|
const entity = params.EntityName ? this.EntityByName(params.EntityName) : null;
|
|
1963
2422
|
const willCache = this.runViewCacheEligible(params);
|
|
1964
|
-
|
|
1965
|
-
|
|
2423
|
+
// entity_object ALWAYS widens, cache-eligible or not: a BaseEntity must hydrate from
|
|
2424
|
+
// every column it is entitled to, or it round-trips partial state into a save. This
|
|
2425
|
+
// must happen here rather than only in the server's PreProcessRunView, which is not on
|
|
2426
|
+
// the client's path — a client entity_object request without CacheLocal would otherwise
|
|
2427
|
+
// ship the caller's narrow Fields over the wire and materialize partial entities. What a
|
|
2428
|
+
// field-restricted user RECEIVES is still their allowed set: the server strips denied
|
|
2429
|
+
// columns on the wire, and the missing keys mark those fields not-loaded.
|
|
2430
|
+
const widenForEntityObject = params.ResultType === 'entity_object';
|
|
2431
|
+
if (entity && (willCache || widenForEntityObject)) {
|
|
2432
|
+
params.Fields = this.ComputeRunViewFetchFields(entity);
|
|
1966
2433
|
// Platform contract: explicit Fields always include the primary key(s) —
|
|
1967
2434
|
// project back down to requested ∪ PK, matching the direct SQL path.
|
|
1968
2435
|
if (callerRequestedFields) {
|
|
@@ -1988,7 +2455,8 @@ export class ProviderBase {
|
|
|
1988
2455
|
this.cacheDeniedForViewOnlyRequest(params, contextUser);
|
|
1989
2456
|
if (willCache && !cacheReadDenied && LocalCacheManager.Instance.IsInitialized) {
|
|
1990
2457
|
const rlsWhereClause = this.ComputeRunViewRLSWhereClause(params, contextUser);
|
|
1991
|
-
|
|
2458
|
+
const flsFieldsKey = this.ComputeRunViewFLSFingerprintKey(params);
|
|
2459
|
+
fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, rlsWhereClause, undefined, flsFieldsKey);
|
|
1992
2460
|
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
1993
2461
|
if (cached) {
|
|
1994
2462
|
// These rows are the cache's shared, deep-frozen objects — the runtime freeze is
|
|
@@ -1998,6 +2466,17 @@ export class ProviderBase {
|
|
|
1998
2466
|
if (callerRequestedFields && params.ResultType !== 'entity_object') {
|
|
1999
2467
|
results = ProjectRowsToFields(results, callerRequestedFields);
|
|
2000
2468
|
}
|
|
2469
|
+
// Field-level security MUST be applied here, not only in PostRunView: this path
|
|
2470
|
+
// RETURNS (see RunView) without ever calling PostRunView, and it is the exact
|
|
2471
|
+
// path the cross-user leak runs through — an unrestricted user warms the cache
|
|
2472
|
+
// with every column, then a restricted user hits that same slot. The cache is
|
|
2473
|
+
// column-agnostic by design (one entry serves every field subset), so the
|
|
2474
|
+
// caller's Fields list is unrelated to security and cannot be relied on.
|
|
2475
|
+
results = this.ApplyFieldSecurityProjection(results, params, contextUser);
|
|
2476
|
+
// Record Changes payload security, on the same cache-hit path and for the same
|
|
2477
|
+
// reason: the slot is shared and full-width, so whoever warmed it decides nothing
|
|
2478
|
+
// about who may read what out of it.
|
|
2479
|
+
results = this.ApplyRecordChangeFieldSecurityProjection(results, params, contextUser);
|
|
2001
2480
|
// Reconstruct RunViewResult from cached data
|
|
2002
2481
|
cachedResult = {
|
|
2003
2482
|
Success: true,
|
|
@@ -2050,6 +2529,10 @@ export class ProviderBase {
|
|
|
2050
2529
|
// Run registered PreRunView hooks on each param in the batch
|
|
2051
2530
|
for (let i = 0; i < params.length; i++) {
|
|
2052
2531
|
params[i] = await this.RunPreRunViewHooks(params[i], contextUser);
|
|
2532
|
+
// Field-level security predicate gate, same contract as the single-view path.
|
|
2533
|
+
// One bad view rejects the whole batch — a batch is one caller request, and
|
|
2534
|
+
// silently returning partial results would hide the denial.
|
|
2535
|
+
this.AssertPredicatesRespectFieldSecurity(params[i], contextUser);
|
|
2053
2536
|
}
|
|
2054
2537
|
// Start telemetry tracking for batch operation
|
|
2055
2538
|
const fromEngine = params.some(p => p._fromEngine);
|
|
@@ -2112,13 +2595,16 @@ export class ProviderBase {
|
|
|
2112
2595
|
let callerFields = param.Fields && param.Fields.length > 0
|
|
2113
2596
|
? param.Fields.map(f => f.trim().toLowerCase())
|
|
2114
2597
|
: null;
|
|
2115
|
-
// Only override Fields to
|
|
2598
|
+
// Only override Fields to the widened fetch set when caching will actually happen
|
|
2116
2599
|
// for this call. For non-cached calls we respect the caller's narrow Fields
|
|
2117
|
-
// end-to-end — there's no cache-coherence concern to preserve.
|
|
2600
|
+
// end-to-end — there's no cache-coherence concern to preserve. Widened = ALL
|
|
2601
|
+
// fields, for every user (see PreRunView).
|
|
2118
2602
|
const batchEntity = param.EntityName ? this.EntityByName(param.EntityName) : null;
|
|
2119
2603
|
const batchWillCache = this.runViewCacheEligible(param);
|
|
2120
|
-
|
|
2121
|
-
|
|
2604
|
+
// Same entity_object-always-widens rule as the single-view path above.
|
|
2605
|
+
const batchWidenForEntityObject = param.ResultType === 'entity_object';
|
|
2606
|
+
if (batchEntity && (batchWillCache || batchWidenForEntityObject)) {
|
|
2607
|
+
param.Fields = this.ComputeRunViewFetchFields(batchEntity);
|
|
2122
2608
|
// Platform contract: explicit Fields always include the primary key(s)
|
|
2123
2609
|
if (callerFields) {
|
|
2124
2610
|
callerFields = ProviderBase.UnionFieldsWithPrimaryKeys(callerFields, batchEntity);
|
|
@@ -2138,7 +2624,8 @@ export class ProviderBase {
|
|
|
2138
2624
|
this.cacheDeniedForViewOnlyRequest(param, contextUser);
|
|
2139
2625
|
if (batchWillCache && !batchCacheReadDenied && LocalCacheManager.Instance.IsInitialized) {
|
|
2140
2626
|
const rlsWhereClause = this.ComputeRunViewRLSWhereClause(param, contextUser);
|
|
2141
|
-
const
|
|
2627
|
+
const flsFieldsKey = this.ComputeRunViewFLSFingerprintKey(param);
|
|
2628
|
+
const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString, rlsWhereClause, undefined, flsFieldsKey);
|
|
2142
2629
|
fingerprintMap.set(i, fingerprint);
|
|
2143
2630
|
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
2144
2631
|
if (cached) {
|
|
@@ -2147,6 +2634,11 @@ export class ProviderBase {
|
|
|
2147
2634
|
if (callerFields && param.ResultType !== 'entity_object') {
|
|
2148
2635
|
results = ProjectRowsToFields(results, callerFields);
|
|
2149
2636
|
}
|
|
2637
|
+
// Field-level security on the batch cache-hit path — same reasoning as the
|
|
2638
|
+
// single-view path in PreRunView: this result is returned without
|
|
2639
|
+
// PostRunViews projecting it, and the cached superset is column-agnostic.
|
|
2640
|
+
results = this.ApplyFieldSecurityProjection(results, param, contextUser);
|
|
2641
|
+
results = this.ApplyRecordChangeFieldSecurityProjection(results, param, contextUser);
|
|
2150
2642
|
const cachedViewResult = {
|
|
2151
2643
|
Success: true,
|
|
2152
2644
|
Results: results,
|
|
@@ -2402,13 +2894,21 @@ export class ProviderBase {
|
|
|
2402
2894
|
else if (checkResult.status === 'differential') {
|
|
2403
2895
|
// Cache is stale but we have differential data - merge with cached data
|
|
2404
2896
|
const fingerprint = this.clientCacheFingerprint(param);
|
|
2405
|
-
//
|
|
2897
|
+
// Every primary key column, in order. The server's `deletedRecordIDs` are the full
|
|
2898
|
+
// RecordChanges.RecordID segments (`F1|v1||F2|v2` for a composite key), and the merge
|
|
2899
|
+
// keys cached and updated rows the same way — so keying on the first column alone made
|
|
2900
|
+
// composite-key deletes never match and collapsed rows sharing that column. An entity the
|
|
2901
|
+
// server described but this provider cannot resolve is not merged: fall through to the
|
|
2902
|
+
// full refetch below rather than inventing an `ID` key.
|
|
2406
2903
|
const entity = this.EntityByName(param.EntityName);
|
|
2407
|
-
const
|
|
2904
|
+
const primaryKeyFieldNames = entity?.PrimaryKeys.map(pk => pk.Name) ?? [];
|
|
2905
|
+
if (primaryKeyFieldNames.length === 0) {
|
|
2906
|
+
LogError(`ProviderBase: server returned differential data for '${param.EntityName}' but the entity is not in this provider's metadata — refetching in full.`);
|
|
2907
|
+
}
|
|
2408
2908
|
// Apply differential update to cache (runViewCacheEligible, not raw CacheLocal — see the
|
|
2409
2909
|
// cacheable-gate note in prepareSmartCacheCheckParams; keeps Materialized/count_only/etc. out).
|
|
2410
|
-
if (this.runViewCacheEligibleForWrite(param) && checkResult.differentialData && LocalCacheManager.Instance.IsInitialized) {
|
|
2411
|
-
const merged = await LocalCacheManager.Instance.ApplyDifferentialUpdate(fingerprint, param, checkResult.differentialData.updatedRows, checkResult.differentialData.deletedRecordIDs,
|
|
2910
|
+
if (primaryKeyFieldNames.length > 0 && this.runViewCacheEligibleForWrite(param) && checkResult.differentialData && LocalCacheManager.Instance.IsInitialized) {
|
|
2911
|
+
const merged = await LocalCacheManager.Instance.ApplyDifferentialUpdate(fingerprint, param, checkResult.differentialData.updatedRows, checkResult.differentialData.deletedRecordIDs, primaryKeyFieldNames, checkResult.maxUpdatedAt || new Date().toISOString(), checkResult.rowCount || 0, checkResult.aggregateResults, // Pass fresh aggregate results (can't be differentially computed)
|
|
2412
2912
|
this);
|
|
2413
2913
|
if (merged) {
|
|
2414
2914
|
const mergedResult = {
|
|
@@ -2564,11 +3064,11 @@ export class ProviderBase {
|
|
|
2564
3064
|
const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
|
|
2565
3065
|
await LocalCacheManager.Instance.SetRunViewResult(preResult.fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
|
|
2566
3066
|
}
|
|
2567
|
-
else if (this.shouldAutoCache(params, result)) {
|
|
3067
|
+
else if (this.shouldAutoCache(params, result, contextUser)) {
|
|
2568
3068
|
// Server-side auto-cache: small, unfiltered, unsorted results are
|
|
2569
3069
|
// automatically cached even without explicit CacheLocal. These are
|
|
2570
3070
|
// safe for in-place upsert on entity changes (no filter to evaluate).
|
|
2571
|
-
const fingerprint = preResult.fingerprint || LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, this.ComputeRunViewRLSWhereClause(params, contextUser));
|
|
3071
|
+
const fingerprint = preResult.fingerprint || LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, this.ComputeRunViewRLSWhereClause(params, contextUser), undefined, this.ComputeRunViewFLSFingerprintKey(params));
|
|
2572
3072
|
const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
|
|
2573
3073
|
await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
|
|
2574
3074
|
LogStatusEx({ message: ` 📦 [Auto-Cache] RunView "${params.EntityName || params.ViewName || 'unknown'}" — ${result.Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
|
|
@@ -2582,6 +3082,15 @@ export class ProviderBase {
|
|
|
2582
3082
|
if (result.Success && preResult.callerRequestedFields && params.ResultType !== 'entity_object') {
|
|
2583
3083
|
result.Results = ProjectRowsToFields(result.Results, preResult.callerRequestedFields);
|
|
2584
3084
|
}
|
|
3085
|
+
// Field-level security on the cache-MISS path (the hit path is enforced in PreRunView,
|
|
3086
|
+
// which returns before this hook ever runs). Must come AFTER the cache write above so
|
|
3087
|
+
// the cache keeps the universal superset — permission changes then take effect on the
|
|
3088
|
+
// next metadata refresh with no result-cache invalidation, because visibility is a pure
|
|
3089
|
+
// read-time projection rather than something baked into the cached rows.
|
|
3090
|
+
if (result.Success) {
|
|
3091
|
+
result.Results = this.ApplyFieldSecurityProjection(result.Results, params, contextUser);
|
|
3092
|
+
result.Results = this.ApplyRecordChangeFieldSecurityProjection(result.Results, params, contextUser);
|
|
3093
|
+
}
|
|
2585
3094
|
// Transform the result set into BaseEntity-derived objects, if needed
|
|
2586
3095
|
await this.TransformSimpleObjectToEntityObject(params, result, contextUser);
|
|
2587
3096
|
// Run registered PostRunView hooks (e.g., data masking, audit logging).
|
|
@@ -2636,7 +3145,7 @@ export class ProviderBase {
|
|
|
2636
3145
|
// per item. Compute lazily only for indexes PreRunViews skipped (cache
|
|
2637
3146
|
// was disabled for them but auto-cache/OnDataChanged may still need it).
|
|
2638
3147
|
const fingerprint = preResult.fingerprintMap?.get(i)
|
|
2639
|
-
?? LocalCacheManager.Instance.GenerateRunViewFingerprint(params[i], this.InstanceConnectionString, this.ComputeRunViewRLSWhereClause(params[i], contextUser));
|
|
3148
|
+
?? LocalCacheManager.Instance.GenerateRunViewFingerprint(params[i], this.InstanceConnectionString, this.ComputeRunViewRLSWhereClause(params[i], contextUser), undefined, this.ComputeRunViewFLSFingerprintKey(params[i]));
|
|
2640
3149
|
// CRITICAL: must be the SAME eligibility predicate PreRunViews used to decide
|
|
2641
3150
|
// whether to widen Fields. Writing a non-widened (narrow or keyset-paged)
|
|
2642
3151
|
// result here poisons the Fields-agnostic superset slot — this exact gate
|
|
@@ -2645,7 +3154,7 @@ export class ProviderBase {
|
|
|
2645
3154
|
const maxUpdatedAt = this.extractMaxUpdatedAt(results[i].Results);
|
|
2646
3155
|
cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount, this));
|
|
2647
3156
|
}
|
|
2648
|
-
else if (this.shouldAutoCache(params[i], results[i])) {
|
|
3157
|
+
else if (this.shouldAutoCache(params[i], results[i], contextUser)) {
|
|
2649
3158
|
const maxUpdatedAt = this.extractMaxUpdatedAt(results[i].Results);
|
|
2650
3159
|
cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount, this));
|
|
2651
3160
|
LogStatusEx({ message: ` 📦 [Auto-Cache] RunViews "${params[i].EntityName || params[i].ViewName || 'unknown'}" — ${results[i].Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
|
|
@@ -2674,6 +3183,18 @@ export class ProviderBase {
|
|
|
2674
3183
|
}
|
|
2675
3184
|
}
|
|
2676
3185
|
}
|
|
3186
|
+
// Field-level security on the batch cache-MISS results. Deliberately a separate loop
|
|
3187
|
+
// from the projection above rather than folded into it: that one is gated on
|
|
3188
|
+
// callerFieldsMap (present only when Fields were actually widened), while this must run
|
|
3189
|
+
// for every uncached result regardless of whether the caller narrowed Fields. Cache hits
|
|
3190
|
+
// are skipped because PreRunViews already projected them.
|
|
3191
|
+
for (let i = 0; i < results.length; i++) {
|
|
3192
|
+
if (preResult.cacheStatusMap?.get(i)?.status === 'hit' || !results[i].Success) {
|
|
3193
|
+
continue;
|
|
3194
|
+
}
|
|
3195
|
+
results[i].Results = this.ApplyFieldSecurityProjection(results[i].Results, params[i], contextUser);
|
|
3196
|
+
results[i].Results = this.ApplyRecordChangeFieldSecurityProjection(results[i].Results, params[i], contextUser);
|
|
3197
|
+
}
|
|
2677
3198
|
// Transform results to entity objects AFTER caching plain objects.
|
|
2678
3199
|
// Skip results that came from cache hits — they're already entity objects.
|
|
2679
3200
|
const transformPromises = [];
|
|
@@ -2881,15 +3402,16 @@ export class ProviderBase {
|
|
|
2881
3402
|
return false;
|
|
2882
3403
|
return entity.TrustServerCacheCompletely !== false;
|
|
2883
3404
|
}
|
|
2884
|
-
shouldAutoCache(params, result) {
|
|
3405
|
+
shouldAutoCache(params, result, contextUser) {
|
|
2885
3406
|
if (!this.TrustLocalCacheCompletely)
|
|
2886
3407
|
return false;
|
|
2887
3408
|
if (params.CacheLocal)
|
|
2888
3409
|
return false; // already handled
|
|
2889
3410
|
// Same eligibility predicate as the main cache path — covers BypassCache,
|
|
2890
|
-
// AfterKey (keyset pages), count_only,
|
|
2891
|
-
// auto-cached keyset page or
|
|
2892
|
-
// entity+filter slot just like the
|
|
3411
|
+
// AfterKey (keyset pages), count_only, cache-disallowed entities, and the
|
|
3412
|
+
// field-security entity_object exemption. An auto-cached keyset page or
|
|
3413
|
+
// count_only result would poison the entity+filter slot just like the
|
|
3414
|
+
// main-path variants of those bugs.
|
|
2893
3415
|
if (!this.runViewCacheEligible(params))
|
|
2894
3416
|
return false;
|
|
2895
3417
|
if (!LocalCacheManager.Instance.IsInitialized)
|
|
@@ -3357,24 +3879,62 @@ export class ProviderBase {
|
|
|
3357
3879
|
// first, make sure we reset the flag to false so that if another call to this function happens
|
|
3358
3880
|
// while we are waiting for the async call to finish, we dont do it again
|
|
3359
3881
|
this._refresh = false;
|
|
3360
|
-
//
|
|
3361
|
-
//
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
this.UpdateLocalMetadata(res);
|
|
3372
|
-
this._latestLocalMetadataTimestamps = this._latestRemoteMetadataTimestamps; // update this since we just used server to get all the stuff
|
|
3373
|
-
await this.SaveLocalMetadataToStorage();
|
|
3882
|
+
// SINGLE-FLIGHT: at most one full metadata reload runs at a time. Without this, a
|
|
3883
|
+
// second refresh request arriving while a reload is still awaiting its queries starts
|
|
3884
|
+
// a CONCURRENT reload, and whichever finishes LAST wins the atomic swap — an older
|
|
3885
|
+
// snapshot can overwrite a newer one. A joiner must not simply await and return,
|
|
3886
|
+
// either: the in-flight reload's queries may predate the write that prompted the
|
|
3887
|
+
// joiner, so it flags ONE follow-up; the loop below reruns after the current pass,
|
|
3888
|
+
// guaranteeing the final swap comes from a read that started after the last request.
|
|
3889
|
+
if (this._metadataReloadInFlight) {
|
|
3890
|
+
this._metadataReloadQueued = true;
|
|
3891
|
+
await this._metadataReloadInFlight;
|
|
3892
|
+
return true;
|
|
3374
3893
|
}
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3894
|
+
this._metadataReloadInFlight = (async () => {
|
|
3895
|
+
let effectiveHardRefresh = hardRefresh;
|
|
3896
|
+
do {
|
|
3897
|
+
this._metadataReloadQueued = false;
|
|
3898
|
+
// The local timestamps must describe the snapshot about to be loaded. On the
|
|
3899
|
+
// hard-refresh path the staleness check was SKIPPED, so the cached remote
|
|
3900
|
+
// timestamps predate this pass — copying them as-is would make the next
|
|
3901
|
+
// periodic check see a mismatch and reload once more for nothing. Re-read
|
|
3902
|
+
// them (one cheap status query, authoritative) BEFORE the load, not after:
|
|
3903
|
+
// a write landing DURING the load then leaves the stamped timestamps looking
|
|
3904
|
+
// stale and the next tick reloads — the safe direction. Reading after could
|
|
3905
|
+
// stamp the snapshot as containing a write it does not.
|
|
3906
|
+
if (effectiveHardRefresh) {
|
|
3907
|
+
await this.RefreshRemoteMetadataTimestamps(providerToUse);
|
|
3908
|
+
}
|
|
3909
|
+
// Fetch new metadata without clearing current metadata
|
|
3910
|
+
// This ensures readers always see valid data (old until new is ready)
|
|
3911
|
+
const start = new Date().getTime();
|
|
3912
|
+
const res = await this.GetAllMetadata(providerToUse, effectiveHardRefresh);
|
|
3913
|
+
const end = new Date().getTime();
|
|
3914
|
+
LogStatusEx({ message: `GetAllMetadata() took ${end - start} ms`, verboseOnly: true });
|
|
3915
|
+
if (res) {
|
|
3916
|
+
// Atomic swap via UpdateLocalMetadata: single property assignment is atomic in JavaScript
|
|
3917
|
+
// Readers now see new metadata instead of old
|
|
3918
|
+
// Uses UpdateLocalMetadata() to maintain consistency with LoadLocalMetadataFromStorage()
|
|
3919
|
+
// and allow potential subclass overrides for extensibility
|
|
3920
|
+
this.UpdateLocalMetadata(res);
|
|
3921
|
+
this._latestLocalMetadataTimestamps = this._latestRemoteMetadataTimestamps; // update this since we just used server to get all the stuff
|
|
3922
|
+
await this.SaveLocalMetadataToStorage();
|
|
3923
|
+
}
|
|
3924
|
+
else {
|
|
3925
|
+
// GetAllMetadata failed - log error but keep existing metadata
|
|
3926
|
+
LogError('GetAllMetadata() returned undefined - metadata not updated');
|
|
3927
|
+
}
|
|
3928
|
+
// A queued follow-up exists only because another refresh request arrived
|
|
3929
|
+
// mid-reload; rerun hard so the re-read cannot be served by any cache layer.
|
|
3930
|
+
effectiveHardRefresh = true;
|
|
3931
|
+
} while (this._metadataReloadQueued);
|
|
3932
|
+
})();
|
|
3933
|
+
try {
|
|
3934
|
+
await this._metadataReloadInFlight;
|
|
3935
|
+
}
|
|
3936
|
+
finally {
|
|
3937
|
+
this._metadataReloadInFlight = null;
|
|
3378
3938
|
}
|
|
3379
3939
|
}
|
|
3380
3940
|
return true;
|
|
@@ -3402,6 +3962,7 @@ export class ProviderBase {
|
|
|
3402
3962
|
}
|
|
3403
3963
|
else {
|
|
3404
3964
|
LogStatusEx({ message: `⚡ [Metadata Cache] Background check: metadata is current — no refresh needed`, verboseOnly: false });
|
|
3965
|
+
await this.RefreshCurrentUser();
|
|
3405
3966
|
}
|
|
3406
3967
|
}
|
|
3407
3968
|
catch (e) {
|
|
@@ -3445,6 +4006,9 @@ export class ProviderBase {
|
|
|
3445
4006
|
}
|
|
3446
4007
|
else {
|
|
3447
4008
|
LogStatusEx({ message: `⚡ [Metadata Cache] Pre-validation: metadata is current`, verboseOnly: false });
|
|
4009
|
+
// Even when entity metadata is current, refresh CurrentUser so user roles and permissions
|
|
4010
|
+
// stay in sync with the database without requiring a schema change or manual cache clear.
|
|
4011
|
+
await this.RefreshCurrentUser();
|
|
3448
4012
|
}
|
|
3449
4013
|
}
|
|
3450
4014
|
catch (e) {
|
|
@@ -3594,13 +4158,17 @@ export class ProviderBase {
|
|
|
3594
4158
|
if (d && d.Success) {
|
|
3595
4159
|
// cache the dataset for anyone who wants to use it
|
|
3596
4160
|
await this.CacheDataset(ProviderBase._mjMetadataDatasetName, null, d);
|
|
4161
|
+
// Record which entities this metadata is built from, so a write to any of them
|
|
4162
|
+
// schedules a debounced refresh of this provider's metadata (see
|
|
4163
|
+
// registerMetadataDatasetMembership — membership is the dataset definition, not a list).
|
|
4164
|
+
this.registerMetadataDatasetMembership(d);
|
|
3597
4165
|
// got the results, let's build our response in the format we need
|
|
3598
4166
|
const simpleMetadata = {};
|
|
3599
4167
|
for (let r of d.Results) {
|
|
3600
4168
|
simpleMetadata[r.Code] = r.Results;
|
|
3601
4169
|
}
|
|
3602
4170
|
// Post Process Entities because there's some special handling of the sub-objects
|
|
3603
|
-
simpleMetadata.AllEntities = this.PostProcessEntityMetadata(simpleMetadata.Entities, simpleMetadata.EntityFields, simpleMetadata.EntityFieldValues, simpleMetadata.EntityPermissions, simpleMetadata.EntityRelationships, simpleMetadata.EntitySettings, simpleMetadata.EntityOrganicKeys, simpleMetadata.EntityOrganicKeyRelatedEntities);
|
|
4171
|
+
simpleMetadata.AllEntities = this.PostProcessEntityMetadata(simpleMetadata.Entities, simpleMetadata.EntityFields, simpleMetadata.EntityFieldValues, simpleMetadata.EntityPermissions, simpleMetadata.EntityRelationships, simpleMetadata.EntitySettings, simpleMetadata.EntityOrganicKeys, simpleMetadata.EntityOrganicKeyRelatedEntities, simpleMetadata.EntityFieldPermissions);
|
|
3604
4172
|
// Post Process the Applications, because we want to handle the sub-objects properly.
|
|
3605
4173
|
simpleMetadata.AllApplications = simpleMetadata.Applications.map((a) => {
|
|
3606
4174
|
a.ApplicationEntities = simpleMetadata.ApplicationEntities.filter((ae) => UUIDsEqual(ae.ApplicationID, a.ID));
|
|
@@ -3623,6 +4191,25 @@ export class ProviderBase {
|
|
|
3623
4191
|
LogError(e);
|
|
3624
4192
|
}
|
|
3625
4193
|
}
|
|
4194
|
+
/**
|
|
4195
|
+
* Refreshes the CurrentUser from the server and updates local metadata in place.
|
|
4196
|
+
* Useful on warm boot or when user roles/permissions change dynamically without
|
|
4197
|
+
* entity schema changes.
|
|
4198
|
+
*/
|
|
4199
|
+
async RefreshCurrentUser() {
|
|
4200
|
+
try {
|
|
4201
|
+
const user = await this.GetCurrentUser();
|
|
4202
|
+
if (user && this._localMetadata) {
|
|
4203
|
+
this._localMetadata.CurrentUser = user;
|
|
4204
|
+
void this.SaveLocalMetadataToStorage();
|
|
4205
|
+
return user;
|
|
4206
|
+
}
|
|
4207
|
+
}
|
|
4208
|
+
catch (e) {
|
|
4209
|
+
LogError(`[Metadata Cache] RefreshCurrentUser failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
4210
|
+
}
|
|
4211
|
+
return null;
|
|
4212
|
+
}
|
|
3626
4213
|
/**
|
|
3627
4214
|
* Post-processes entity metadata to establish relationships between entities and their child objects.
|
|
3628
4215
|
* Links fields, permissions, relationships, and settings to their parent entities.
|
|
@@ -3652,17 +4239,30 @@ export class ProviderBase {
|
|
|
3652
4239
|
}
|
|
3653
4240
|
return map;
|
|
3654
4241
|
}
|
|
3655
|
-
PostProcessEntityMetadata(entities, fields, fieldValues, permissions, relationships, settings, organicKeys, organicKeyRelatedEntities) {
|
|
4242
|
+
PostProcessEntityMetadata(entities, fields, fieldValues, permissions, relationships, settings, organicKeys, organicKeyRelatedEntities, fieldPermissions) {
|
|
3656
4243
|
const result = [];
|
|
3657
4244
|
// Sort entities alphabetically by name to ensure deterministic ordering
|
|
3658
4245
|
// This prevents non-deterministic output in CodeGen and other metadata consumers
|
|
3659
|
-
const sortedEntities = entities.sort((a, b) => a.Name.
|
|
4246
|
+
const sortedEntities = entities.sort((a, b) => ordinalCompare(a.Name, b.Name) || ordinalCompare(a.ID, b.ID));
|
|
3660
4247
|
if (fieldValues && fieldValues.length > 0) {
|
|
3661
4248
|
const fieldValuesByFieldId = this.groupByNormalizedUUID(fieldValues, fv => fv.EntityFieldID);
|
|
3662
4249
|
for (const f of fields) {
|
|
3663
4250
|
f.EntityFieldValues = fieldValuesByFieldId.get(NormalizeUUID(f.ID)) || [];
|
|
3664
4251
|
}
|
|
3665
4252
|
}
|
|
4253
|
+
// Link field-level security records to their fields. Optional and typically absent:
|
|
4254
|
+
// the dataset item is only present on databases that have it, and the array is empty
|
|
4255
|
+
// in every deployment that has not configured field security — so the whole pass is
|
|
4256
|
+
// skipped rather than assigning an empty array to thousands of field rows.
|
|
4257
|
+
if (fieldPermissions && fieldPermissions.length > 0) {
|
|
4258
|
+
const fieldPermissionsByFieldId = this.groupByNormalizedUUID(fieldPermissions, fp => fp.EntityFieldID);
|
|
4259
|
+
for (const f of fields) {
|
|
4260
|
+
const fieldPerms = fieldPermissionsByFieldId.get(NormalizeUUID(f.ID));
|
|
4261
|
+
if (fieldPerms) {
|
|
4262
|
+
f.EntityFieldPermissions = fieldPerms;
|
|
4263
|
+
}
|
|
4264
|
+
}
|
|
4265
|
+
}
|
|
3666
4266
|
// Link organic key related entities to their parent organic keys
|
|
3667
4267
|
if (organicKeys && organicKeyRelatedEntities && organicKeyRelatedEntities.length > 0) {
|
|
3668
4268
|
const okreByOrganicKeyId = this.groupByNormalizedUUID(organicKeyRelatedEntities, okre => okre.EntityOrganicKeyID);
|
|
@@ -3680,7 +4280,7 @@ export class ProviderBase {
|
|
|
3680
4280
|
for (const e of sortedEntities) {
|
|
3681
4281
|
const entityIdKey = NormalizeUUID(e.ID);
|
|
3682
4282
|
const entityFields = fieldsByEntityId.get(entityIdKey) || [];
|
|
3683
|
-
e.EntityFields = entityFields.sort((a, b) => a.Sequence - b.Sequence);
|
|
4283
|
+
e.EntityFields = entityFields.sort((a, b) => (a.Sequence - b.Sequence) || ordinalCompare(a.Name, b.Name) || ordinalCompare(a.ID, b.ID));
|
|
3684
4284
|
e.EntityPermissions = permissionsByEntityId.get(entityIdKey) || [];
|
|
3685
4285
|
e.EntityRelationships = relationshipsByEntityId.get(entityIdKey) || [];
|
|
3686
4286
|
e.EntitySettings = settingsByEntityId.get(entityIdKey) || [];
|
|
@@ -3867,13 +4467,18 @@ export class ProviderBase {
|
|
|
3867
4467
|
/**
|
|
3868
4468
|
* Checks if local metadata is out of date and needs refreshing.
|
|
3869
4469
|
* Compares local timestamps with server timestamps.
|
|
4470
|
+
* @param bypassMinCheckInterval - When true, skips the {@link MinRefreshCheckIntervalMs}
|
|
4471
|
+
* throttle. Event-driven callers pass true: they hold positive evidence that a metadata
|
|
4472
|
+
* member entity was just written, and the throttle otherwise answers "fresh" for any check
|
|
4473
|
+
* arriving within the window of the previous one — which would silently drop the second of
|
|
4474
|
+
* two permission changes made less than the window apart.
|
|
3870
4475
|
* @returns True if refresh is needed, false otherwise
|
|
3871
4476
|
*/
|
|
3872
|
-
async CheckToSeeIfRefreshNeeded(providerToUse) {
|
|
4477
|
+
async CheckToSeeIfRefreshNeeded(providerToUse, bypassMinCheckInterval) {
|
|
3873
4478
|
if (!this.AllowRefresh)
|
|
3874
4479
|
return false;
|
|
3875
4480
|
const now = Date.now();
|
|
3876
|
-
if ((now - this._lastRefreshCheckAt) < ProviderBase.MinRefreshCheckIntervalMs) {
|
|
4481
|
+
if (!bypassMinCheckInterval && (now - this._lastRefreshCheckAt) < ProviderBase.MinRefreshCheckIntervalMs) {
|
|
3877
4482
|
LogStatusEx({
|
|
3878
4483
|
message: `[RefreshCheck] Skipped — last check was ${now - this._lastRefreshCheckAt}ms ago (min interval ${ProviderBase.MinRefreshCheckIntervalMs}ms)`,
|
|
3879
4484
|
verboseOnly: true
|
|
@@ -3888,10 +4493,13 @@ export class ProviderBase {
|
|
|
3888
4493
|
/**
|
|
3889
4494
|
* Refreshes metadata only if needed based on timestamp comparison.
|
|
3890
4495
|
* Combines check and refresh into a single operation.
|
|
4496
|
+
* @param bypassMinCheckInterval - Passed through to {@link CheckToSeeIfRefreshNeeded};
|
|
4497
|
+
* event-driven callers set true so the throttle cannot eat a check they have positive
|
|
4498
|
+
* evidence for.
|
|
3891
4499
|
* @returns True if refresh was successful or not needed
|
|
3892
4500
|
*/
|
|
3893
|
-
async RefreshIfNeeded(providerToUse) {
|
|
3894
|
-
if (await this.CheckToSeeIfRefreshNeeded(providerToUse))
|
|
4501
|
+
async RefreshIfNeeded(providerToUse, bypassMinCheckInterval) {
|
|
4502
|
+
if (await this.CheckToSeeIfRefreshNeeded(providerToUse, bypassMinCheckInterval))
|
|
3895
4503
|
return this.Refresh(providerToUse);
|
|
3896
4504
|
else
|
|
3897
4505
|
return true;
|
|
@@ -3943,6 +4551,11 @@ export class ProviderBase {
|
|
|
3943
4551
|
// to a downstream `.constructor`/`.LoadFromData` crash.
|
|
3944
4552
|
throw new Error(`Entity '${entityName}' could not be instantiated — MJGlobal ClassFactory returned null. Ensure LoadGeneratedEntities()/LoadCoreEntities() has run so the entity's class is registered.`);
|
|
3945
4553
|
}
|
|
4554
|
+
// Always rebind. ClassFactory passes `(Entity, this)` into the constructor,
|
|
4555
|
+
// but a 1-arg subclass (`constructor(Entity) { super(Entity); }`) silently
|
|
4556
|
+
// drops the provider. Without this, ProviderToUse falls back to the global
|
|
4557
|
+
// host and a nested save on an independent instance deadlocks on FKs.
|
|
4558
|
+
newObject.BindProvider(this);
|
|
3946
4559
|
await newObject.Config(actualContextUser);
|
|
3947
4560
|
// Initialize IS-A parent entity composition chain before any data operations
|
|
3948
4561
|
await newObject.InitializeParentEntity();
|
|
@@ -4361,13 +4974,31 @@ export class ProviderBase {
|
|
|
4361
4974
|
const tsKey = this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey;
|
|
4362
4975
|
const fmtKey = this.LocalStoragePrefix + ProviderBase.localStorageFormatKey;
|
|
4363
4976
|
const dataKey = this.LocalStoragePrefix + ProviderBase.localStorageAllMetadataKey;
|
|
4977
|
+
const membershipKey = this.LocalStoragePrefix + ProviderBase.localStorageDatasetMembershipKey;
|
|
4364
4978
|
const readStart = Date.now();
|
|
4365
|
-
const all = await ls.GetItems([tsKey, fmtKey, dataKey]);
|
|
4979
|
+
const all = await ls.GetItems([tsKey, fmtKey, dataKey, membershipKey]);
|
|
4366
4980
|
const readMs = Date.now() - readStart;
|
|
4367
4981
|
const tsRaw = all.get(tsKey) ?? null;
|
|
4368
4982
|
const format = all.get(fmtKey) ?? null;
|
|
4369
4983
|
const raw = all.get(dataKey) ?? null;
|
|
4984
|
+
const membershipRaw = all.get(membershipKey) ?? null;
|
|
4370
4985
|
this._latestLocalMetadataTimestamps = tsRaw ? JSON.parse(tsRaw) : null;
|
|
4986
|
+
// Restore the metadata-member entity set so the event-driven refresh works on a warm
|
|
4987
|
+
// boot that never calls GetAllMetadata (the stale-while-revalidate fast start). The
|
|
4988
|
+
// set is only stored after a successful dataset load, so restoring it is exactly as
|
|
4989
|
+
// trustworthy as restoring the metadata itself.
|
|
4990
|
+
if (membershipRaw) {
|
|
4991
|
+
try {
|
|
4992
|
+
const names = JSON.parse(membershipRaw);
|
|
4993
|
+
if (Array.isArray(names) && names.length > 0) {
|
|
4994
|
+
this._metadataDatasetEntityNames = new Set(names);
|
|
4995
|
+
this.ensureInflightViewInvalidation();
|
|
4996
|
+
}
|
|
4997
|
+
}
|
|
4998
|
+
catch (membershipErr) {
|
|
4999
|
+
LogError(`[Metadata Cache] Failed to restore dataset membership: ${membershipErr instanceof Error ? membershipErr.message : String(membershipErr)}`);
|
|
5000
|
+
}
|
|
5001
|
+
}
|
|
4371
5002
|
if (!raw)
|
|
4372
5003
|
return;
|
|
4373
5004
|
// Decompress if stored in compressed format, otherwise parse directly
|
|
@@ -4413,10 +5044,12 @@ export class ProviderBase {
|
|
|
4413
5044
|
static { this.localStorageTimestampsKey = this.localStorageRootKey + '_Timestamps'; }
|
|
4414
5045
|
static { this.localStorageAllMetadataKey = this.localStorageRootKey + '_AllMetadata'; }
|
|
4415
5046
|
static { this.localStorageFormatKey = this.localStorageRootKey + '_Format'; }
|
|
5047
|
+
static { this.localStorageDatasetMembershipKey = this.localStorageRootKey + '_DatasetMembership'; }
|
|
4416
5048
|
static { this.localStorageKeys = [
|
|
4417
5049
|
ProviderBase.localStorageTimestampsKey,
|
|
4418
5050
|
ProviderBase.localStorageAllMetadataKey,
|
|
4419
5051
|
ProviderBase.localStorageFormatKey,
|
|
5052
|
+
ProviderBase.localStorageDatasetMembershipKey,
|
|
4420
5053
|
]; }
|
|
4421
5054
|
/**
|
|
4422
5055
|
* Converts a base64-encoded string to an ArrayBuffer.
|
|
@@ -4460,46 +5093,74 @@ export class ProviderBase {
|
|
|
4460
5093
|
if (!ls)
|
|
4461
5094
|
return;
|
|
4462
5095
|
const start = Date.now();
|
|
4463
|
-
//
|
|
4464
|
-
//
|
|
4465
|
-
//
|
|
4466
|
-
|
|
4467
|
-
//
|
|
5096
|
+
// Serialize the AllMetadata object FIRST. If this throws (or the payload write below
|
|
5097
|
+
// fails), nothing else has been written yet, so the previously stored timestamps still
|
|
5098
|
+
// describe the previously stored payload and LocalMetadataObsolete() will report
|
|
5099
|
+
// obsolete on the next boot — which is what makes the save retry. Writing timestamps
|
|
5100
|
+
// before the payload left the cache claiming freshness with no payload behind it, and
|
|
5101
|
+
// that state was never retried.
|
|
4468
5102
|
const jsonString = JSON.stringify(this._localMetadata);
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
const compressedStream = blob.stream().pipeThrough(cs);
|
|
4475
|
-
const compressedBuffer = await new Response(compressedStream).arrayBuffer();
|
|
4476
|
-
const base64 = ProviderBase.arrayBufferToBase64(compressedBuffer);
|
|
4477
|
-
await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageAllMetadataKey, base64);
|
|
4478
|
-
await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageFormatKey, 'gzip');
|
|
4479
|
-
const elapsed = Date.now() - start;
|
|
4480
|
-
const ratio = jsonString.length > 0 ? (base64.length / jsonString.length * 100).toFixed(1) : '?';
|
|
4481
|
-
LogStatusEx({
|
|
4482
|
-
message: `[Metadata Cache] Save complete: ${elapsed}ms, raw=${(jsonString.length / 1024 / 1024).toFixed(1)}MB, compressed=${(base64.length / 1024 / 1024).toFixed(1)}MB (${ratio}%)`,
|
|
4483
|
-
verboseOnly: true
|
|
4484
|
-
});
|
|
4485
|
-
return;
|
|
4486
|
-
}
|
|
4487
|
-
catch (compressErr) {
|
|
4488
|
-
// Compression failed — fall through to uncompressed save
|
|
4489
|
-
LogError(`[Metadata Cache] Compression failed, falling back to uncompressed: ${compressErr instanceof Error ? compressErr.message : String(compressErr)}`);
|
|
4490
|
-
}
|
|
5103
|
+
const snapshot = await this.writeMetadataSnapshot(ls, jsonString);
|
|
5104
|
+
// Persist the metadata-member entity set beside the snapshot it belongs to, so the
|
|
5105
|
+
// event-driven refresh survives a warm boot (see LoadLocalMetadataFromStorage).
|
|
5106
|
+
if (this._metadataDatasetEntityNames?.size) {
|
|
5107
|
+
await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageDatasetMembershipKey, JSON.stringify([...this._metadataDatasetEntityNames]));
|
|
4491
5108
|
}
|
|
4492
|
-
//
|
|
4493
|
-
|
|
4494
|
-
await ls.SetItem(this.LocalStoragePrefix + ProviderBase.
|
|
4495
|
-
|
|
5109
|
+
// Timestamps LAST: they are the freshness claim for everything written above, so they
|
|
5110
|
+
// must be the final thing to land.
|
|
5111
|
+
await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey, JSON.stringify(this._latestLocalMetadataTimestamps));
|
|
5112
|
+
this.logMetadataSaveComplete(Date.now() - start, jsonString.length, snapshot);
|
|
5113
|
+
}
|
|
5114
|
+
catch (e) {
|
|
5115
|
+
LogError(`[Metadata Cache] SaveLocalMetadataToStorage failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
5116
|
+
}
|
|
5117
|
+
}
|
|
5118
|
+
/**
|
|
5119
|
+
* Writes the serialized metadata payload and its format marker. Compressed (gzip+base64) when
|
|
5120
|
+
* CompressionStream is available, otherwise plain JSON. The snapshot path intentionally uses
|
|
5121
|
+
* string storage so the compressed form round-trips cleanly through providers that don't
|
|
5122
|
+
* support binary natively. A compression failure falls back to the uncompressed write; a
|
|
5123
|
+
* failure of the write itself propagates to the caller so nothing after it is stored.
|
|
5124
|
+
*/
|
|
5125
|
+
async writeMetadataSnapshot(ls, jsonString) {
|
|
5126
|
+
const dataKey = this.LocalStoragePrefix + ProviderBase.localStorageAllMetadataKey;
|
|
5127
|
+
const formatKey = this.LocalStoragePrefix + ProviderBase.localStorageFormatKey;
|
|
5128
|
+
// Attempt compressed storage using native CompressionStream (available in modern browsers and Node 18+)
|
|
5129
|
+
if (typeof CompressionStream !== 'undefined') {
|
|
5130
|
+
try {
|
|
5131
|
+
const blob = new Blob([jsonString]);
|
|
5132
|
+
const cs = new CompressionStream('gzip');
|
|
5133
|
+
const compressedStream = blob.stream().pipeThrough(cs);
|
|
5134
|
+
const compressedBuffer = await new Response(compressedStream).arrayBuffer();
|
|
5135
|
+
const base64 = ProviderBase.arrayBufferToBase64(compressedBuffer);
|
|
5136
|
+
await ls.SetItem(dataKey, base64);
|
|
5137
|
+
await ls.SetItem(formatKey, 'gzip');
|
|
5138
|
+
return { compressed: true, storedLength: base64.length };
|
|
5139
|
+
}
|
|
5140
|
+
catch (compressErr) {
|
|
5141
|
+
// Compression failed — fall through to uncompressed save
|
|
5142
|
+
LogError(`[Metadata Cache] Compression failed, falling back to uncompressed: ${compressErr instanceof Error ? compressErr.message : String(compressErr)}`);
|
|
5143
|
+
}
|
|
5144
|
+
}
|
|
5145
|
+
// Fallback: uncompressed save (older environments without CompressionStream)
|
|
5146
|
+
await ls.SetItem(dataKey, jsonString);
|
|
5147
|
+
await ls.SetItem(formatKey, 'json');
|
|
5148
|
+
return { compressed: false, storedLength: jsonString.length };
|
|
5149
|
+
}
|
|
5150
|
+
logMetadataSaveComplete(elapsedMs, rawLength, snapshot) {
|
|
5151
|
+
const mb = (n) => (n / 1024 / 1024).toFixed(1);
|
|
5152
|
+
if (snapshot.compressed) {
|
|
5153
|
+
const ratio = rawLength > 0 ? (snapshot.storedLength / rawLength * 100).toFixed(1) : '?';
|
|
4496
5154
|
LogStatusEx({
|
|
4497
|
-
message: `[Metadata Cache] Save complete
|
|
5155
|
+
message: `[Metadata Cache] Save complete: ${elapsedMs}ms, raw=${mb(rawLength)}MB, compressed=${mb(snapshot.storedLength)}MB (${ratio}%)`,
|
|
4498
5156
|
verboseOnly: true
|
|
4499
5157
|
});
|
|
4500
5158
|
}
|
|
4501
|
-
|
|
4502
|
-
|
|
5159
|
+
else {
|
|
5160
|
+
LogStatusEx({
|
|
5161
|
+
message: `[Metadata Cache] Save complete (uncompressed): ${elapsedMs}ms, size=${mb(rawLength)}MB`,
|
|
5162
|
+
verboseOnly: true
|
|
5163
|
+
});
|
|
4503
5164
|
}
|
|
4504
5165
|
}
|
|
4505
5166
|
/**
|