@memberjunction/core 6.2.0-edge.4 → 6.2.0-edge.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generic/BaseEntitySaveQueue.d.ts +22 -0
- package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
- package/dist/generic/BaseEntitySaveQueue.js +43 -4
- package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +18 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
- package/dist/generic/alternateBaseViews.d.ts +119 -0
- package/dist/generic/alternateBaseViews.d.ts.map +1 -0
- package/dist/generic/alternateBaseViews.js +223 -0
- package/dist/generic/alternateBaseViews.js.map +1 -0
- package/dist/generic/baseEngine.d.ts +82 -14
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +311 -46
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
- package/dist/generic/baseEngineRegistry.js +2 -1
- package/dist/generic/baseEngineRegistry.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +194 -14
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +638 -218
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/baseInfo.d.ts +31 -3
- package/dist/generic/baseInfo.d.ts.map +1 -1
- package/dist/generic/baseInfo.js +138 -40
- package/dist/generic/baseInfo.js.map +1 -1
- package/dist/generic/contextTokens.d.ts +136 -0
- package/dist/generic/contextTokens.d.ts.map +1 -0
- package/dist/generic/contextTokens.js +319 -0
- package/dist/generic/contextTokens.js.map +1 -0
- package/dist/generic/databaseProviderBase.d.ts +103 -13
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +321 -77
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/embeddedRecord.d.ts +6 -0
- package/dist/generic/embeddedRecord.d.ts.map +1 -1
- package/dist/generic/embeddedRecord.js +15 -1
- package/dist/generic/embeddedRecord.js.map +1 -1
- package/dist/generic/entityCompanion.d.ts +7 -0
- package/dist/generic/entityCompanion.d.ts.map +1 -1
- package/dist/generic/entityCompanion.js +8 -0
- package/dist/generic/entityCompanion.js.map +1 -1
- package/dist/generic/entityConfiguration.d.ts +8 -2
- package/dist/generic/entityConfiguration.d.ts.map +1 -1
- package/dist/generic/entityConfiguration.js +9 -0
- package/dist/generic/entityConfiguration.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +306 -12
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +894 -92
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/entityValueFieldSecurity.d.ts +139 -0
- package/dist/generic/entityValueFieldSecurity.d.ts.map +1 -0
- package/dist/generic/entityValueFieldSecurity.js +250 -0
- package/dist/generic/entityValueFieldSecurity.js.map +1 -0
- package/dist/generic/fieldSecuritySQLGate.d.ts +350 -0
- package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -0
- package/dist/generic/fieldSecuritySQLGate.js +898 -0
- package/dist/generic/fieldSecuritySQLGate.js.map +1 -0
- package/dist/generic/indexRestrictedFields.d.ts +47 -0
- package/dist/generic/indexRestrictedFields.d.ts.map +1 -0
- package/dist/generic/indexRestrictedFields.js +129 -0
- package/dist/generic/indexRestrictedFields.js.map +1 -0
- package/dist/generic/infoProviderResolution.d.ts +16 -0
- package/dist/generic/infoProviderResolution.d.ts.map +1 -0
- package/dist/generic/infoProviderResolution.js +17 -0
- package/dist/generic/infoProviderResolution.js.map +1 -0
- package/dist/generic/interfaces.d.ts +39 -2
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +318 -51
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +675 -109
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/logging.d.ts.map +1 -1
- package/dist/generic/logging.js +7 -1
- package/dist/generic/logging.js.map +1 -1
- package/dist/generic/metadata.d.ts +3 -2
- package/dist/generic/metadata.d.ts.map +1 -1
- package/dist/generic/metadata.js +5 -2
- package/dist/generic/metadata.js.map +1 -1
- package/dist/generic/mjapiUrl.d.ts +21 -0
- package/dist/generic/mjapiUrl.d.ts.map +1 -0
- package/dist/generic/mjapiUrl.js +25 -0
- package/dist/generic/mjapiUrl.js.map +1 -0
- package/dist/generic/platformSQL.d.ts +54 -0
- package/dist/generic/platformSQL.d.ts.map +1 -1
- package/dist/generic/platformSQL.js +55 -0
- package/dist/generic/platformSQL.js.map +1 -1
- package/dist/generic/providerBase.d.ts +370 -57
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +1085 -284
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/queryInfo.d.ts +23 -9
- package/dist/generic/queryInfo.d.ts.map +1 -1
- package/dist/generic/queryInfo.js +73 -33
- package/dist/generic/queryInfo.js.map +1 -1
- package/dist/generic/queryInfoInterfaces.d.ts +8 -0
- package/dist/generic/queryInfoInterfaces.d.ts.map +1 -1
- package/dist/generic/queryTemplateContext.d.ts +49 -0
- package/dist/generic/queryTemplateContext.d.ts.map +1 -0
- package/dist/generic/queryTemplateContext.js +113 -0
- package/dist/generic/queryTemplateContext.js.map +1 -0
- package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -1
- package/dist/generic/recordChangeFieldSecurity.js +13 -2
- package/dist/generic/recordChangeFieldSecurity.js.map +1 -1
- package/dist/generic/relatedRecordCollection.d.ts +13 -0
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
- package/dist/generic/relatedRecordCollection.js +44 -5
- package/dist/generic/relatedRecordCollection.js.map +1 -1
- package/dist/generic/runQuery.d.ts +42 -0
- package/dist/generic/runQuery.d.ts.map +1 -1
- package/dist/generic/runQuery.js +14 -0
- package/dist/generic/runQuery.js.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.js +38 -2
- package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
- package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
- package/dist/generic/saveEntityGraphOperation.js +3 -0
- package/dist/generic/saveEntityGraphOperation.js.map +1 -1
- package/dist/generic/scoring/ReciprocalRankFusion.d.ts.map +1 -1
- package/dist/generic/scoring/ReciprocalRankFusion.js +10 -3
- package/dist/generic/scoring/ReciprocalRankFusion.js.map +1 -1
- package/dist/generic/securityInfo.d.ts +17 -6
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +25 -10
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/generic/telemetryManager.d.ts +9 -0
- package/dist/generic/telemetryManager.d.ts.map +1 -1
- package/dist/generic/telemetryManager.js +5 -1
- package/dist/generic/telemetryManager.js.map +1 -1
- package/dist/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +3 -0
- package/dist/generic/util.js.map +1 -1
- package/dist/generic/wellKnownUserSource.d.ts +3 -2
- package/dist/generic/wellKnownUserSource.d.ts.map +1 -1
- package/dist/generic/wellKnownUserSource.js +18 -9
- package/dist/generic/wellKnownUserSource.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +89 -0
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +36 -0
- package/dist/views/runView.js.map +1 -1
- package/dist/views/viewProperties.d.ts +55 -0
- package/dist/views/viewProperties.d.ts.map +1 -0
- package/dist/views/viewProperties.js +258 -0
- package/dist/views/viewProperties.js.map +1 -0
- package/package.json +3 -3
- package/readme.md +29 -4
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { BaseEntity } from "./baseEntity.js";
|
|
2
2
|
import { EntityDocumentTypeInfo, EntityFieldTSType, EntityInfo, EntityPermissionType, FieldSecurityDenialMessage, FieldSecurityError } from "./entityInfo.js";
|
|
3
|
-
import {
|
|
3
|
+
import { GetEffectiveAlternateBaseViewName, IsExplicitBaseViewOverride } from "./alternateBaseViews.js";
|
|
4
|
+
import { ProviderType, AllMetadata } from "./interfaces.js";
|
|
4
5
|
import { RecordChangeFieldSecurityProjector } from "./recordChangeFieldSecurity.js";
|
|
5
6
|
import { ComputeRRF } from "./scoring/ReciprocalRankFusion.js";
|
|
6
7
|
import { LocalCacheManager, CacheCategory } from "./localCacheManager.js";
|
|
7
8
|
import { ApplicationInfo } from "../generic/applicationInfo.js";
|
|
8
9
|
import { AuditLogTypeInfo, AuthorizationInfo, AuthorizationRoleInfo, RoleInfo, RowLevelSecurityFilterInfo, UserInfo } from "./securityInfo.js";
|
|
9
|
-
import { MJGlobal, MJEventType, NormalizeUUID, UUIDsEqual, EscapeSQLString, ordinalCompare } from "@memberjunction/global";
|
|
10
|
-
import {
|
|
10
|
+
import { MJGlobal, MJEventType, NormalizeUUID, UUIDsEqual, IndexByUUID, EscapeSQLString, ordinalCompare } from "@memberjunction/global";
|
|
11
|
+
import { FieldSecuritySQLGate } from "./fieldSecuritySQLGate.js";
|
|
11
12
|
import { TelemetryManager } from "./telemetryManager.js";
|
|
12
|
-
import { LogDebug, LogError, LogStatus, LogStatusEx } from "./logging.js";
|
|
13
|
+
import { LogDebug, LogError, LogStatus, LogStatusEx, LogWarning } from "./logging.js";
|
|
13
14
|
import { QueryCategoryInfo, QueryFieldInfo, QueryInfo, QueryPermissionInfo, QueryEntityInfo, QueryParameterInfo, QueryDependencyInfo, SQLDialectInfo, QuerySQLInfo } from "./queryInfo.js";
|
|
14
15
|
import { LibraryInfo } from "./libraryInfo.js";
|
|
15
16
|
import { CompositeKey } from "./compositeKey.js";
|
|
@@ -20,6 +21,8 @@ import { IsPlatformSQL } from "./platformSQL.js";
|
|
|
20
21
|
import { GetDataHooks } from "./dataHooks.js";
|
|
21
22
|
import { TransformSimpleObjectToEntityObject } from "./util.js";
|
|
22
23
|
import { LoadRelatedRecordsBatched } from "./relatedRecordBatchLoader.js";
|
|
24
|
+
import { GetLocalTimeZone } from "./contextTokens.js";
|
|
25
|
+
import { FindQueryContextTokens, QueryContextCacheValues } from "./queryTemplateContext.js";
|
|
23
26
|
/**
|
|
24
27
|
* Creates a new instance of AllMetadata from a simple object.
|
|
25
28
|
* Handles deserialization and proper instantiation of all metadata classes.
|
|
@@ -248,6 +251,14 @@ export class ProviderBase {
|
|
|
248
251
|
* so a push can see the metadata rows it has not committed yet (MJ#4836).
|
|
249
252
|
*/
|
|
250
253
|
this._metadataReadsJoinTransaction = false;
|
|
254
|
+
/**
|
|
255
|
+
* The MJ_Metadata dataset items (code + entity name + row count) as of the last load. The remote
|
|
256
|
+
* timestamps ({@link GetLatestMetadataUpdates}) are keyed by entity name while a dataset
|
|
257
|
+
* read is scoped by item code, so this is the bridge that lets a staleness check be turned
|
|
258
|
+
* into a scoped reload (see {@link PartialRefreshItemCodes}) and lets {@link adoptServerMetadata}
|
|
259
|
+
* verify that the received dataset items' row counts match `_latestRemoteMetadataTimestamps`.
|
|
260
|
+
*/
|
|
261
|
+
this._metadataDatasetItems = null;
|
|
251
262
|
/** Debounce timer for {@link scheduleMetadataMemberRefresh}. */
|
|
252
263
|
this._metadataMemberRefreshTimer = null;
|
|
253
264
|
// ── Metadata change notices from other servers ─────────────────────
|
|
@@ -259,6 +270,7 @@ export class ProviderBase {
|
|
|
259
270
|
this._peerMetadataNoticeTimer = null;
|
|
260
271
|
this._metadataMemberRefreshWaits = 0;
|
|
261
272
|
this._clientFingerprintMemo = new WeakMap();
|
|
273
|
+
this._requestDeniedReadFields = new WeakMap();
|
|
262
274
|
this._cachedVisibleExplorerNavigationItems = null;
|
|
263
275
|
/** Guard for {@link logMetadataSnapshotDisabledOnce}. */
|
|
264
276
|
this._metadataSnapshotSkipLogged = false;
|
|
@@ -439,6 +451,34 @@ export class ProviderBase {
|
|
|
439
451
|
this._lingerInvalidationWired = true;
|
|
440
452
|
ProviderBase._invalidationTargets.add(new WeakRef(this));
|
|
441
453
|
}
|
|
454
|
+
/**
|
|
455
|
+
* Drops this provider's in-flight/lingered RunView dedup entries so the next identical
|
|
456
|
+
* RunView goes to the server instead of replaying a result from the last
|
|
457
|
+
* {@link DedupLingerMs}.
|
|
458
|
+
*
|
|
459
|
+
* Saves and deletes made through a `BaseEntity` in this process already invalidate the entries
|
|
460
|
+
* for their entity automatically. Call this after anything that can write on the SERVER
|
|
461
|
+
* without such an event reaching this process — a Remote Operation, an Action, an agent run,
|
|
462
|
+
* any other mutation. {@link RouteOperation} and the GraphQL client's mutation transport call
|
|
463
|
+
* it for you.
|
|
464
|
+
*
|
|
465
|
+
* Dropping an in-flight entry never affects the callers already waiting on it; it only stops
|
|
466
|
+
* later callers from joining (and stops the result from lingering), since that query may have
|
|
467
|
+
* read pre-write data.
|
|
468
|
+
*
|
|
469
|
+
* @param entityNames - entities known to have been written. Only entries touching them are
|
|
470
|
+
* dropped. Omit (or pass an empty list) when the writes are unknown: every entry is dropped.
|
|
471
|
+
*/
|
|
472
|
+
InvalidateRunViewDedupCache(entityNames) {
|
|
473
|
+
const names = (entityNames ?? []).map(n => n?.trim().toLowerCase()).filter((n) => !!n);
|
|
474
|
+
if (names.length === 0) {
|
|
475
|
+
this._inflightViews.clear();
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
for (const n of names) {
|
|
479
|
+
this.invalidateInflightViewsForEntity(n);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
442
482
|
/**
|
|
443
483
|
* Drops every in-flight/lingered RunView entry whose params touch the given
|
|
444
484
|
* entity (lowercased name). Called on BaseEntity save/delete/remote-invalidate.
|
|
@@ -476,6 +516,31 @@ export class ProviderBase {
|
|
|
476
516
|
* timer, so the refresh runs no earlier than this long after the LAST write.
|
|
477
517
|
*/
|
|
478
518
|
static { this.MetadataDatasetRefreshDebounceMs = 500; }
|
|
519
|
+
/**
|
|
520
|
+
* MJ_Metadata item codes that may be reloaded on their own, without rebuilding the rest of
|
|
521
|
+
* the metadata graph: the saved-query family. Those rows are written continuously by agents
|
|
522
|
+
* (a query is persisted per analysis step), and every write moves the timestamps of these
|
|
523
|
+
* items. Before this list existed, each such write cost a FULL reload — every EntityInfo and
|
|
524
|
+
* EntityFieldInfo rebuilt (hundreds of MB on a large schema) to pick up a few QueryInfo rows;
|
|
525
|
+
* on a busy server that was a fresh half-gigabyte graph per refresh window until the process
|
|
526
|
+
* hit its memory guard.
|
|
527
|
+
*
|
|
528
|
+
* Membership rule: an item qualifies only when no OTHER metadata class embeds or memoizes its
|
|
529
|
+
* rows at construction. The query classes reference each other lazily through the provider
|
|
530
|
+
* (`QueryInfo.Fields` filters `QueryFields` on first use), so they can be swapped as a set;
|
|
531
|
+
* entity metadata cannot (EntityInfo owns its fields, permissions and relationships).
|
|
532
|
+
* The family is always reloaded together — see {@link PartialRefreshItemCodes}.
|
|
533
|
+
* Intersected with {@link AllMetadataArrays} so the list is valid for whatever this build knows.
|
|
534
|
+
*/
|
|
535
|
+
static get PartialRefreshMetadataItemCodes() {
|
|
536
|
+
const known = new Set(AllMetadataArrays.map(m => m.key.substring(3))); // 'AllQueries' → 'Queries'
|
|
537
|
+
return ProviderBase._queryFamilyItemCodes.filter(code => known.has(code));
|
|
538
|
+
}
|
|
539
|
+
static { this._queryFamilyItemCodes = [
|
|
540
|
+
'QueryCategories', 'Queries', 'QueryFields', 'QueryPermissions', 'QueryEntities', 'QueryParameters', 'QueryDependencies', 'QuerySQLs',
|
|
541
|
+
]; }
|
|
542
|
+
/** The synthetic roll-up row {@link GetLatestMetadataUpdates} appends to the per-entity timestamps. */
|
|
543
|
+
static { this._allMetadataTypeName = 'All Entity Metadata'; }
|
|
479
544
|
/**
|
|
480
545
|
* Records which entities compose this provider's metadata, from the loaded MJ_Metadata
|
|
481
546
|
* dataset result, and registers this instance with the static event fan-out so writes to any
|
|
@@ -494,6 +559,15 @@ export class ProviderBase {
|
|
|
494
559
|
return; // a failed/empty load must not erase a previously recorded set
|
|
495
560
|
}
|
|
496
561
|
this._metadataDatasetEntityNames = names;
|
|
562
|
+
this._metadataDatasetItems = (dataset.Results ?? [])
|
|
563
|
+
.filter(item => item.Code && item.EntityName)
|
|
564
|
+
.map(item => ({
|
|
565
|
+
Code: item.Code,
|
|
566
|
+
EntityName: item.EntityName,
|
|
567
|
+
EntityID: item.EntityID,
|
|
568
|
+
RowCount: Array.isArray(item.Results) ? item.Results.length : undefined,
|
|
569
|
+
LatestUpdateDate: item.LatestUpdateDate
|
|
570
|
+
}));
|
|
497
571
|
this.ensureInflightViewInvalidation();
|
|
498
572
|
}
|
|
499
573
|
/**
|
|
@@ -807,6 +881,21 @@ export class ProviderBase {
|
|
|
807
881
|
return true;
|
|
808
882
|
}
|
|
809
883
|
this._metadataMemberRefreshWaits = 0;
|
|
884
|
+
// The writer holds positive evidence, so the timestamps are re-read from the authoritative
|
|
885
|
+
// status query. When they attribute the change entirely to the saved-query family (see
|
|
886
|
+
// PartialRefreshMetadataItemCodes) only that family is reloaded, cache layers bypassed as a
|
|
887
|
+
// hard refresh would. Anything that cannot be attributed — another item moved, the status
|
|
888
|
+
// read failed, or it claims nothing changed — keeps the base policy: a HARD Refresh().
|
|
889
|
+
let scoped = false;
|
|
890
|
+
try {
|
|
891
|
+
scoped = this.AllowRefresh && await this.RefreshRemoteMetadataTimestamps() && this.PartialRefreshItemCodes() !== null;
|
|
892
|
+
}
|
|
893
|
+
catch (e) {
|
|
894
|
+
LogError(`Metadata refresh after a member-entity change could not read the timestamps; running a full refresh: ${e instanceof Error ? e.message : String(e)}`);
|
|
895
|
+
}
|
|
896
|
+
if (scoped) {
|
|
897
|
+
return this.ReloadMetadata(true, undefined, true);
|
|
898
|
+
}
|
|
810
899
|
return this.Refresh();
|
|
811
900
|
}
|
|
812
901
|
/**
|
|
@@ -934,7 +1023,16 @@ export class ProviderBase {
|
|
|
934
1023
|
if (!key) {
|
|
935
1024
|
return { Success: false, ResultCode: 'INVALID_OPERATION_KEY', ErrorMessage: 'operationKey is required' };
|
|
936
1025
|
}
|
|
937
|
-
|
|
1026
|
+
try {
|
|
1027
|
+
return await this.InternalRouteOperation(key, input, options ?? {});
|
|
1028
|
+
}
|
|
1029
|
+
finally {
|
|
1030
|
+
// A Remote Operation executes server-side and can write any entity — none of which
|
|
1031
|
+
// raises a BaseEntity event in THIS process. Without this, an identical RunView answered
|
|
1032
|
+
// in the last DedupLingerMs would replay pre-operation rows. Runs on failure too: an
|
|
1033
|
+
// operation that failed part-way may still have written.
|
|
1034
|
+
this.InvalidateRunViewDedupCache();
|
|
1035
|
+
}
|
|
938
1036
|
}
|
|
939
1037
|
/**
|
|
940
1038
|
* Provider-specific transport for a Remote Operation. The default implementation reports that
|
|
@@ -1032,7 +1130,10 @@ export class ProviderBase {
|
|
|
1032
1130
|
return this.executeRunViewsPipeline(params, contextUser);
|
|
1033
1131
|
}
|
|
1034
1132
|
// ── Coalescing: merge concurrent RunViews into one mega-batch ──
|
|
1035
|
-
|
|
1133
|
+
// A run carrying view properties is never merged: a merged batch with any CacheLocal member
|
|
1134
|
+
// goes through the cache-check transport, which carries dynamic runs only and would drop the
|
|
1135
|
+
// view and its properties.
|
|
1136
|
+
if (ProviderBase.CoalesceWindowMs > 0 && !this.TrustLocalCacheCompletely && !params.some(ProviderBase.carriesViewProperties)) {
|
|
1036
1137
|
// Only coalesce on the client side where cache validation round-trips are expensive.
|
|
1037
1138
|
// Server-side trusts its cache (no network calls), so coalescing adds no benefit.
|
|
1038
1139
|
return this.enqueueCoalescedRunViews(params, contextUser);
|
|
@@ -1110,6 +1211,35 @@ export class ProviderBase {
|
|
|
1110
1211
|
async executeRunViewsPipeline(params, contextUser) {
|
|
1111
1212
|
// Pre-processing for batch
|
|
1112
1213
|
const preResult = await this.PreRunViews(params, contextUser);
|
|
1214
|
+
const refused = preResult.refusedResults;
|
|
1215
|
+
if (!refused) {
|
|
1216
|
+
return this.runAdmittedViews(params, preResult, contextUser);
|
|
1217
|
+
}
|
|
1218
|
+
// Every later stage works on the admitted views only — the same subset, in the same
|
|
1219
|
+
// order, that PreRunViews built its cache maps and smart-cache-check params from.
|
|
1220
|
+
const admitted = params.filter((_, i) => !refused.has(i));
|
|
1221
|
+
const admittedResults = await this.runAdmittedViews(admitted, preResult, contextUser);
|
|
1222
|
+
return ProviderBase.interleaveRefusedResults(admittedResults, refused, params.length);
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Puts each refused view's result back at its own index of the caller's batch, with the
|
|
1226
|
+
* admitted views' results filling the remaining indexes in order.
|
|
1227
|
+
*/
|
|
1228
|
+
static interleaveRefusedResults(admittedResults, refused, batchSize) {
|
|
1229
|
+
const merged = [];
|
|
1230
|
+
let next = 0;
|
|
1231
|
+
for (let i = 0; i < batchSize; i++) {
|
|
1232
|
+
const refusal = refused.get(i);
|
|
1233
|
+
merged.push(refusal ? refusal : admittedResults[next++]);
|
|
1234
|
+
}
|
|
1235
|
+
return merged;
|
|
1236
|
+
}
|
|
1237
|
+
/**
|
|
1238
|
+
* The cache, database and post-processing stages of a RunViews batch, for the views the
|
|
1239
|
+
* field-security predicate gate admitted. `params` and every index map in `preResult`
|
|
1240
|
+
* describe the same admitted views.
|
|
1241
|
+
*/
|
|
1242
|
+
async runAdmittedViews(params, preResult, contextUser) {
|
|
1113
1243
|
// Check for smart cache check mode
|
|
1114
1244
|
if (preResult.useSmartCacheCheck && preResult.smartCacheCheckParams) {
|
|
1115
1245
|
return this.executeSmartCacheCheck(params, preResult, contextUser);
|
|
@@ -1398,7 +1528,12 @@ export class ProviderBase {
|
|
|
1398
1528
|
return results;
|
|
1399
1529
|
})
|
|
1400
1530
|
.catch(err => {
|
|
1401
|
-
|
|
1531
|
+
// Only our own entry: an invalidation may have dropped it and a newer request for the
|
|
1532
|
+
// same key taken the slot, which this rejection must not evict.
|
|
1533
|
+
const entry = this._inflightViews.get(key);
|
|
1534
|
+
if (entry && entry.promise === promise) {
|
|
1535
|
+
this._inflightViews.delete(key);
|
|
1536
|
+
}
|
|
1402
1537
|
throw err;
|
|
1403
1538
|
});
|
|
1404
1539
|
this.ensureInflightViewInvalidation();
|
|
@@ -1442,14 +1577,58 @@ export class ProviderBase {
|
|
|
1442
1577
|
* - entities where server caching is disallowed
|
|
1443
1578
|
*/
|
|
1444
1579
|
runViewCacheEligible(param) {
|
|
1580
|
+
// In the browser a run carrying view properties is never cached: the server works out values
|
|
1581
|
+
// the browser cannot see (token defaults, server-set values), so a browser slot could go stale
|
|
1582
|
+
// without any row changing. The server caches it, keyed on the resolved values.
|
|
1583
|
+
if (!this.TrustLocalCacheCompletely && ProviderBase.carriesViewProperties(param)) {
|
|
1584
|
+
return false;
|
|
1585
|
+
}
|
|
1445
1586
|
return !param.BypassCache &&
|
|
1446
1587
|
!param.AfterKey &&
|
|
1447
1588
|
!IsMaterializedDataSource(param.DataSource) &&
|
|
1589
|
+
!this.isAlternateBaseViewCacheBypassed(param) &&
|
|
1448
1590
|
param.ResultType !== 'count_only' &&
|
|
1449
1591
|
(param.CacheLocal === true || this.TrustLocalCacheCompletely) &&
|
|
1450
1592
|
this.IsServerCacheAllowedForEntity(param);
|
|
1451
|
-
// Deliberately not user-dependent:
|
|
1452
|
-
//
|
|
1593
|
+
// Deliberately not user-dependent: field security changes which slot a request uses
|
|
1594
|
+
// (the fls: segment), never whether it is cacheable.
|
|
1595
|
+
}
|
|
1596
|
+
/**
|
|
1597
|
+
* True when the request targets an `AlternateBaseView` (explicitly on `param.AlternateBaseView`
|
|
1598
|
+
* or pinned on `param.ViewEntity`) whose `CachePolicy` (or an ancestor's `CachePolicy` in its
|
|
1599
|
+
* `ParentID` chain) is `'Bypass'` (`IsBypassCache === true`), when the requested alternate
|
|
1600
|
+
* base view does not resolve to a usable view on the entity, or when a saved User View is
|
|
1601
|
+
* referenced only by `ViewID`/`ViewName` without a loaded `ViewEntity` (fail-closed: we cannot
|
|
1602
|
+
* synchronously inspect `DisplayState.alternateBaseView` until the view is loaded).
|
|
1603
|
+
*/
|
|
1604
|
+
isAlternateBaseViewCacheBypassed(param) {
|
|
1605
|
+
if (IsExplicitBaseViewOverride(param)) {
|
|
1606
|
+
return false;
|
|
1607
|
+
}
|
|
1608
|
+
const effectiveAbv = GetEffectiveAlternateBaseViewName(param);
|
|
1609
|
+
if (!effectiveAbv) {
|
|
1610
|
+
if (!param.ViewEntity && Boolean(param.ViewID || param.ViewName)) {
|
|
1611
|
+
const knownEntity = this.ResolveRunViewEntitySync(param);
|
|
1612
|
+
if (knownEntity) {
|
|
1613
|
+
return knownEntity.AlternateBaseViews.some(v => v.IsBypassCache || !v.IsUsable);
|
|
1614
|
+
}
|
|
1615
|
+
return this.Entities.some(e => e.AlternateBaseViews.some(v => v.IsBypassCache || !v.IsUsable));
|
|
1616
|
+
}
|
|
1617
|
+
return false;
|
|
1618
|
+
}
|
|
1619
|
+
const entity = this.ResolveRunViewEntitySync(param);
|
|
1620
|
+
if (!entity) {
|
|
1621
|
+
return true; // Fail closed when entity cannot be resolved synchronously
|
|
1622
|
+
}
|
|
1623
|
+
const abv = entity.GetAlternateBaseView(effectiveAbv);
|
|
1624
|
+
if (!abv || !abv.IsUsable) {
|
|
1625
|
+
return true;
|
|
1626
|
+
}
|
|
1627
|
+
return abv.IsBypassCache;
|
|
1628
|
+
}
|
|
1629
|
+
/** True when a run carries view properties (even an empty map): the browser sends it to the server as it is. */
|
|
1630
|
+
static carriesViewProperties(param) {
|
|
1631
|
+
return param.Properties !== undefined || param.ServerProperties !== undefined;
|
|
1453
1632
|
}
|
|
1454
1633
|
/** True when the entity is a CodeGen materialized-query wrapper (materialized_vw*) whose snapshot is
|
|
1455
1634
|
* refreshed out-of-band — the same one IsServerCacheAllowedForEntity excludes from the server cache. */
|
|
@@ -1477,6 +1656,7 @@ export class ProviderBase {
|
|
|
1477
1656
|
return !param.BypassCache &&
|
|
1478
1657
|
!param.AfterKey &&
|
|
1479
1658
|
!IsMaterializedDataSource(param.DataSource) &&
|
|
1659
|
+
!this.isAlternateBaseViewCacheBypassed(param) &&
|
|
1480
1660
|
param.ResultType !== 'count_only' &&
|
|
1481
1661
|
param.CacheLocal === true &&
|
|
1482
1662
|
!this.isMaterializedWrapperEntity(param);
|
|
@@ -1545,6 +1725,8 @@ export class ProviderBase {
|
|
|
1545
1725
|
p.UserSearchString ?? '',
|
|
1546
1726
|
p.ViewID ?? '',
|
|
1547
1727
|
p.ViewName ?? '',
|
|
1728
|
+
IsExplicitBaseViewOverride(p) ? 'abv_override:base' : '',
|
|
1729
|
+
p.TimeZone ?? '',
|
|
1548
1730
|
contextUser?.ID ?? ''
|
|
1549
1731
|
].join('|');
|
|
1550
1732
|
return `${base}|${extras}`;
|
|
@@ -1606,15 +1788,15 @@ export class ProviderBase {
|
|
|
1606
1788
|
if (memoized) {
|
|
1607
1789
|
return memoized;
|
|
1608
1790
|
}
|
|
1609
|
-
// The signed-in user's row filter (rls:) and
|
|
1610
|
-
//
|
|
1791
|
+
// The signed-in user's row filter (rls:) and field-security key (fls:, see
|
|
1792
|
+
// ComputeRunViewFLSFingerprintKey) are part of client slot identity, as they are on the server.
|
|
1611
1793
|
// The browser cache outlives a session — one IndexedDB store per server URL — and the
|
|
1612
1794
|
// currency check compares only maxUpdatedAt and rowCount, which cannot tell one user's
|
|
1613
1795
|
// rows from another's, nor notice a column. Without these segments, a different user on
|
|
1614
1796
|
// the same browser could be served the previous user's rows, and a user whose field
|
|
1615
1797
|
// access is tightened would keep seeing a column just taken away. Both are empty for an
|
|
1616
1798
|
// unrestricted user, whose fingerprints are unchanged.
|
|
1617
|
-
const base = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString, this.ComputeRunViewRLSWhereClause(param), undefined, this.
|
|
1799
|
+
const base = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString, this.ComputeRunViewRLSWhereClause(param), undefined, this.ComputeRunViewFLSFingerprintKey(param));
|
|
1618
1800
|
// Normalize a FULL-COVERAGE field list to '*' — in the FINGERPRINT only (B44).
|
|
1619
1801
|
//
|
|
1620
1802
|
// entity_object params get widened to an explicit list of every entity field
|
|
@@ -1626,14 +1808,17 @@ export class ProviderBase {
|
|
|
1626
1808
|
// This touches ONLY how the slot is keyed. param.Fields is left untouched, so what the
|
|
1627
1809
|
// provider FETCHES is unchanged — an earlier attempt cleared param.Fields instead and was
|
|
1628
1810
|
// reverted precisely because fetch behavior could not be verified.
|
|
1629
|
-
const fieldsKey =
|
|
1811
|
+
const fieldsKey = (param.ResultType === 'entity_object' || this.isFullCoverageFieldList(param))
|
|
1812
|
+
? '*'
|
|
1813
|
+
: ProviderBase.normalizeFieldsKey(param.Fields);
|
|
1630
1814
|
const fingerprint = `${base}|f:${fieldsKey}`;
|
|
1631
1815
|
this._clientFingerprintMemo.set(param, fingerprint);
|
|
1632
1816
|
return fingerprint;
|
|
1633
1817
|
}
|
|
1634
1818
|
/**
|
|
1635
|
-
* True when `param.Fields` names EVERY field of the entity — i.e. an explicit list
|
|
1636
|
-
* semantically "full width"
|
|
1819
|
+
* True when `param.Fields` names EVERY fetchable field of the entity — i.e. an explicit list
|
|
1820
|
+
* that is semantically "full width" (accounting for binary fields excluded when
|
|
1821
|
+
* `IncludeBinaryFields !== true`). Case-insensitive; order-independent. Returns false on any
|
|
1637
1822
|
* uncertainty (unknown entity, missing fields) so the fingerprint falls back to the explicit
|
|
1638
1823
|
* list — the safe direction, since misclassifying narrow-as-full would cross-serve shapes.
|
|
1639
1824
|
*/
|
|
@@ -1645,11 +1830,15 @@ export class ProviderBase {
|
|
|
1645
1830
|
if (!entity || entity.Fields.length === 0) {
|
|
1646
1831
|
return false;
|
|
1647
1832
|
}
|
|
1648
|
-
|
|
1833
|
+
const includeBinary = param.IncludeBinaryFields === true;
|
|
1834
|
+
const expectedFields = includeBinary || !entity.HasBinaryFields
|
|
1835
|
+
? entity.Fields
|
|
1836
|
+
: entity.Fields.filter(f => !f.IsBinaryFieldType);
|
|
1837
|
+
if (param.Fields.length < expectedFields.length) {
|
|
1649
1838
|
return false; // cheap reject: cannot cover every field with fewer names
|
|
1650
1839
|
}
|
|
1651
1840
|
const requested = new Set(param.Fields.map(f => f.trim().toLowerCase()));
|
|
1652
|
-
return
|
|
1841
|
+
return expectedFields.every(f => requested.has(f.Name.trim().toLowerCase()));
|
|
1653
1842
|
}
|
|
1654
1843
|
/**
|
|
1655
1844
|
* Ranked search over **one** entity's records. See {@link IMetadataProvider.SearchEntity}
|
|
@@ -1995,9 +2184,37 @@ export class ProviderBase {
|
|
|
1995
2184
|
// the gate only consults `authorized` when a contextUser is present.
|
|
1996
2185
|
authorized: !user || qInfo.UserCanRun(user),
|
|
1997
2186
|
categoryPath: qInfo.CategoryPath,
|
|
1998
|
-
queryName: qInfo.Name
|
|
2187
|
+
queryName: qInfo.Name,
|
|
2188
|
+
contextTokens: FindQueryContextTokens(qInfo.ID, id => [this.Queries.find(q => UUIDsEqual(q.ID, id))?.SQL, ...this.QuerySQLs.filter(s => UUIDsEqual(s.QueryID, id)).map(s => s.SQL)], id => this.QueryDependencies.filter(d => UUIDsEqual(d.QueryID, id)).map(d => d.DependsOnQueryID)),
|
|
2189
|
+
queryId: qInfo.ID
|
|
1999
2190
|
};
|
|
2000
2191
|
}
|
|
2192
|
+
/**
|
|
2193
|
+
* The fingerprint segment for what the server sets on a run: `ServerParameters`, and the
|
|
2194
|
+
* `Context.*` values the query reads, worked out for this user, zone and day. Empty when the
|
|
2195
|
+
* run has neither, so the fingerprint is exactly what it was before either existed.
|
|
2196
|
+
* @returns null when the query reads `Context.Now`: such a result is never reused
|
|
2197
|
+
*/
|
|
2198
|
+
queryCacheServerSegment(params, contextUser, auth) {
|
|
2199
|
+
const segment = {};
|
|
2200
|
+
if (params.ServerParameters && Object.keys(params.ServerParameters).length > 0) {
|
|
2201
|
+
segment.__serverParameters = params.ServerParameters;
|
|
2202
|
+
}
|
|
2203
|
+
// ponytail: an unresolvable query (not in metadata) adds no Context segment. The TTL gate then
|
|
2204
|
+
// serves only the slot's warmer, so users stay apart, but a date value can be stale for up to
|
|
2205
|
+
// the slot's TTL across midnight. Resolve the SQL here too if that ever matters.
|
|
2206
|
+
const tokens = auth.resolvable ? auth.contextTokens ?? [] : [];
|
|
2207
|
+
if (tokens.length > 0) {
|
|
2208
|
+
// A browser's server works out dates in the zone the browser sends; a server run uses its own TimeZone or UTC.
|
|
2209
|
+
const timeZone = params.TimeZone ?? (this.ProviderType === ProviderType.Network ? GetLocalTimeZone() : undefined);
|
|
2210
|
+
const values = QueryContextCacheValues(tokens, { User: contextUser ?? this.CurrentUser, TimeZone: timeZone });
|
|
2211
|
+
if (values === null) {
|
|
2212
|
+
return null;
|
|
2213
|
+
}
|
|
2214
|
+
segment.__context = values;
|
|
2215
|
+
}
|
|
2216
|
+
return segment;
|
|
2217
|
+
}
|
|
2001
2218
|
async RunQuery(params, contextUser) {
|
|
2002
2219
|
// Shallow-clone for symmetry with RunView — pipeline must never mutate caller objects
|
|
2003
2220
|
params = { ...params };
|
|
@@ -2026,8 +2243,11 @@ export class ProviderBase {
|
|
|
2026
2243
|
// (the fingerprint carries no DataSource segment). Keeping materialized out of this layer
|
|
2027
2244
|
// means the only staleness is the refresh cadence the snapshot already advertises.
|
|
2028
2245
|
&& !IsMaterializedDataSource(params.DataSource)
|
|
2246
|
+
// A RequireApproved run must check the approval on every run; a cached slot would skip it.
|
|
2247
|
+
&& !params.RequireApproved
|
|
2029
2248
|
&& LocalCacheManager.Instance.IsInitialized;
|
|
2030
2249
|
let queryFingerprint;
|
|
2250
|
+
let queryDefinition;
|
|
2031
2251
|
if (queryCacheEngaged) {
|
|
2032
2252
|
// Resolve + authorize ONCE, BEFORE fingerprinting (B45/B46 seam). The resolved
|
|
2033
2253
|
// category feeds the fingerprint below, and the same result drives the permission
|
|
@@ -2036,19 +2256,31 @@ export class ProviderBase {
|
|
|
2036
2256
|
const queryAuth = this.ResolveQueryCacheAuthorization(params, contextUser);
|
|
2037
2257
|
// MaxRows/StartRow shape the result set — they MUST distinguish cache slots,
|
|
2038
2258
|
// so fold them into the parameters portion of the fingerprint.
|
|
2259
|
+
// Server-set values (ServerParameters, the Context.* values the query reads) shape the
|
|
2260
|
+
// result too, so one user's slot never answers another user or another day.
|
|
2261
|
+
const serverSegment = this.queryCacheServerSegment(params, contextUser, queryAuth);
|
|
2039
2262
|
const fingerprintParams = {
|
|
2040
2263
|
...(params.Parameters ?? {}),
|
|
2041
2264
|
__maxRows: params.MaxRows ?? -1,
|
|
2042
|
-
__startRow: params.StartRow ?? 0
|
|
2265
|
+
__startRow: params.StartRow ?? 0,
|
|
2266
|
+
...serverSegment
|
|
2043
2267
|
};
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2268
|
+
if (serverSegment !== null) {
|
|
2269
|
+
queryFingerprint = LocalCacheManager.Instance.GenerateRunQueryFingerprint(
|
|
2270
|
+
// The RESOLVED identity when metadata resolves, so a request by ID, by name, or by
|
|
2271
|
+
// both shares one slot (and a query's slots can be found by its ID). The caller's
|
|
2272
|
+
// identity otherwise.
|
|
2273
|
+
queryAuth.resolvable ? queryAuth.queryId : params.QueryID, queryAuth.resolvable ? queryAuth.queryName : params.QueryName, fingerprintParams, this.InstanceConnectionString,
|
|
2274
|
+
// B46: the full category path is a DISTINGUISHING element. Canonical resolved
|
|
2275
|
+
// path when metadata resolves; the caller-stated path otherwise (runtime-created
|
|
2276
|
+
// queries) — either way, same-named queries in different categories get
|
|
2277
|
+
// different slots.
|
|
2278
|
+
queryAuth.resolvable ? queryAuth.categoryPath : params.CategoryPath);
|
|
2279
|
+
}
|
|
2280
|
+
queryDefinition = queryAuth.definition;
|
|
2281
|
+
// TTL-enforced, and a miss when the slot was written under another query definition.
|
|
2282
|
+
const cached = queryFingerprint ? await LocalCacheManager.Instance.GetRunQueryResult(queryFingerprint, queryDefinition) : null;
|
|
2283
|
+
if (cached && queryFingerprint) {
|
|
2052
2284
|
const serveFromSlot = () => ({
|
|
2053
2285
|
QueryID: cached.queryId ?? params.QueryID ?? '',
|
|
2054
2286
|
QueryName: params.QueryName ?? '',
|
|
@@ -2131,7 +2363,9 @@ export class ProviderBase {
|
|
|
2131
2363
|
else {
|
|
2132
2364
|
const response = await checker([{
|
|
2133
2365
|
params,
|
|
2134
|
-
|
|
2366
|
+
// '' for a slot written without a hash: the server answers `stale` and returns
|
|
2367
|
+
// its hash, so the rewrite below stamps the slot.
|
|
2368
|
+
cacheStatus: { maxUpdatedAt: cached.maxUpdatedAt, rowCount: cached.rowCount, definitionHash: cached.definitionHash ?? '' }
|
|
2135
2369
|
}], contextUser);
|
|
2136
2370
|
const check = response.results?.[0];
|
|
2137
2371
|
if (response.success && check) {
|
|
@@ -2141,7 +2375,7 @@ export class ProviderBase {
|
|
|
2141
2375
|
if ((check.status === 'stale' || check.status === 'no_validation') && check.results) {
|
|
2142
2376
|
const freshRows = check.results;
|
|
2143
2377
|
// Fire-and-forget slot rewrite — same pattern as the RunView client path
|
|
2144
|
-
LocalCacheManager.Instance.SetRunQueryResult(queryFingerprint, params.QueryName ?? '', freshRows, check.maxUpdatedAt ?? '', check.rowCount, check.queryId, params.CacheLocalTTL, contextUser?.ID).catch(e => LogError(`RunQuery cache rewrite failed: ${e}`));
|
|
2378
|
+
LocalCacheManager.Instance.SetRunQueryResult(queryFingerprint, params.QueryName ?? '', freshRows, check.maxUpdatedAt ?? '', check.rowCount, check.queryId, params.CacheLocalTTL, contextUser?.ID, check.definitionHash ? { QueryID: check.queryId, Hash: check.definitionHash } : undefined).catch(e => LogError(`RunQuery cache rewrite failed: ${e}`));
|
|
2145
2379
|
return {
|
|
2146
2380
|
QueryID: check.queryId ?? params.QueryID ?? '',
|
|
2147
2381
|
QueryName: params.QueryName ?? '',
|
|
@@ -2177,7 +2411,7 @@ export class ProviderBase {
|
|
|
2177
2411
|
// or the LocalCacheManager default). maxUpdatedAt is unknown for a plain run —
|
|
2178
2412
|
// the smart-validation path stamps it when the Query has CacheValidationSQL.
|
|
2179
2413
|
if (queryCacheEngaged && queryFingerprint && result.Success) {
|
|
2180
|
-
LocalCacheManager.Instance.SetRunQueryResult(queryFingerprint, result.QueryName, result.Results, '', result.TotalRowCount, result.QueryID, params.CacheLocalTTL, contextUser?.ID).catch(e => LogError(`RunQuery cache write failed: ${e}`));
|
|
2414
|
+
LocalCacheManager.Instance.SetRunQueryResult(queryFingerprint, result.QueryName, result.Results, '', result.TotalRowCount, result.QueryID, params.CacheLocalTTL, contextUser?.ID, queryDefinition).catch(e => LogError(`RunQuery cache write failed: ${e}`));
|
|
2181
2415
|
}
|
|
2182
2416
|
return result;
|
|
2183
2417
|
}
|
|
@@ -2240,6 +2474,25 @@ export class ProviderBase {
|
|
|
2240
2474
|
}
|
|
2241
2475
|
EntityInfo.AssertEntityActiveStatus(entity, callerName);
|
|
2242
2476
|
}
|
|
2477
|
+
/**
|
|
2478
|
+
* Emits a `[Deprecated]` warning when a cache-hit RunView served rows for a deprecated
|
|
2479
|
+
* `AlternateBaseView` (or an alternate view whose ancestor in `ParentID` is deprecated).
|
|
2480
|
+
* Cache misses log deprecation once inside `ResolveAlternateBaseView` during execution.
|
|
2481
|
+
*/
|
|
2482
|
+
logDeprecatedAlternateBaseViewIfAny(params, callerName) {
|
|
2483
|
+
const effectiveAbv = GetEffectiveAlternateBaseViewName(params);
|
|
2484
|
+
if (!effectiveAbv) {
|
|
2485
|
+
return;
|
|
2486
|
+
}
|
|
2487
|
+
const entity = this.ResolveRunViewEntitySync(params);
|
|
2488
|
+
const abv = entity?.GetAlternateBaseView(effectiveAbv);
|
|
2489
|
+
if (entity && abv?.IsDeprecated) {
|
|
2490
|
+
LogStatusEx({
|
|
2491
|
+
message: `[Deprecated] AlternateBaseView '${abv.Name}' on entity '${entity.Name}' is deprecated (called from ${callerName}).`,
|
|
2492
|
+
verboseOnly: false,
|
|
2493
|
+
});
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2243
2496
|
// Type aliases for cleaner code
|
|
2244
2497
|
get PreRunViewResult() { return this._preRunViewResultType; }
|
|
2245
2498
|
get PreRunViewsResult() { return this._preRunViewsResultType; }
|
|
@@ -2311,7 +2564,7 @@ export class ProviderBase {
|
|
|
2311
2564
|
return entity.GetEffectiveRowFilterWhereClause(user, EntityPermissionType.Read, '');
|
|
2312
2565
|
}
|
|
2313
2566
|
/**
|
|
2314
|
-
* The
|
|
2567
|
+
* The field-security cache key for one entity: a canonical list of the fields the user MAY read.
|
|
2315
2568
|
*
|
|
2316
2569
|
* Keyed on the ALLOWED set rather than the denied set for two reasons. Once metadata ships
|
|
2317
2570
|
* to browsers filtered to what a user may see ([#3485](https://github.com/MemberJunction/MJ/issues/3485)),
|
|
@@ -2323,18 +2576,20 @@ export class ProviderBase {
|
|
|
2323
2576
|
* Returns '' when the entity has field security off or the user is denied nothing, so
|
|
2324
2577
|
* unrestricted users keep byte-identical fingerprints and shared slots.
|
|
2325
2578
|
*
|
|
2326
|
-
*
|
|
2327
|
-
*
|
|
2328
|
-
*
|
|
2579
|
+
* Reads the denied set through {@link ResolveRequestDeniedReadFields}, so the key and the
|
|
2580
|
+
* SELECT list a database provider builds for the same request describe the same columns.
|
|
2581
|
+
*
|
|
2582
|
+
* @param params - The request; also the memo the denied set is shared through
|
|
2583
|
+
* @param contextUser - The request's user; defaults to {@link CurrentUser}
|
|
2329
2584
|
*/
|
|
2330
|
-
ComputeClientFLSAllowedKey(params) {
|
|
2331
|
-
const user = this.CurrentUser;
|
|
2332
|
-
if (!user
|
|
2585
|
+
ComputeClientFLSAllowedKey(params, contextUser) {
|
|
2586
|
+
const user = contextUser ?? this.CurrentUser;
|
|
2587
|
+
if (!user)
|
|
2333
2588
|
return '';
|
|
2334
|
-
const entity = this.
|
|
2335
|
-
if (!entity
|
|
2589
|
+
const entity = this.ResolveRunViewEntitySync(params);
|
|
2590
|
+
if (!entity)
|
|
2336
2591
|
return '';
|
|
2337
|
-
const denied =
|
|
2592
|
+
const denied = this.ResolveRequestDeniedReadFields(params, entity, user);
|
|
2338
2593
|
if (denied.size === 0)
|
|
2339
2594
|
return '';
|
|
2340
2595
|
return entity.Fields
|
|
@@ -2344,32 +2599,107 @@ export class ProviderBase {
|
|
|
2344
2599
|
.join(',');
|
|
2345
2600
|
}
|
|
2346
2601
|
/**
|
|
2347
|
-
* The field-security segment for a {@link LocalCacheManager} RunView fingerprint
|
|
2348
|
-
*
|
|
2602
|
+
* The field-security segment for a {@link LocalCacheManager} RunView fingerprint, on both
|
|
2603
|
+
* tiers: the slot a request reads and fills belongs to the user's permission class.
|
|
2604
|
+
*
|
|
2605
|
+
* - **The entity's allowed columns** ({@link ComputeClientFLSAllowedKey}). A database
|
|
2606
|
+
* provider selects only the columns the reader may see, so a restricted reader's fill is
|
|
2607
|
+
* narrower than an unrestricted one's, and the two must never share a slot. A browser
|
|
2608
|
+
* stores rows exactly as the server returned them and never projects on read, so its
|
|
2609
|
+
* slots need the same identity. It also keeps user searches apart: which rows match a
|
|
2610
|
+
* search depends on which columns the reader may search.
|
|
2611
|
+
* - **For a saved view, the user's denials on every entity.** A view's stored clause can
|
|
2612
|
+
* reach other entities (nested view filters), and those clauses are only screened when the
|
|
2613
|
+
* database runs them, on a miss. Keying the slot on the whole denial profile means a hit
|
|
2614
|
+
* is only ever served from a fill by a user whose clauses screen identically.
|
|
2615
|
+
*
|
|
2616
|
+
* Empty for a user denied nothing, so unrestricted users keep byte-identical fingerprints
|
|
2617
|
+
* and share slots. Read-time projection ({@link ApplyFieldSecurityProjection}) still runs on
|
|
2618
|
+
* every hit and miss, as a backstop.
|
|
2619
|
+
*
|
|
2620
|
+
* @param params - The request
|
|
2621
|
+
* @param contextUser - The request's user; defaults to {@link CurrentUser}. On a server
|
|
2622
|
+
* this must be the request's user, never the process user.
|
|
2623
|
+
*/
|
|
2624
|
+
ComputeRunViewFLSFingerprintKey(params, contextUser) {
|
|
2625
|
+
const user = contextUser ?? this.CurrentUser;
|
|
2626
|
+
const allowed = this.ComputeClientFLSAllowedKey(params, user);
|
|
2627
|
+
const namesView = !!(params.ViewID || params.ViewName || params.ViewEntity);
|
|
2628
|
+
const profile = namesView && user ? this.ComputeFieldSecurityProfileKey(user) : '';
|
|
2629
|
+
return profile ? `${allowed}#view:${profile}` : allowed;
|
|
2630
|
+
}
|
|
2631
|
+
/**
|
|
2632
|
+
* Every field `user` may not read, across all entities: one `entityID:field,field` entry per
|
|
2633
|
+
* entity on which the user is denied something, sorted. '' for a user denied nothing.
|
|
2349
2634
|
*
|
|
2350
|
-
*
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2635
|
+
* @param user - The user whose denials to describe
|
|
2636
|
+
*/
|
|
2637
|
+
ComputeFieldSecurityProfileKey(user) {
|
|
2638
|
+
// Memoized per (catalog, user) and recomputed whenever field-security configuration or the
|
|
2639
|
+
// user's roles change, so a saved-view RunView does not walk the catalog each time.
|
|
2640
|
+
return FieldSecuritySQLGate.CatalogDenialProfileKey(this.Entities, user);
|
|
2641
|
+
}
|
|
2642
|
+
/**
|
|
2643
|
+
* The fingerprint of the server-cache slot a RunView reads and fills for `contextUser`:
|
|
2644
|
+
* the request, this provider's connection, the user's row-level-security clause and the
|
|
2645
|
+
* user's field-security key. Every server path that reads or writes a RunView slot builds
|
|
2646
|
+
* its fingerprint here, so none of them can leave a segment out — a path that omitted the
|
|
2647
|
+
* user's segments would read or fill another permission class's slot.
|
|
2648
|
+
*
|
|
2649
|
+
* @param params - The request
|
|
2650
|
+
* @param contextUser - The request's user; defaults to {@link CurrentUser}
|
|
2651
|
+
*/
|
|
2652
|
+
ComputeRunViewCacheFingerprint(params, contextUser) {
|
|
2653
|
+
return LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, this.ComputeRunViewRLSWhereClause(params, contextUser), undefined, this.ComputeRunViewFLSFingerprintKey(params, contextUser));
|
|
2654
|
+
}
|
|
2655
|
+
/**
|
|
2656
|
+
* The field-security key a browser sends with a cached slot's status, and the one a server
|
|
2657
|
+
* compares it with: a hash of {@link ComputeRunViewFLSFingerprintKey}, or '' for a user
|
|
2658
|
+
* denied nothing.
|
|
2354
2659
|
*
|
|
2355
|
-
*
|
|
2356
|
-
*
|
|
2357
|
-
*
|
|
2358
|
-
*
|
|
2359
|
-
* neither of which notices a column, so the server answers "current" and the browser keeps
|
|
2360
|
-
* showing a column that was just taken away.
|
|
2660
|
+
* The currency check otherwise compares only `maxUpdatedAt` and `rowCount`, and neither
|
|
2661
|
+
* changes when a user loses a column. A browser whose metadata has not refreshed yet keeps
|
|
2662
|
+
* its old slot (same fingerprint) and would be told "current" while still showing the
|
|
2663
|
+
* column; with this key the server sees the mismatch and answers "stale" with fresh rows.
|
|
2361
2664
|
*
|
|
2362
|
-
*
|
|
2363
|
-
*
|
|
2665
|
+
* @param params - The request
|
|
2666
|
+
* @param contextUser - The request's user; defaults to {@link CurrentUser}
|
|
2364
2667
|
*/
|
|
2365
|
-
|
|
2366
|
-
|
|
2668
|
+
ComputeFieldSecurityCacheStatusKey(params, contextUser) {
|
|
2669
|
+
const key = this.ComputeRunViewFLSFingerprintKey(params, contextUser);
|
|
2670
|
+
return key ? LocalCacheManager.Instance.HashSecuritySegment(key) : '';
|
|
2671
|
+
}
|
|
2672
|
+
/**
|
|
2673
|
+
* The fields `user` may not read on `entity` for this one request, resolved once and reused by
|
|
2674
|
+
* everything that asks again for the same request (`params` object), user object and entity.
|
|
2675
|
+
*
|
|
2676
|
+
* The cache key and the SELECT list are built at different moments of a request. Read
|
|
2677
|
+
* independently, a metadata refresh landing between the two would key a slot for one
|
|
2678
|
+
* permission class and fill it with another's columns. Sharing one answer per request rules
|
|
2679
|
+
* that out. Empty when field security does not apply.
|
|
2680
|
+
*
|
|
2681
|
+
* @param params - The request; the memo is keyed on this object
|
|
2682
|
+
* @param entity - The entity being read
|
|
2683
|
+
* @param user - The request's user
|
|
2684
|
+
*/
|
|
2685
|
+
ResolveRequestDeniedReadFields(params, entity, user) {
|
|
2686
|
+
if (!user) {
|
|
2687
|
+
return new Set();
|
|
2688
|
+
}
|
|
2689
|
+
const memo = this._requestDeniedReadFields.get(params);
|
|
2690
|
+
if (memo && memo.User === user && UUIDsEqual(memo.EntityID, entity.ID)) {
|
|
2691
|
+
return memo.Denied;
|
|
2692
|
+
}
|
|
2693
|
+
const denied = entity.FieldLevelSecurityApplies ? entity.GetDeniedReadFields(user) : new Set();
|
|
2694
|
+
this._requestDeniedReadFields.set(params, { User: user, EntityID: entity.ID, Denied: denied });
|
|
2695
|
+
return denied;
|
|
2367
2696
|
}
|
|
2368
2697
|
/**
|
|
2369
2698
|
* The fetch-widening field list for a cache-eligible request: always every entity field.
|
|
2370
|
-
* One slot per (entity, filter, order) serves every caller regardless of
|
|
2371
|
-
* they asked for
|
|
2372
|
-
*
|
|
2699
|
+
* One slot per (entity, filter, order, permission class) serves every caller regardless of
|
|
2700
|
+
* the field subset they asked for. Field security is not applied here: a database provider
|
|
2701
|
+
* leaves the user's denied columns out of the SELECT it builds from this list, and the slot
|
|
2702
|
+
* key carries the user's class ({@link ComputeRunViewFLSFingerprintKey}).
|
|
2373
2703
|
*/
|
|
2374
2704
|
ComputeRunViewFetchFields(entity, params) {
|
|
2375
2705
|
const includeBinary = params?.IncludeBinaryFields === true;
|
|
@@ -2383,12 +2713,15 @@ export class ProviderBase {
|
|
|
2383
2713
|
* @param fieldNames - Field names as a caller supplied them, e.g. `RunViewParams.Fields`.
|
|
2384
2714
|
*/
|
|
2385
2715
|
static NamesAnyBinaryField(entity, fieldNames) {
|
|
2386
|
-
if (!fieldNames || fieldNames.length === 0)
|
|
2387
|
-
return false;
|
|
2388
|
-
const binaryNames = new Set(entity.Fields.filter(f => f.IsBinaryFieldType).map(f => f.Name.toLowerCase()));
|
|
2389
|
-
if (binaryNames.size === 0)
|
|
2716
|
+
if (!fieldNames || fieldNames.length === 0 || !entity.HasBinaryFields)
|
|
2390
2717
|
return false;
|
|
2391
|
-
|
|
2718
|
+
const binaryFields = entity.BinaryFields;
|
|
2719
|
+
return fieldNames.some(name => {
|
|
2720
|
+
if (typeof name !== 'string')
|
|
2721
|
+
return false;
|
|
2722
|
+
const lower = name.trim().toLowerCase();
|
|
2723
|
+
return binaryFields.some(bf => bf.Name.toLowerCase() === lower);
|
|
2724
|
+
});
|
|
2392
2725
|
}
|
|
2393
2726
|
/**
|
|
2394
2727
|
* Decides whether a RunView returns the entity's binary fields, and records the decision on
|
|
@@ -2421,69 +2754,78 @@ export class ProviderBase {
|
|
|
2421
2754
|
// FIELD-LEVEL SECURITY
|
|
2422
2755
|
// ========================================================================
|
|
2423
2756
|
/**
|
|
2424
|
-
* Rejects a RunView whose
|
|
2425
|
-
*
|
|
2757
|
+
* Rejects a RunView whose caller-authored SQL reads a field the user cannot read: `ExtraFilter`,
|
|
2758
|
+
* `OrderBy`, `OverrideExcludeFilter`, every `Aggregates[].expression`, and — when the request
|
|
2759
|
+
* carries a loaded `ViewEntity` — that view's stored `WhereClause` and `OrderByClause`.
|
|
2426
2760
|
*
|
|
2427
2761
|
* Output projection alone is security theater. A user denied `Salary` can send
|
|
2428
2762
|
* `ExtraFilter: "Salary > 200000"` or `OrderBy: "Salary DESC"` and reconstruct the values
|
|
2429
2763
|
* from which rows come back and in what order — the column never appears in a result, so
|
|
2430
2764
|
* every output-stripping point reports "secure." Aggregates are the same channel in a purer
|
|
2431
2765
|
* form: `Aggregates: [{expression: 'MIN(Salary)'}]` under a narrow filter returns a denied
|
|
2432
|
-
* field's exact values directly.
|
|
2433
|
-
*
|
|
2434
|
-
* surface (`UserSearchString` is handled by excluding denied fields from the searched set,
|
|
2435
|
-
* not by rejection — see below).
|
|
2766
|
+
* field's exact values directly. `UserSearchString` is handled by excluding denied fields from
|
|
2767
|
+
* the searched set, not by rejection.
|
|
2436
2768
|
*
|
|
2437
|
-
* Lives at the provider layer
|
|
2438
|
-
*
|
|
2439
|
-
*
|
|
2440
|
-
*
|
|
2769
|
+
* Lives at the provider layer because every RunView funnels through here — the batch path,
|
|
2770
|
+
* server-internal agents and actions running under a restricted `contextUser`, and the
|
|
2771
|
+
* resolver alike. A database provider checks the clauses it actually runs a second time, once
|
|
2772
|
+
* a saved view is resolved, so a request that names only a `ViewID` is covered too.
|
|
2441
2773
|
*
|
|
2442
|
-
* The
|
|
2443
|
-
* see {@link ProviderBase.FieldSecurityDenialMessage}.
|
|
2774
|
+
* The reading of each clause, and the error it raises, is {@link FieldSecuritySQLGate}'s.
|
|
2444
2775
|
*/
|
|
2445
2776
|
AssertPredicatesRespectFieldSecurity(params, contextUser) {
|
|
2446
|
-
if (!contextUser
|
|
2777
|
+
if (!contextUser) {
|
|
2447
2778
|
return;
|
|
2448
2779
|
}
|
|
2449
|
-
const entity = this.
|
|
2450
|
-
if (!entity
|
|
2451
|
-
return; // the overwhelmingly common case — one boolean, no allocation
|
|
2452
|
-
}
|
|
2453
|
-
const denied = entity.GetDeniedReadFields(contextUser);
|
|
2454
|
-
if (denied.size === 0) {
|
|
2780
|
+
const entity = this.ResolveRunViewEntitySync(params);
|
|
2781
|
+
if (!entity) {
|
|
2455
2782
|
return;
|
|
2456
2783
|
}
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
for (const field of entity.Fields) {
|
|
2460
|
-
if (denied.has(field.Name.trim().toLowerCase())) {
|
|
2461
|
-
deniedByName.set(field.Name.trim().toLowerCase(), field.Name);
|
|
2462
|
-
}
|
|
2463
|
-
}
|
|
2464
|
-
// Aliases are deliberately not scanned — an alias names the output column, no data
|
|
2465
|
-
// flows through it.
|
|
2466
|
-
const clauses = [
|
|
2784
|
+
const view = params.ViewEntity;
|
|
2785
|
+
this.AssertClausesRespectFieldSecurityForEntity(entity, [
|
|
2467
2786
|
params.ExtraFilter,
|
|
2468
|
-
params.
|
|
2787
|
+
params.OverrideExcludeFilter,
|
|
2469
2788
|
...(params.Aggregates ?? []).map(a => a?.expression),
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2789
|
+
view ? view.Get('WhereClause') : undefined,
|
|
2790
|
+
], contextUser);
|
|
2791
|
+
this.AssertSortRespectsFieldSecurityForEntity(entity, [
|
|
2792
|
+
params.OrderBy,
|
|
2793
|
+
view?.OrderByClause,
|
|
2794
|
+
], contextUser);
|
|
2795
|
+
}
|
|
2796
|
+
/**
|
|
2797
|
+
* Throws a {@link FieldSecurityError} when any of `clauses` — SQL fragments that run against
|
|
2798
|
+
* `entity`'s view — reads a field `user` cannot read, reads whole rows of an entity the user is
|
|
2799
|
+
* denied fields on, or cannot be read well enough to tell. Non-string and blank entries are
|
|
2800
|
+
* skipped. A no-op, with nothing scanned, for a user denied nothing on any entity.
|
|
2801
|
+
*
|
|
2802
|
+
* The shared entry point for every clause slot a provider runs: RunView parameters, a saved
|
|
2803
|
+
* view's stored clauses, dataset item filters, user-search text spliced as SQL.
|
|
2804
|
+
*/
|
|
2805
|
+
AssertClausesRespectFieldSecurityForEntity(entity, clauses, user) {
|
|
2806
|
+
this.assertFragmentsAllowed(entity, clauses, user, (sql, context) => FieldSecuritySQLGate.FindViolation(sql, context));
|
|
2807
|
+
}
|
|
2808
|
+
/**
|
|
2809
|
+
* {@link AssertClausesRespectFieldSecurityForEntity} for ORDER BY fragments, which also refuses
|
|
2810
|
+
* a sort by column position for a user denied a field on `entity`
|
|
2811
|
+
* ({@link FieldSecuritySQLGate.FindSortViolation}).
|
|
2812
|
+
*/
|
|
2813
|
+
AssertSortRespectsFieldSecurityForEntity(entity, orderBys, user) {
|
|
2814
|
+
this.assertFragmentsAllowed(entity, orderBys, user, (sql, context) => FieldSecuritySQLGate.FindSortViolation(sql, context));
|
|
2815
|
+
}
|
|
2816
|
+
/** Throws the field-security error for the first of `fragments` that `find` refuses. */
|
|
2817
|
+
assertFragmentsAllowed(entity, fragments, user, find) {
|
|
2818
|
+
const context = { User: user, Dialect: this.PlatformKey, Catalog: this.Entities, TargetEntity: entity };
|
|
2819
|
+
for (const clause of fragments) {
|
|
2820
|
+
if (typeof clause !== 'string' || clause.trim().length === 0) {
|
|
2473
2821
|
continue;
|
|
2474
2822
|
}
|
|
2475
|
-
const
|
|
2476
|
-
if (
|
|
2477
|
-
LogDebug(`[FieldSecurity] Rejected
|
|
2478
|
-
|
|
2479
|
-
throw new FieldSecurityError(hits[0], entity.Name);
|
|
2823
|
+
const violation = find(clause, context);
|
|
2824
|
+
if (violation) {
|
|
2825
|
+
LogDebug(`[FieldSecurity] Rejected SQL on '${entity.Name}' for user ${user.Email}: ${violation.Kind} ${violation.Reference}`);
|
|
2826
|
+
throw FieldSecuritySQLGate.ErrorForViolation(violation, entity.Name);
|
|
2480
2827
|
}
|
|
2481
2828
|
}
|
|
2482
|
-
// UserSearchString is not a predicate the caller authored — it is a search term the
|
|
2483
|
-
// platform expands across IncludeInUserSearchAPI fields. There is nothing to reject;
|
|
2484
|
-
// the correct handling is to not search the denied fields, which the SQL builder does
|
|
2485
|
-
// by consulting the same denied set. Rejecting here would make the search box fail
|
|
2486
|
-
// for a term that merely happens to match a restricted field's name.
|
|
2487
2829
|
}
|
|
2488
2830
|
/**
|
|
2489
2831
|
* The single wording for "you cannot use this field," modeled on SQL Server's posture of
|
|
@@ -2503,15 +2845,13 @@ export class ProviderBase {
|
|
|
2503
2845
|
/**
|
|
2504
2846
|
* Strips fields the user cannot read from plain-object result rows.
|
|
2505
2847
|
*
|
|
2506
|
-
*
|
|
2507
|
-
*
|
|
2508
|
-
*
|
|
2509
|
-
*
|
|
2510
|
-
*
|
|
2511
|
-
*
|
|
2512
|
-
*
|
|
2513
|
-
* restricted service account has no business holding: the `simple`-path SELECT-list
|
|
2514
|
-
* intersection, and `Load()`'s allowed-column SELECT.
|
|
2848
|
+
* The backstop behind slot segmentation: cache slots are keyed by the reader's permission
|
|
2849
|
+
* class ({@link ComputeRunViewFLSFingerprintKey}) and filled with only that class's columns,
|
|
2850
|
+
* and this still runs on BOTH cache hits and cache misses, checking every row. A slot can hold
|
|
2851
|
+
* more than its class may read (an `entity_object` fill selects every column, and a slot keyed
|
|
2852
|
+
* just before a permission change keeps its old width), and this is what keeps such a slot
|
|
2853
|
+
* from reaching a reader. It reads live metadata, so a tightened permission applies here from
|
|
2854
|
+
* the next metadata refresh.
|
|
2515
2855
|
*
|
|
2516
2856
|
* NEVER applied to `entity_object` results. Those become `BaseEntity` instances whose
|
|
2517
2857
|
* fields round-trip through `GenerateSaveSQL`, which iterates ALL `IsSPParameter` fields
|
|
@@ -2521,11 +2861,11 @@ export class ProviderBase {
|
|
|
2521
2861
|
* output, which the GraphQL layer enforces separately.
|
|
2522
2862
|
*/
|
|
2523
2863
|
ApplyFieldSecurityProjection(rows, params, contextUser) {
|
|
2524
|
-
if (!rows?.length || !contextUser ||
|
|
2864
|
+
if (!rows?.length || !contextUser || params.ResultType === 'entity_object') {
|
|
2525
2865
|
return rows;
|
|
2526
2866
|
}
|
|
2527
|
-
const entity = this.
|
|
2528
|
-
if (!entity?.
|
|
2867
|
+
const entity = this.ResolveRunViewEntitySync(params);
|
|
2868
|
+
if (!entity?.FieldLevelSecurityApplies) {
|
|
2529
2869
|
return rows;
|
|
2530
2870
|
}
|
|
2531
2871
|
const denied = entity.GetDeniedReadFields(contextUser);
|
|
@@ -2536,32 +2876,78 @@ export class ProviderBase {
|
|
|
2536
2876
|
}
|
|
2537
2877
|
/**
|
|
2538
2878
|
* Row-loop half of {@link ApplyFieldSecurityProjection}, split out so the policy above reads
|
|
2539
|
-
* as policy. Mirrors {@link ProjectRowsToFields}' matching semantics (trim + lowercase)
|
|
2540
|
-
*
|
|
2541
|
-
*
|
|
2879
|
+
* as policy. Mirrors {@link ProjectRowsToFields}' matching semantics (trim + lowercase).
|
|
2880
|
+
*
|
|
2881
|
+
* Every row is checked on its own: rows in one result need not share a shape (a slot can mix
|
|
2882
|
+
* rows written by different paths), so no row stands in for the others. A row carrying no
|
|
2883
|
+
* denied key is kept as is, and when none do the input array itself is returned.
|
|
2884
|
+
*
|
|
2885
|
+
* The per-key work (trim, lowercase, set lookup) is done once per key SHAPE rather than once
|
|
2886
|
+
* per row: rows with the same keys in the same order share one {@link RowProjectionPlan}. A
|
|
2887
|
+
* result is almost always a single shape, so a restricted user's 10k-row result pays for one
|
|
2888
|
+
* row's worth of key matching plus a copy per row.
|
|
2542
2889
|
*
|
|
2543
2890
|
* @param deniedLowercase field names to omit, already lowercased
|
|
2544
2891
|
*/
|
|
2545
2892
|
static omitFieldsFromRows(rows, deniedLowercase) {
|
|
2546
|
-
const
|
|
2547
|
-
|
|
2548
|
-
|
|
2893
|
+
const plans = { Last: null, BySignature: new Map() };
|
|
2894
|
+
let changed = false;
|
|
2895
|
+
const projected = rows.map((row) => {
|
|
2896
|
+
const narrowed = ProviderBase.omitFieldsFromRow(row, deniedLowercase, plans);
|
|
2897
|
+
changed = changed || narrowed !== row;
|
|
2898
|
+
return narrowed;
|
|
2899
|
+
});
|
|
2900
|
+
return changed ? projected : rows;
|
|
2901
|
+
}
|
|
2902
|
+
/** One row of {@link omitFieldsFromRows}: the row itself when it carries no denied key, else a copy without them. */
|
|
2903
|
+
static omitFieldsFromRow(row, deniedLowercase, plans) {
|
|
2904
|
+
if (!row || typeof row !== 'object') {
|
|
2905
|
+
return row;
|
|
2549
2906
|
}
|
|
2550
|
-
const
|
|
2551
|
-
|
|
2552
|
-
|
|
2907
|
+
const source = row;
|
|
2908
|
+
const keys = Object.keys(source);
|
|
2909
|
+
const plan = ProviderBase.rowProjectionPlanFor(keys, deniedLowercase, plans);
|
|
2910
|
+
if (!plan.Keep) {
|
|
2911
|
+
return row;
|
|
2553
2912
|
}
|
|
2554
|
-
const
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2913
|
+
const filtered = {};
|
|
2914
|
+
for (const key of plan.Keep) {
|
|
2915
|
+
filtered[key] = source[key];
|
|
2916
|
+
}
|
|
2917
|
+
return filtered;
|
|
2918
|
+
}
|
|
2919
|
+
/**
|
|
2920
|
+
* The plan for a row with exactly these keys, built on first sight of the shape. The last plan
|
|
2921
|
+
* used is tried first; otherwise plans are found by the joined key list. Either way the plan's
|
|
2922
|
+
* own key list is compared with the row's before it is used, so two different shapes can
|
|
2923
|
+
* never share a plan.
|
|
2924
|
+
*/
|
|
2925
|
+
static rowProjectionPlanFor(keys, deniedLowercase, plans) {
|
|
2926
|
+
if (plans.Last && ProviderBase.sameKeys(plans.Last.Keys, keys)) {
|
|
2927
|
+
return plans.Last; // consecutive rows nearly always share a shape: no signature to build
|
|
2928
|
+
}
|
|
2929
|
+
const signature = keys.join('\u0000');
|
|
2930
|
+
let plan = plans.BySignature.get(signature);
|
|
2931
|
+
if (!plan || !ProviderBase.sameKeys(plan.Keys, keys)) {
|
|
2932
|
+
const keep = keys.some(k => deniedLowercase.has(k.trim().toLowerCase()))
|
|
2933
|
+
? keys.filter(k => !deniedLowercase.has(k.trim().toLowerCase()))
|
|
2934
|
+
: null;
|
|
2935
|
+
plan = { Keys: keys, Keep: keep };
|
|
2936
|
+
plans.BySignature.set(signature, plan);
|
|
2937
|
+
}
|
|
2938
|
+
plans.Last = plan;
|
|
2939
|
+
return plan;
|
|
2940
|
+
}
|
|
2941
|
+
static sameKeys(a, b) {
|
|
2942
|
+
if (a.length !== b.length) {
|
|
2943
|
+
return false;
|
|
2944
|
+
}
|
|
2945
|
+
for (let i = 0; i < a.length; i++) {
|
|
2946
|
+
if (a[i] !== b[i]) {
|
|
2947
|
+
return false;
|
|
2562
2948
|
}
|
|
2563
|
-
|
|
2564
|
-
|
|
2949
|
+
}
|
|
2950
|
+
return true;
|
|
2565
2951
|
}
|
|
2566
2952
|
/**
|
|
2567
2953
|
* Strips or narrows the `MJ: Record Changes` payload columns a user may not read, using the
|
|
@@ -2573,6 +2959,9 @@ export class ProviderBase {
|
|
|
2573
2959
|
* Everything about the per-row denied set, the payload treatment, and the fail-closed decision
|
|
2574
2960
|
* lives in {@link RecordChangeFieldSecurityProjector}; this is only the RunView wiring.
|
|
2575
2961
|
*
|
|
2962
|
+
* A request whose entity cannot be resolved without I/O (only a `ViewID` or `ViewName`) is
|
|
2963
|
+
* projected too, so a saved view over Record Changes is not a way around it.
|
|
2964
|
+
*
|
|
2576
2965
|
* Runs at all four RunView projection points, matching the main projection: both cache-hit
|
|
2577
2966
|
* paths and both cache-miss paths. The cache-hit path is not optional — it is the exact path
|
|
2578
2967
|
* the original cross-user leak runs through, an unrestricted user warming a full-width slot
|
|
@@ -2595,9 +2984,13 @@ export class ProviderBase {
|
|
|
2595
2984
|
if (!rows?.length || !contextUser || params.ResultType === 'entity_object') {
|
|
2596
2985
|
return rows;
|
|
2597
2986
|
}
|
|
2598
|
-
|
|
2987
|
+
const entity = this.ResolveRunViewEntitySync(params);
|
|
2988
|
+
if (entity && !RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entity)) {
|
|
2599
2989
|
return rows;
|
|
2600
2990
|
}
|
|
2991
|
+
// An entity that does not resolve here (a ViewID/ViewName-only request) may be Record
|
|
2992
|
+
// Changes. The projector decides per row from the row's own EntityID and fails closed on a
|
|
2993
|
+
// row without one; rows that carry no payload column come back untouched.
|
|
2601
2994
|
return new RecordChangeFieldSecurityProjector(this, contextUser).ProjectRows(rows);
|
|
2602
2995
|
}
|
|
2603
2996
|
/**
|
|
@@ -2629,6 +3022,9 @@ export class ProviderBase {
|
|
|
2629
3022
|
// Hooks run after PlatformSQL resolution so they see plain-string filters,
|
|
2630
3023
|
// and before cache fingerprinting so injected filters affect the cache key.
|
|
2631
3024
|
params = await this.RunPreRunViewHooks(params, contextUser);
|
|
3025
|
+
// A stored view's property values are worked out before the cache lookup, so the
|
|
3026
|
+
// fingerprint keys on them. After hooks, so a hook can set ServerProperties.
|
|
3027
|
+
await this.PrepareRunViewProperties(params, contextUser);
|
|
2632
3028
|
// Field-level security: reject predicates referencing unreadable fields. Runs AFTER
|
|
2633
3029
|
// hooks so injected filters are scanned too, and BEFORE the cache lookup so a rejected
|
|
2634
3030
|
// request never consults (or warms) a cache slot.
|
|
@@ -2640,6 +3036,7 @@ export class ProviderBase {
|
|
|
2640
3036
|
EntityName: params.EntityName,
|
|
2641
3037
|
ViewID: params.ViewID,
|
|
2642
3038
|
ViewName: params.ViewName,
|
|
3039
|
+
AlternateBaseView: GetEffectiveAlternateBaseViewName(params),
|
|
2643
3040
|
ExtraFilter: params.ExtraFilter,
|
|
2644
3041
|
OrderBy: params.OrderBy,
|
|
2645
3042
|
ResultType: params.ResultType,
|
|
@@ -2659,8 +3056,10 @@ export class ProviderBase {
|
|
|
2659
3056
|
await this.EntityStatusCheck(params, 'PreRunView', contextUser);
|
|
2660
3057
|
const entityCheckTime = performance.now() - entityCheckStart;
|
|
2661
3058
|
// Save the caller's original Fields request for post-cache filtering.
|
|
2662
|
-
// We
|
|
2663
|
-
// that satisfies any
|
|
3059
|
+
// We fetch every field this user's permission class may read, so the cache entry is a
|
|
3060
|
+
// superset for that class: it satisfies any later query for the same entity+filter, by
|
|
3061
|
+
// any user of the same class, regardless of field subset. Slots are keyed per class (the
|
|
3062
|
+
// fls: segment), so no slot is shared between classes at different widths.
|
|
2664
3063
|
const entityLookupStart = performance.now();
|
|
2665
3064
|
let callerRequestedFields = params.Fields && params.Fields.length > 0
|
|
2666
3065
|
? params.Fields.map(f => f.trim().toLowerCase())
|
|
@@ -2668,8 +3067,8 @@ export class ProviderBase {
|
|
|
2668
3067
|
// Only override Fields to the widened fetch set when caching will actually happen
|
|
2669
3068
|
// for this call. For non-cached calls we respect the caller's narrow Fields
|
|
2670
3069
|
// end-to-end — there's no cache-coherence concern to preserve. The widened set is
|
|
2671
|
-
// ALL entity fields
|
|
2672
|
-
//
|
|
3070
|
+
// ALL entity fields; a database provider then leaves out the columns this user may
|
|
3071
|
+
// not read, and the slot is keyed by the user's permission class (fls: segment).
|
|
2673
3072
|
const entity = params.EntityName ? this.EntityByName(params.EntityName) : null;
|
|
2674
3073
|
const willCache = this.runViewCacheEligible(params);
|
|
2675
3074
|
// entity_object ALWAYS widens, cache-eligible or not: a BaseEntity must hydrate from
|
|
@@ -2708,9 +3107,7 @@ export class ProviderBase {
|
|
|
2708
3107
|
const cacheReadDenied = (!!entity && !!contextUser && !(entity.GetUserPermisions(contextUser)?.CanRead ?? false)) ||
|
|
2709
3108
|
this.cacheDeniedForViewOnlyRequest(params, contextUser);
|
|
2710
3109
|
if (willCache && !cacheReadDenied && LocalCacheManager.Instance.IsInitialized) {
|
|
2711
|
-
|
|
2712
|
-
const flsFieldsKey = this.ComputeRunViewFLSFingerprintKey(params);
|
|
2713
|
-
fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, rlsWhereClause, undefined, flsFieldsKey);
|
|
3110
|
+
fingerprint = this.ComputeRunViewCacheFingerprint(params, contextUser);
|
|
2714
3111
|
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
2715
3112
|
if (cached) {
|
|
2716
3113
|
// These rows are the cache's shared, deep-frozen objects — the runtime freeze is
|
|
@@ -2722,14 +3119,12 @@ export class ProviderBase {
|
|
|
2722
3119
|
}
|
|
2723
3120
|
// Field-level security MUST be applied here, not only in PostRunView: this path
|
|
2724
3121
|
// RETURNS (see RunView) without ever calling PostRunView, and it is the exact
|
|
2725
|
-
// path
|
|
2726
|
-
//
|
|
2727
|
-
// column
|
|
2728
|
-
// caller's Fields list is unrelated to security and cannot be relied on.
|
|
3122
|
+
// path a cross-user leak would run through. Slots are keyed by permission class,
|
|
3123
|
+
// but a slot can still be wider than its class (an entity_object fill selects
|
|
3124
|
+
// every column), and the caller's Fields list is unrelated to security.
|
|
2729
3125
|
results = this.ApplyFieldSecurityProjection(results, params, contextUser);
|
|
2730
|
-
// Record Changes payload security, on the same cache-hit path
|
|
2731
|
-
//
|
|
2732
|
-
// about who may read what out of it.
|
|
3126
|
+
// Record Changes payload security, on the same cache-hit path: its denials belong
|
|
3127
|
+
// to the entity each row is about, which the Record Changes slot key cannot carry.
|
|
2733
3128
|
results = this.ApplyRecordChangeFieldSecurityProjection(results, params, contextUser);
|
|
2734
3129
|
// Reconstruct RunViewResult from cached data
|
|
2735
3130
|
cachedResult = {
|
|
@@ -2745,6 +3140,7 @@ export class ProviderBase {
|
|
|
2745
3140
|
AggregateResults: LocalCacheManager.Instance.ReorderAggregateResultsToRequest(cached.aggregateResults, params.Aggregates)
|
|
2746
3141
|
};
|
|
2747
3142
|
cacheStatus = 'hit';
|
|
3143
|
+
this.logDeprecatedAlternateBaseViewIfAny(params, 'PreRunView');
|
|
2748
3144
|
if (!params.CacheLocal && this.TrustLocalCacheCompletely) {
|
|
2749
3145
|
LogStatusEx({ message: ` ✅ [Server Cache HIT] RunView "${params.EntityName || params.ViewName || 'unknown'}" — ${results.length} rows served from server cache (no DB query)`, verboseOnly: true });
|
|
2750
3146
|
}
|
|
@@ -2756,7 +3152,7 @@ export class ProviderBase {
|
|
|
2756
3152
|
const cacheCheckTime = performance.now() - cacheCheckStart;
|
|
2757
3153
|
const totalPreTime = performance.now() - preViewStart;
|
|
2758
3154
|
if (totalPreTime > 50) {
|
|
2759
|
-
|
|
3155
|
+
LogStatusEx({ message: `[PERF-PRE] PreRunView ${params.EntityName}: ${totalPreTime.toFixed(1)}ms (telemetry=${telemetryTime.toFixed(1)}ms, entityCheck=${entityCheckTime.toFixed(1)}ms, entityLookup=${entityLookupTime.toFixed(1)}ms, cache=${cacheCheckTime.toFixed(1)}ms)`, verboseOnly: true });
|
|
2760
3156
|
}
|
|
2761
3157
|
return {
|
|
2762
3158
|
telemetryEventId,
|
|
@@ -2771,23 +3167,20 @@ export class ProviderBase {
|
|
|
2771
3167
|
/**
|
|
2772
3168
|
* Pre-processing hook for RunViews (batch).
|
|
2773
3169
|
* Handles telemetry, validation, and cache lookup for multiple views.
|
|
3170
|
+
* A view the field-security predicate gate refuses is left out of every later stage and
|
|
3171
|
+
* reported in `refusedResults`; the cache status describes the admitted views only.
|
|
2774
3172
|
* @param params - Array of view parameters
|
|
2775
3173
|
* @param contextUser - Optional user context
|
|
2776
|
-
* @returns Pre-processing result with cache status for each view
|
|
3174
|
+
* @returns Pre-processing result with cache status for each admitted view
|
|
2777
3175
|
*/
|
|
2778
3176
|
async PreRunViews(params, contextUser) {
|
|
2779
3177
|
// Resolve any PlatformSQL values to plain strings for the active platform
|
|
2780
3178
|
for (const p of params) {
|
|
2781
3179
|
this.ResolvePlatformSQLInParams(p);
|
|
2782
3180
|
}
|
|
2783
|
-
// Run registered PreRunView hooks on each param in the batch
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
// Field-level security predicate gate, same contract as the single-view path.
|
|
2787
|
-
// One bad view rejects the whole batch — a batch is one caller request, and
|
|
2788
|
-
// silently returning partial results would hide the denial.
|
|
2789
|
-
this.AssertPredicatesRespectFieldSecurity(params[i], contextUser);
|
|
2790
|
-
}
|
|
3181
|
+
// Run registered PreRunView hooks on each param in the batch, then the field-security
|
|
3182
|
+
// predicate gate (after the hooks, so injected filters are scanned too).
|
|
3183
|
+
const refusedResults = await this.runBatchHooksAndPredicateGate(params, contextUser);
|
|
2791
3184
|
// Start telemetry tracking for batch operation
|
|
2792
3185
|
const fromEngine = params.some(p => p._fromEngine);
|
|
2793
3186
|
const telemetryEventId = TelemetryManager.Instance.StartEvent('RunView', 'ProviderBase.RunViews', {
|
|
@@ -2809,6 +3202,7 @@ export class ProviderBase {
|
|
|
2809
3202
|
// client), which is exactly what a keyset sweep does on every page.
|
|
2810
3203
|
StartRows: params.map(p => p.StartRow),
|
|
2811
3204
|
AfterKeys: params.map(p => p.AfterKey?.ToConcatenatedString()),
|
|
3205
|
+
AlternateBaseViews: params.map(p => GetEffectiveAlternateBaseViewName(p)),
|
|
2812
3206
|
// Exemption was threaded through the DEPRECATED batch twin but not this one, so
|
|
2813
3207
|
// RunViewParams.Telemetry.Exempt was silently ineffective for every batch RunView —
|
|
2814
3208
|
// and, because RunView() delegates here whenever BypassCache or AfterKey is set,
|
|
@@ -2822,17 +3216,69 @@ export class ProviderBase {
|
|
|
2822
3216
|
ExemptReason: params.find(p => p.Telemetry?.Reason)?.Telemetry?.Reason,
|
|
2823
3217
|
_fromEngine: fromEngine
|
|
2824
3218
|
}, contextUser?.ID);
|
|
3219
|
+
// A refused view never reaches a cache lookup, the smart cache check or the database.
|
|
3220
|
+
const admitted = refusedResults ? params.filter((_, i) => !refusedResults.has(i)) : params;
|
|
3221
|
+
const preResult = await this.lookUpAdmittedViews(admitted, telemetryEventId, contextUser);
|
|
3222
|
+
return refusedResults ? { ...preResult, refusedResults } : preResult;
|
|
3223
|
+
}
|
|
3224
|
+
/**
|
|
3225
|
+
* Runs each view's PreRunView hooks, replacing `params[i]` with the hooked params, then the
|
|
3226
|
+
* field-security predicate gate — the same contract as the single-view path, except that a
|
|
3227
|
+
* refusal fails only its own view: it becomes that view's `Success: false` result, carrying
|
|
3228
|
+
* the refusal's ambiguous message, so the views batched with it still run. A batch is often
|
|
3229
|
+
* several independent reads (a dashboard's panels), and one unreadable column in one of them
|
|
3230
|
+
* must not blank the rest; the refusal is still reported, at the refused view's index. Any
|
|
3231
|
+
* other error from the gate is thrown and fails the batch.
|
|
3232
|
+
* @returns The refused views' results keyed by batch index, or `undefined` when none was refused.
|
|
3233
|
+
*/
|
|
3234
|
+
async runBatchHooksAndPredicateGate(params, contextUser) {
|
|
3235
|
+
let refused;
|
|
3236
|
+
for (let i = 0; i < params.length; i++) {
|
|
3237
|
+
params[i] = await this.RunPreRunViewHooks(params[i], contextUser);
|
|
3238
|
+
await this.PrepareRunViewProperties(params[i], contextUser);
|
|
3239
|
+
try {
|
|
3240
|
+
this.AssertPredicatesRespectFieldSecurity(params[i], contextUser);
|
|
3241
|
+
}
|
|
3242
|
+
catch (error) {
|
|
3243
|
+
refused ??= new Map();
|
|
3244
|
+
refused.set(i, ProviderBase.fieldSecurityRefusalAsResult(error));
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
return refused;
|
|
3248
|
+
}
|
|
3249
|
+
/** `error` as a failed view result when it is a field-security refusal; otherwise rethrows it. */
|
|
3250
|
+
static fieldSecurityRefusalAsResult(error) {
|
|
3251
|
+
if (!(error instanceof Error) || (!(error instanceof FieldSecurityError) && error.name !== FieldSecurityError.ErrorName)) {
|
|
3252
|
+
throw error;
|
|
3253
|
+
}
|
|
3254
|
+
return { Success: false, Results: [], RowCount: 0, TotalRowCount: 0, ExecutionTime: 0, UserViewRunID: '', ErrorMessage: error.message };
|
|
3255
|
+
}
|
|
3256
|
+
/**
|
|
3257
|
+
* The cache stage of PreRunViews for the views the predicate gate admitted. An empty batch
|
|
3258
|
+
* (every view refused) is reported as fully cached, so the pipeline returns without a
|
|
3259
|
+
* database call and still ends the batch's telemetry event.
|
|
3260
|
+
*/
|
|
3261
|
+
async lookUpAdmittedViews(params, telemetryEventId, contextUser) {
|
|
3262
|
+
if (params.length === 0) {
|
|
3263
|
+
return { telemetryEventId, allCached: true, cachedResults: [] };
|
|
3264
|
+
}
|
|
2825
3265
|
// Client-side providers route any CacheLocal params through smart-cache-check:
|
|
2826
3266
|
// a single batched GraphQL call sends fingerprints (hashes + counts) per view,
|
|
2827
3267
|
// the server confirms current vs stale, and only stale views return data.
|
|
2828
3268
|
// Server-side providers trust the cache completely and fall through to the
|
|
2829
3269
|
// traditional flow which returns cached data immediately on hit.
|
|
2830
3270
|
if (!this.TrustLocalCacheCompletely) {
|
|
2831
|
-
|
|
3271
|
+
// The cache-check transport carries dynamic runs only, so a batch holding a run with view
|
|
3272
|
+
// properties takes the ordinary path, where the view and its properties reach the server.
|
|
3273
|
+
const useSmartCacheCheck = params.some(p => p.CacheLocal) && !params.some(ProviderBase.carriesViewProperties);
|
|
2832
3274
|
if (useSmartCacheCheck && LocalCacheManager.Instance.IsInitialized) {
|
|
2833
3275
|
return this.prepareSmartCacheCheckParams(params, telemetryEventId, contextUser);
|
|
2834
3276
|
}
|
|
2835
3277
|
}
|
|
3278
|
+
return this.lookUpBatchCache(params, telemetryEventId, contextUser);
|
|
3279
|
+
}
|
|
3280
|
+
/** The server's trusted-cache lookup for a batch: serves hits, and lists the misses to run. */
|
|
3281
|
+
async lookUpBatchCache(params, telemetryEventId, contextUser) {
|
|
2836
3282
|
// Traditional caching flow
|
|
2837
3283
|
const cacheStatusMap = new Map();
|
|
2838
3284
|
const callerFieldsMap = new Map();
|
|
@@ -2852,7 +3298,7 @@ export class ProviderBase {
|
|
|
2852
3298
|
// Only override Fields to the widened fetch set when caching will actually happen
|
|
2853
3299
|
// for this call. For non-cached calls we respect the caller's narrow Fields
|
|
2854
3300
|
// end-to-end — there's no cache-coherence concern to preserve. Widened = ALL
|
|
2855
|
-
// fields
|
|
3301
|
+
// fields (see PreRunView).
|
|
2856
3302
|
const batchEntity = param.EntityName ? this.EntityByName(param.EntityName) : null;
|
|
2857
3303
|
const batchWillCache = this.runViewCacheEligible(param);
|
|
2858
3304
|
// Same entity_object-always-widens rule as the single-view path above.
|
|
@@ -2879,9 +3325,7 @@ export class ProviderBase {
|
|
|
2879
3325
|
const batchCacheReadDenied = (!!batchEntity && !!contextUser && !(batchEntity.GetUserPermisions(contextUser)?.CanRead ?? false)) ||
|
|
2880
3326
|
this.cacheDeniedForViewOnlyRequest(param, contextUser);
|
|
2881
3327
|
if (batchWillCache && !batchCacheReadDenied && LocalCacheManager.Instance.IsInitialized) {
|
|
2882
|
-
const
|
|
2883
|
-
const flsFieldsKey = this.ComputeRunViewFLSFingerprintKey(param);
|
|
2884
|
-
const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString, rlsWhereClause, undefined, flsFieldsKey);
|
|
3328
|
+
const fingerprint = this.ComputeRunViewCacheFingerprint(param, contextUser);
|
|
2885
3329
|
fingerprintMap.set(i, fingerprint);
|
|
2886
3330
|
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
2887
3331
|
if (cached) {
|
|
@@ -2892,7 +3336,7 @@ export class ProviderBase {
|
|
|
2892
3336
|
}
|
|
2893
3337
|
// Field-level security on the batch cache-hit path — same reasoning as the
|
|
2894
3338
|
// single-view path in PreRunView: this result is returned without
|
|
2895
|
-
// PostRunViews projecting it, and
|
|
3339
|
+
// PostRunViews projecting it, and a slot can be wider than its class.
|
|
2896
3340
|
results = this.ApplyFieldSecurityProjection(results, param, contextUser);
|
|
2897
3341
|
results = this.ApplyRecordChangeFieldSecurityProjection(results, param, contextUser);
|
|
2898
3342
|
const cachedViewResult = {
|
|
@@ -2909,6 +3353,7 @@ export class ProviderBase {
|
|
|
2909
3353
|
};
|
|
2910
3354
|
// if needed this will transform each result into an entity object
|
|
2911
3355
|
await this.TransformSimpleObjectToEntityObject(param, cachedViewResult, contextUser);
|
|
3356
|
+
this.logDeprecatedAlternateBaseViewIfAny(param, 'PreRunViews');
|
|
2912
3357
|
if (!param.CacheLocal && this.TrustLocalCacheCompletely) {
|
|
2913
3358
|
LogStatusEx({ message: ` ✅ [Server Cache HIT] RunViews "${param.EntityName || param.ViewName || 'unknown'}" — ${cached.results.length} rows served from server cache (no DB query)`, verboseOnly: true });
|
|
2914
3359
|
}
|
|
@@ -2953,24 +3398,13 @@ export class ProviderBase {
|
|
|
2953
3398
|
for (let i = 0; i < params.length; i++) {
|
|
2954
3399
|
const param = params[i];
|
|
2955
3400
|
await this.EntityStatusCheck(param, 'PreRunViews', contextUser);
|
|
3401
|
+
let entity;
|
|
2956
3402
|
if (param.ResultType === 'entity_object') {
|
|
2957
|
-
|
|
3403
|
+
entity = this.EntityByName(param.EntityName);
|
|
2958
3404
|
if (!entity) {
|
|
2959
3405
|
throw new Error(`Entity ${param.EntityName} not found in metadata`);
|
|
2960
3406
|
}
|
|
2961
|
-
// NOTE (R2-GAP-1, logged as B44): widening to an explicit full field list makes
|
|
2962
|
-
// the client fingerprint carry `f:<every field>` rather than `f:*`, so the cache
|
|
2963
|
-
// classifier reads the slot as a NARROW projection and stops maintaining it in
|
|
2964
|
-
// place. entity_object is the BaseEngine default, so this costs in-place
|
|
2965
|
-
// maintenance on the client's most common slot shape. It is a PERF gap only —
|
|
2966
|
-
// the slot is invalidated and refetched, never served wrong.
|
|
2967
|
-
//
|
|
2968
|
-
// Deliberately NOT "fixed" by clearing Fields here: that would change what the
|
|
2969
|
-
// provider FETCHES, not just how the slot is keyed, and I could not verify the
|
|
2970
|
-
// downstream fetch behavior tonight. The safe fix is to normalize a full-coverage
|
|
2971
|
-
// field list to `*` in the FINGERPRINT only, which cannot affect fetching.
|
|
2972
3407
|
this.ResolveIncludeBinaryFields(param, entity);
|
|
2973
|
-
param.Fields = this.ComputeRunViewFetchFields(entity, param);
|
|
2974
3408
|
}
|
|
2975
3409
|
// Gate on runViewCacheEligible (NOT raw param.CacheLocal): the smart-cache-check path is a
|
|
2976
3410
|
// second, independent cache transport, and gating it on CacheLocal alone re-admits the exact
|
|
@@ -2978,9 +3412,16 @@ export class ProviderBase {
|
|
|
2978
3412
|
// BaseEntity event), count_only, AfterKey, BypassCache, cache-disallowed entities. On a client
|
|
2979
3413
|
// (!TrustLocalCacheCompletely) runViewCacheEligible already implies CacheLocal===true, so this is
|
|
2980
3414
|
// strictly a tightening — normal cacheable slots are unaffected.
|
|
3415
|
+
//
|
|
3416
|
+
// Compute clientCacheFingerprint BEFORE widening param.Fields for entity_object so the
|
|
3417
|
+
// fingerprint sees the caller's original Fields (normalizing to `|f:*` in O(1) without
|
|
3418
|
+
// building a full-entity field Set or misclassifying entities with excluded binary columns).
|
|
2981
3419
|
if (this.runViewCacheEligibleForWrite(param) && LocalCacheManager.Instance.IsInitialized) {
|
|
2982
3420
|
cacheable.push({ paramIndex: i, fingerprint: this.clientCacheFingerprint(param) });
|
|
2983
3421
|
}
|
|
3422
|
+
if (param.ResultType === 'entity_object' && entity) {
|
|
3423
|
+
param.Fields = this.ComputeRunViewFetchFields(entity, param);
|
|
3424
|
+
}
|
|
2984
3425
|
}
|
|
2985
3426
|
// Phase 2 — batched read: one IDB transaction (or one Redis MGET) returns
|
|
2986
3427
|
// every cached entry's status (maxUpdatedAt + rowCount). Was previously N
|
|
@@ -2997,6 +3438,8 @@ export class ProviderBase {
|
|
|
2997
3438
|
smartCacheCheckParams[paramIndex].cacheStatus = {
|
|
2998
3439
|
maxUpdatedAt: cached.maxUpdatedAt,
|
|
2999
3440
|
rowCount: cached.rowCount,
|
|
3441
|
+
// Lets the server answer "stale" when this user's columns changed but no row did.
|
|
3442
|
+
fieldSecurityKey: this.ComputeFieldSecurityCacheStatusKey(params[paramIndex], contextUser),
|
|
3000
3443
|
};
|
|
3001
3444
|
}
|
|
3002
3445
|
}
|
|
@@ -3005,7 +3448,8 @@ export class ProviderBase {
|
|
|
3005
3448
|
allCached: false, // Don't return cached directly - let server validate
|
|
3006
3449
|
useSmartCacheCheck: true,
|
|
3007
3450
|
smartCacheCheckParams,
|
|
3008
|
-
cacheStatusMap: new Map()
|
|
3451
|
+
cacheStatusMap: new Map(),
|
|
3452
|
+
preResolvedCache: cacheMap,
|
|
3009
3453
|
};
|
|
3010
3454
|
}
|
|
3011
3455
|
/**
|
|
@@ -3039,20 +3483,30 @@ export class ProviderBase {
|
|
|
3039
3483
|
UserViewRunID: ''
|
|
3040
3484
|
}));
|
|
3041
3485
|
}
|
|
3042
|
-
// ──
|
|
3043
|
-
//
|
|
3044
|
-
//
|
|
3045
|
-
//
|
|
3046
|
-
//
|
|
3047
|
-
|
|
3486
|
+
// ── Reuse pre-resolved cache map from prepareSmartCacheCheckParams ──
|
|
3487
|
+
// prepareSmartCacheCheckParams already read all cacheable fingerprints from
|
|
3488
|
+
// LocalCacheManager in a single batched IDB transaction. Reuse that map directly
|
|
3489
|
+
// so 'current' entries don't trigger a second IDB transaction + JSON.parse pass.
|
|
3490
|
+
// Only query LocalCacheManager for any 'current' fingerprint not already present
|
|
3491
|
+
// in preResult.preResolvedCache (e.g., direct test invocations).
|
|
3492
|
+
const preResolvedCache = preResult.preResolvedCache
|
|
3493
|
+
? new Map(preResult.preResolvedCache)
|
|
3494
|
+
: new Map();
|
|
3495
|
+
const missingFingerprints = [];
|
|
3048
3496
|
for (const sr of response.results) {
|
|
3049
3497
|
if (sr.status === 'current' && params[sr.viewIndex]) {
|
|
3050
|
-
|
|
3498
|
+
const fp = this.clientCacheFingerprint(params[sr.viewIndex]);
|
|
3499
|
+
if (!preResolvedCache.has(fp)) {
|
|
3500
|
+
missingFingerprints.push(fp);
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3503
|
+
}
|
|
3504
|
+
if (missingFingerprints.length > 0) {
|
|
3505
|
+
const fetched = await LocalCacheManager.Instance.GetRunViewResults(missingFingerprints);
|
|
3506
|
+
for (const [k, v] of fetched) {
|
|
3507
|
+
preResolvedCache.set(k, v);
|
|
3051
3508
|
}
|
|
3052
3509
|
}
|
|
3053
|
-
const preResolvedCache = currentFingerprints.length > 0
|
|
3054
|
-
? await LocalCacheManager.Instance.GetRunViewResults(currentFingerprints)
|
|
3055
|
-
: new Map();
|
|
3056
3510
|
// Pre-build index Map for server results lookup — avoids O(N^2) .find() scans below.
|
|
3057
3511
|
const serverResultsByViewIndex = new Map();
|
|
3058
3512
|
if (response.results) {
|
|
@@ -3325,14 +3779,15 @@ export class ProviderBase {
|
|
|
3325
3779
|
// Server-side auto-cache: small, unfiltered, unsorted results are
|
|
3326
3780
|
// automatically cached even without explicit CacheLocal. These are
|
|
3327
3781
|
// safe for in-place upsert on entity changes (no filter to evaluate).
|
|
3328
|
-
const fingerprint = preResult.fingerprint ||
|
|
3782
|
+
const fingerprint = preResult.fingerprint || this.ComputeRunViewCacheFingerprint(params, contextUser);
|
|
3329
3783
|
const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
|
|
3330
3784
|
await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
|
|
3331
3785
|
LogStatusEx({ message: ` 📦 [Auto-Cache] RunView "${params.EntityName || params.ViewName || 'unknown'}" — ${result.Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
|
|
3332
3786
|
}
|
|
3333
3787
|
// Project cache-miss DB results back down to the caller's originally requested
|
|
3334
|
-
// fields. PreRunView widened params.Fields to ALL entity fields
|
|
3335
|
-
//
|
|
3788
|
+
// fields. PreRunView widened params.Fields to ALL entity fields (less the columns this
|
|
3789
|
+
// user's permission class may not read) so the cache entry written above is a superset
|
|
3790
|
+
// for that class — but the caller must receive
|
|
3336
3791
|
// the same shape on a miss as they do on a hit (which projects from cache).
|
|
3337
3792
|
// Must run AFTER the cache writes (cache keeps the superset) and only for
|
|
3338
3793
|
// plain-object results (entity objects need all fields).
|
|
@@ -3340,10 +3795,9 @@ export class ProviderBase {
|
|
|
3340
3795
|
result.Results = ProjectRowsToFields(result.Results, preResult.callerRequestedFields);
|
|
3341
3796
|
}
|
|
3342
3797
|
// Field-level security on the cache-MISS path (the hit path is enforced in PreRunView,
|
|
3343
|
-
// which returns before this hook ever runs).
|
|
3344
|
-
// the
|
|
3345
|
-
//
|
|
3346
|
-
// read-time projection rather than something baked into the cached rows.
|
|
3798
|
+
// which returns before this hook ever runs). Runs AFTER the cache write above, as on the
|
|
3799
|
+
// hit path: the slot keeps what the database returned for this permission class, and
|
|
3800
|
+
// every reader is projected against live metadata.
|
|
3347
3801
|
if (result.Success) {
|
|
3348
3802
|
result.Results = this.ApplyFieldSecurityProjection(result.Results, params, contextUser);
|
|
3349
3803
|
result.Results = this.ApplyRecordChangeFieldSecurityProjection(result.Results, params, contextUser);
|
|
@@ -3402,7 +3856,7 @@ export class ProviderBase {
|
|
|
3402
3856
|
// per item. Compute lazily only for indexes PreRunViews skipped (cache
|
|
3403
3857
|
// was disabled for them but auto-cache/OnDataChanged may still need it).
|
|
3404
3858
|
const fingerprint = preResult.fingerprintMap?.get(i)
|
|
3405
|
-
??
|
|
3859
|
+
?? this.ComputeRunViewCacheFingerprint(params[i], contextUser);
|
|
3406
3860
|
// CRITICAL: must be the SAME eligibility predicate PreRunViews used to decide
|
|
3407
3861
|
// whether to widen Fields. Writing a non-widened (narrow or keyset-paged)
|
|
3408
3862
|
// result here poisons the Fields-agnostic superset slot — this exact gate
|
|
@@ -3424,7 +3878,8 @@ export class ProviderBase {
|
|
|
3424
3878
|
await Promise.all(cachePromises);
|
|
3425
3879
|
// Project cache-miss DB results back down to each caller's originally
|
|
3426
3880
|
// requested fields. PreRunViews widened those params' Fields to ALL entity
|
|
3427
|
-
// fields
|
|
3881
|
+
// fields (less what each user's permission class may not read) so the cache entries
|
|
3882
|
+
// written above are supersets for that class — but
|
|
3428
3883
|
// callers must receive the same shape on a miss as on a hit (which projects
|
|
3429
3884
|
// from cache). Skip hits (already projected) and entity_object results
|
|
3430
3885
|
// (need all fields).
|
|
@@ -3483,6 +3938,18 @@ export class ProviderBase {
|
|
|
3483
3938
|
});
|
|
3484
3939
|
}
|
|
3485
3940
|
}
|
|
3941
|
+
/**
|
|
3942
|
+
* Works out a stored view's property values ({@link RunViewParams.Properties},
|
|
3943
|
+
* {@link RunViewParams.ServerProperties}, and each property's default) before the run's cache
|
|
3944
|
+
* lookup, so the result-cache fingerprint keys on the values the run will actually use. Called
|
|
3945
|
+
* on the run's own params copy, after the PreRunView hooks. Must never throw: a run whose
|
|
3946
|
+
* properties would be refused is left for the provider's run path to refuse with its message.
|
|
3947
|
+
*
|
|
3948
|
+
* Does nothing here. A database provider, which resolves token defaults on the server, overrides it.
|
|
3949
|
+
*/
|
|
3950
|
+
async PrepareRunViewProperties(_params, _contextUser) {
|
|
3951
|
+
// The client sends Properties as they are; the server resolves them.
|
|
3952
|
+
}
|
|
3486
3953
|
/**
|
|
3487
3954
|
* Runs all registered PreRunView hooks against a single RunViewParams,
|
|
3488
3955
|
* returning the (possibly mutated) params.
|
|
@@ -3681,11 +4148,16 @@ export class ProviderBase {
|
|
|
3681
4148
|
return false;
|
|
3682
4149
|
// Only auto-cache unfiltered, unsorted queries — these are safe for
|
|
3683
4150
|
// in-place upsert because LocalCacheManager doesn't need to evaluate
|
|
3684
|
-
// SQL predicates or sort expressions.
|
|
4151
|
+
// SQL predicates or sort expressions. AlternateBaseView queries apply
|
|
4152
|
+
// a canonical SQL WHERE/JOIN and must not be auto-cached as unfiltered.
|
|
3685
4153
|
const filter = typeof params.ExtraFilter === 'string' ? params.ExtraFilter.trim() : '';
|
|
3686
4154
|
const orderBy = typeof params.OrderBy === 'string' ? params.OrderBy.trim() : '';
|
|
3687
4155
|
if (filter.length > 0 || orderBy.length > 0)
|
|
3688
4156
|
return false;
|
|
4157
|
+
if (GetEffectiveAlternateBaseViewName(params))
|
|
4158
|
+
return false;
|
|
4159
|
+
if (Boolean(params.ViewID || params.ViewName || params.ViewEntity))
|
|
4160
|
+
return false;
|
|
3689
4161
|
return true;
|
|
3690
4162
|
}
|
|
3691
4163
|
extractMaxUpdatedAt(results) {
|
|
@@ -4081,11 +4553,11 @@ export class ProviderBase {
|
|
|
4081
4553
|
// settings to give it, so a LATER caller that does (StartupManager, passing the host's
|
|
4082
4554
|
// cacheSettings) is not a no-op: Initialize applies that configuration to the already
|
|
4083
4555
|
// initialized manager rather than discarding it, which is what made cacheSettings inert
|
|
4084
|
-
// before.
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4556
|
+
// before. Kick off initialization without awaiting immediately so on warm boots it runs
|
|
4557
|
+
// concurrently with LoadLocalMetadataFromStorage() instead of adding a serial IDB round-trip.
|
|
4558
|
+
const localCacheInitPromise = !LocalCacheManager.Instance.IsInitialized
|
|
4559
|
+
? LocalCacheManager.Instance.Initialize(this.LocalStorageProvider)
|
|
4560
|
+
: Promise.resolve();
|
|
4089
4561
|
// first, let's check to see if we have an existing Metadata.Provider registered, if so
|
|
4090
4562
|
// unless our data.IgnoreExistingMetadata is set to true, we will not refresh the metadata.
|
|
4091
4563
|
//
|
|
@@ -4102,6 +4574,7 @@ export class ProviderBase {
|
|
|
4102
4574
|
// we have an existing globally registered provider AND we are not
|
|
4103
4575
|
// requested to ignore the existing metadata, so we will not refresh it
|
|
4104
4576
|
if (this.CopyMetadataFromGlobalProvider()) {
|
|
4577
|
+
await localCacheInitPromise;
|
|
4105
4578
|
return true; // we're done, if we fail here, we keep going and do normal logic
|
|
4106
4579
|
}
|
|
4107
4580
|
}
|
|
@@ -4115,9 +4588,9 @@ export class ProviderBase {
|
|
|
4115
4588
|
// This is a stale-while-revalidate pattern: serve cached data instantly, then
|
|
4116
4589
|
// refresh if the server says it's outdated.
|
|
4117
4590
|
if (!this.TrustLocalCacheCompletely && !hardRefresh && !this._localMetadata?.AllEntities?.length) {
|
|
4118
|
-
await this.LoadLocalMetadataFromStorage();
|
|
4591
|
+
await Promise.all([localCacheInitPromise, this.LoadLocalMetadataFromStorage()]);
|
|
4119
4592
|
if (this._localMetadata?.AllEntities?.length) {
|
|
4120
|
-
|
|
4593
|
+
LogStatus(`⚡ [Metadata Cache] Loaded ${this._localMetadata.AllEntities.length} entities from local cache — pre-validating before engine startup`);
|
|
4121
4594
|
// Do NOT kick off background validation here — it writes to IndexedDB
|
|
4122
4595
|
// which contends with engine RunView cache reads during StartupManager.
|
|
4123
4596
|
// Instead, the caller (setupGraphQLClient) should call
|
|
@@ -4125,6 +4598,9 @@ export class ProviderBase {
|
|
|
4125
4598
|
return true; // App can proceed immediately with cached metadata
|
|
4126
4599
|
}
|
|
4127
4600
|
}
|
|
4601
|
+
else {
|
|
4602
|
+
await localCacheInitPromise;
|
|
4603
|
+
}
|
|
4128
4604
|
// An empty graph has nothing to throttle: the throttle exists to stop redundant checks of a
|
|
4129
4605
|
// snapshot we already hold. After a status check succeeded but the download that followed
|
|
4130
4606
|
// failed, the throttle is armed and a non-bypassing retry would read "current" and load
|
|
@@ -4132,42 +4608,77 @@ export class ProviderBase {
|
|
|
4132
4608
|
const graphIsEmpty = !this._localMetadata?.AllEntities?.length;
|
|
4133
4609
|
if (hardRefresh || await this.CheckToSeeIfRefreshNeeded(providerToUse, graphIsEmpty)) {
|
|
4134
4610
|
// either a hard refresh flag was set within Refresh(), or LocalMetadata is Obsolete
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4611
|
+
return this.ReloadMetadata(hardRefresh, providerToUse);
|
|
4612
|
+
}
|
|
4613
|
+
return true;
|
|
4614
|
+
}
|
|
4615
|
+
/**
|
|
4616
|
+
* Reloads this provider's metadata. Single-flight: at most one reload runs at a time, and a
|
|
4617
|
+
* request arriving mid-reload queues exactly one follow-up pass.
|
|
4618
|
+
* @param hardRefresh - re-read the remote timestamps first and bypass every cache layer
|
|
4619
|
+
* @param scopedFirst - try the scoped reload of the saved-query family (see
|
|
4620
|
+
* {@link PartialRefreshMetadataItemCodes}) before the full one. Defaults to true unless
|
|
4621
|
+
* hardRefresh: an explicit Refresh() is always a full reload.
|
|
4622
|
+
*/
|
|
4623
|
+
async ReloadMetadata(hardRefresh, providerToUse, scopedFirst = !hardRefresh) {
|
|
4624
|
+
// first, make sure we reset the flag to false so that if another call to this function happens
|
|
4625
|
+
// while we are waiting for the async call to finish, we dont do it again
|
|
4626
|
+
this._refresh = false;
|
|
4627
|
+
// SINGLE-FLIGHT: at most one full metadata reload runs at a time. Without this, a
|
|
4628
|
+
// second refresh request arriving while a reload is still awaiting its queries starts
|
|
4629
|
+
// a CONCURRENT reload, and whichever finishes LAST wins the atomic swap — an older
|
|
4630
|
+
// snapshot can overwrite a newer one. A joiner must not simply await and return,
|
|
4631
|
+
// either: the in-flight reload's queries may predate the write that prompted the
|
|
4632
|
+
// joiner, so it flags ONE follow-up; the loop below reruns after the current pass,
|
|
4633
|
+
// guaranteeing the final swap comes from a read that started after the last request.
|
|
4634
|
+
if (this._metadataReloadInFlight) {
|
|
4635
|
+
this._metadataReloadQueued = true;
|
|
4636
|
+
if (this._metadataReadsJoinTransaction) {
|
|
4637
|
+
// Caller owns an open database transaction (RefreshWithinTransaction) and is
|
|
4638
|
+
// waiting for this reload before it can COMMIT or ROLLBACK. An in-flight pool
|
|
4639
|
+
// reload may be blocked in the database on the caller's own uncommitted locks
|
|
4640
|
+
// (LCK_M_S), so awaiting _metadataReloadInFlight here would self-deadlock.
|
|
4641
|
+
// Run the transactional reload directly on the ambient transaction; setting
|
|
4642
|
+
// _metadataReloadQueued = true above ensures the pool reload re-reads clean
|
|
4643
|
+
// committed state once the transaction finishes and releases its locks.
|
|
4644
|
+
await this.RefreshRemoteMetadataTimestamps(providerToUse);
|
|
4645
|
+
const res = await this.GetAllMetadata(providerToUse, true);
|
|
4646
|
+
if (res) {
|
|
4647
|
+
await this.adoptServerMetadata(res);
|
|
4648
|
+
this._configLoadedFromServer = true;
|
|
4649
|
+
}
|
|
4148
4650
|
return true;
|
|
4149
4651
|
}
|
|
4150
|
-
this._metadataReloadInFlight
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4652
|
+
await this._metadataReloadInFlight;
|
|
4653
|
+
return true;
|
|
4654
|
+
}
|
|
4655
|
+
this._metadataReloadInFlight = (async () => {
|
|
4656
|
+
let effectiveHardRefresh = hardRefresh;
|
|
4657
|
+
let tryScoped = scopedFirst;
|
|
4658
|
+
do {
|
|
4659
|
+
this._metadataReloadQueued = false;
|
|
4660
|
+
// The local timestamps must describe the snapshot about to be loaded. On the
|
|
4661
|
+
// hard-refresh path the staleness check was SKIPPED, so the cached remote
|
|
4662
|
+
// timestamps predate this pass — copying them as-is would make the next
|
|
4663
|
+
// periodic check see a mismatch and reload once more for nothing. Re-read
|
|
4664
|
+
// them (one cheap status query, authoritative) BEFORE the load, not after:
|
|
4665
|
+
// a write landing DURING the load then leaves the stamped timestamps looking
|
|
4666
|
+
// stale and the next tick reloads — the safe direction. Reading after could
|
|
4667
|
+
// stamp the snapshot as containing a write it does not.
|
|
4668
|
+
if (effectiveHardRefresh) {
|
|
4669
|
+
await this.RefreshRemoteMetadataTimestamps(providerToUse);
|
|
4670
|
+
}
|
|
4671
|
+
// SCOPED FIRST: when the staleness is attributable entirely to the saved-query family
|
|
4672
|
+
// (see PartialRefreshMetadataItemCodes), reload just that family and keep every other
|
|
4673
|
+
// array as is. Anything else — or a failed scoped read — takes the full reload below.
|
|
4674
|
+
const scopedDone = tryScoped && await this.TryPartialMetadataReload(providerToUse);
|
|
4675
|
+
if (!scopedDone) {
|
|
4165
4676
|
// Fetch new metadata without clearing current metadata
|
|
4166
4677
|
// This ensures readers always see valid data (old until new is ready)
|
|
4167
4678
|
const start = new Date().getTime();
|
|
4168
4679
|
const res = await this.GetAllMetadata(providerToUse, effectiveHardRefresh);
|
|
4169
4680
|
const end = new Date().getTime();
|
|
4170
|
-
|
|
4681
|
+
LogStatus(`GetAllMetadata() took ${end - start} ms`);
|
|
4171
4682
|
if (res) {
|
|
4172
4683
|
await this.adoptServerMetadata(res);
|
|
4173
4684
|
this._configLoadedFromServer = true;
|
|
@@ -4176,23 +4687,27 @@ export class ProviderBase {
|
|
|
4176
4687
|
// GetAllMetadata failed - log error but keep existing metadata
|
|
4177
4688
|
LogError('GetAllMetadata() returned undefined - metadata not updated');
|
|
4178
4689
|
}
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
}
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4690
|
+
}
|
|
4691
|
+
// A queued follow-up exists only because another refresh request arrived
|
|
4692
|
+
// mid-reload; rerun hard so the re-read cannot be served by any cache layer.
|
|
4693
|
+
effectiveHardRefresh = true;
|
|
4694
|
+
// The follow-up re-reads the timestamps first, so it may still be attributed to the
|
|
4695
|
+
// saved-query family — and a scoped read never trusts a cache layer either.
|
|
4696
|
+
tryScoped = true;
|
|
4697
|
+
} while (this._metadataReloadQueued);
|
|
4698
|
+
})();
|
|
4699
|
+
try {
|
|
4700
|
+
await this._metadataReloadInFlight;
|
|
4701
|
+
}
|
|
4702
|
+
finally {
|
|
4703
|
+
this._metadataReloadInFlight = null;
|
|
4190
4704
|
}
|
|
4191
4705
|
return true;
|
|
4192
4706
|
}
|
|
4193
4707
|
/**
|
|
4194
4708
|
* Adopts a metadata snapshot just loaded from the server: swaps it in, records that the
|
|
4195
|
-
* local copy now matches the server's timestamps
|
|
4709
|
+
* local copy now matches the server's timestamps (verified per item against the received
|
|
4710
|
+
* dataset row counts), and persists it to local storage.
|
|
4196
4711
|
*
|
|
4197
4712
|
* The swap goes through UpdateLocalMetadata — a single property assignment, atomic in
|
|
4198
4713
|
* JavaScript, so readers see the old snapshot until the new one is complete — which keeps
|
|
@@ -4200,9 +4715,240 @@ export class ProviderBase {
|
|
|
4200
4715
|
*/
|
|
4201
4716
|
async adoptServerMetadata(res) {
|
|
4202
4717
|
this.UpdateLocalMetadata(res);
|
|
4203
|
-
this._latestLocalMetadataTimestamps = this.
|
|
4718
|
+
this._latestLocalMetadataTimestamps = this.reconcileAdoptedMetadataTimestamps(res);
|
|
4204
4719
|
await this.SaveLocalMetadataToStorage();
|
|
4205
4720
|
}
|
|
4721
|
+
/**
|
|
4722
|
+
* Verifies that each item in `_latestRemoteMetadataTimestamps` matches the row count actually
|
|
4723
|
+
* received in the loaded `MJ_Metadata` dataset (or `AllMetadata` collections) before advancing
|
|
4724
|
+
* `_latestLocalMetadataTimestamps`. If any item has a mismatched row count (e.g., because a
|
|
4725
|
+
* server cache slot returned stale rows while the SQL status probe reported newer counts),
|
|
4726
|
+
* logs a warning and preserves the unadvanced local timestamp for that item so the next
|
|
4727
|
+
* staleness check (`LocalMetadataObsolete`) detects the discrepancy and self-heals.
|
|
4728
|
+
*/
|
|
4729
|
+
reconcileAdoptedMetadataTimestamps(res) {
|
|
4730
|
+
const remote = this._latestRemoteMetadataTimestamps;
|
|
4731
|
+
if (!remote || remote.length === 0) {
|
|
4732
|
+
return remote;
|
|
4733
|
+
}
|
|
4734
|
+
const norm = (v) => (v ?? '').trim().toLowerCase();
|
|
4735
|
+
const rollup = norm(ProviderBase._allMetadataTypeName);
|
|
4736
|
+
const prevLocal = this._latestLocalMetadataTimestamps ?? [];
|
|
4737
|
+
const datasetItems = this._metadataDatasetItems;
|
|
4738
|
+
const resRecord = res;
|
|
4739
|
+
let hasMismatch = false;
|
|
4740
|
+
const reconciled = [];
|
|
4741
|
+
const datasetItemsByEntityID = datasetItems && datasetItems.length > 0
|
|
4742
|
+
? IndexByUUID(datasetItems.filter(item => Boolean(item.EntityID)), item => item.EntityID)
|
|
4743
|
+
: null;
|
|
4744
|
+
const datasetItemsByNameOrCode = new Map();
|
|
4745
|
+
if (datasetItems && datasetItems.length > 0) {
|
|
4746
|
+
for (const item of datasetItems) {
|
|
4747
|
+
if (item.EntityName)
|
|
4748
|
+
datasetItemsByNameOrCode.set(norm(item.EntityName), item);
|
|
4749
|
+
if (item.Code)
|
|
4750
|
+
datasetItemsByNameOrCode.set(norm(item.Code), item);
|
|
4751
|
+
}
|
|
4752
|
+
}
|
|
4753
|
+
for (const remoteEntry of remote) {
|
|
4754
|
+
if (norm(remoteEntry.Type) === rollup) {
|
|
4755
|
+
continue;
|
|
4756
|
+
}
|
|
4757
|
+
let receivedRowCount;
|
|
4758
|
+
let receivedUpdateDate;
|
|
4759
|
+
if (datasetItems && datasetItems.length > 0) {
|
|
4760
|
+
const match = (remoteEntry.ID ? datasetItemsByEntityID?.Get(remoteEntry.ID) : undefined)
|
|
4761
|
+
?? datasetItemsByNameOrCode.get(norm(remoteEntry.Type));
|
|
4762
|
+
if (match && match.RowCount !== undefined) {
|
|
4763
|
+
receivedRowCount = match.RowCount;
|
|
4764
|
+
receivedUpdateDate = match.LatestUpdateDate;
|
|
4765
|
+
}
|
|
4766
|
+
}
|
|
4767
|
+
else if (resRecord) {
|
|
4768
|
+
// Fallback when adoptServerMetadata is invoked without a preceding dataset load
|
|
4769
|
+
const arrayMatch = AllMetadataArrays.find(m => {
|
|
4770
|
+
const code = m.key.substring(3);
|
|
4771
|
+
return norm(code) === norm(remoteEntry.Type) ||
|
|
4772
|
+
norm(m.key) === norm(remoteEntry.Type) ||
|
|
4773
|
+
norm(`MJ: ${code}`) === norm(remoteEntry.Type);
|
|
4774
|
+
});
|
|
4775
|
+
if (arrayMatch) {
|
|
4776
|
+
const arr = resRecord[arrayMatch.key];
|
|
4777
|
+
if (Array.isArray(arr)) {
|
|
4778
|
+
receivedRowCount = arr.length;
|
|
4779
|
+
}
|
|
4780
|
+
}
|
|
4781
|
+
}
|
|
4782
|
+
if (receivedRowCount !== undefined &&
|
|
4783
|
+
remoteEntry.RowCount !== undefined &&
|
|
4784
|
+
receivedRowCount !== remoteEntry.RowCount) {
|
|
4785
|
+
hasMismatch = true;
|
|
4786
|
+
LogWarning(`[Metadata Cache] Row count mismatch when adopting server metadata for "${remoteEntry.Type}": ` +
|
|
4787
|
+
`received ${receivedRowCount} rows but remote status reported ${remoteEntry.RowCount} rows. ` +
|
|
4788
|
+
`Not advancing local timestamp for "${remoteEntry.Type}" so the next check self-heals.`, 'Metadata');
|
|
4789
|
+
const priorEntry = prevLocal.find(m => norm(m.Type) === norm(remoteEntry.Type));
|
|
4790
|
+
if (priorEntry) {
|
|
4791
|
+
reconciled.push({
|
|
4792
|
+
...priorEntry,
|
|
4793
|
+
RowCount: receivedRowCount
|
|
4794
|
+
});
|
|
4795
|
+
}
|
|
4796
|
+
else {
|
|
4797
|
+
const fallbackDate = receivedUpdateDate &&
|
|
4798
|
+
remoteEntry.UpdatedAt &&
|
|
4799
|
+
new Date(receivedUpdateDate).getTime() !== new Date(remoteEntry.UpdatedAt).getTime()
|
|
4800
|
+
? receivedUpdateDate
|
|
4801
|
+
: new Date(0);
|
|
4802
|
+
reconciled.push({
|
|
4803
|
+
...remoteEntry,
|
|
4804
|
+
UpdatedAt: fallbackDate,
|
|
4805
|
+
RowCount: receivedRowCount
|
|
4806
|
+
});
|
|
4807
|
+
}
|
|
4808
|
+
}
|
|
4809
|
+
else {
|
|
4810
|
+
reconciled.push(remoteEntry);
|
|
4811
|
+
}
|
|
4812
|
+
}
|
|
4813
|
+
if (!hasMismatch) {
|
|
4814
|
+
return remote;
|
|
4815
|
+
}
|
|
4816
|
+
const remoteRollup = remote.find(r => norm(r.Type) === rollup);
|
|
4817
|
+
if (remoteRollup) {
|
|
4818
|
+
const priorRollup = prevLocal.find(m => norm(m.Type) === rollup);
|
|
4819
|
+
const totalReconciledRows = reconciled.reduce((sum, item) => sum + (item.RowCount ?? 0), 0);
|
|
4820
|
+
reconciled.push({
|
|
4821
|
+
...remoteRollup,
|
|
4822
|
+
UpdatedAt: priorRollup?.UpdatedAt ?? new Date(0),
|
|
4823
|
+
RowCount: totalReconciledRows
|
|
4824
|
+
});
|
|
4825
|
+
}
|
|
4826
|
+
return reconciled;
|
|
4827
|
+
}
|
|
4828
|
+
/**
|
|
4829
|
+
* Decides whether the current staleness can be served by a scoped reload of the saved-query
|
|
4830
|
+
* family. Compares local and remote timestamps item by item (the same test as
|
|
4831
|
+
* {@link LocalMetadataObsolete}) and maps each stale entity back to its MJ_Metadata item code.
|
|
4832
|
+
* @returns the family's item codes (the whole family — the query classes are swapped as a set)
|
|
4833
|
+
* when every stale item belongs to it; null when anything else is stale, nothing is, or the
|
|
4834
|
+
* inputs needed to attribute the change are missing — the full reload is then the answer.
|
|
4835
|
+
*/
|
|
4836
|
+
PartialRefreshItemCodes() {
|
|
4837
|
+
const items = this._metadataDatasetItems;
|
|
4838
|
+
const local = this.LatestLocalMetadata;
|
|
4839
|
+
const remote = this.LatestRemoteMetadata;
|
|
4840
|
+
if (!items?.length || !this._localMetadata?.AllEntities?.length || !local?.length || !remote?.length) {
|
|
4841
|
+
return null;
|
|
4842
|
+
}
|
|
4843
|
+
const family = new Set(ProviderBase.PartialRefreshMetadataItemCodes);
|
|
4844
|
+
const norm = (v) => (v ?? '').trim().toLowerCase();
|
|
4845
|
+
const rollup = norm(ProviderBase._allMetadataTypeName);
|
|
4846
|
+
let stale = 0;
|
|
4847
|
+
for (const r of remote) {
|
|
4848
|
+
if (norm(r.Type) === rollup) {
|
|
4849
|
+
continue; // the roll-up moves with any item; the per-item rows decide
|
|
4850
|
+
}
|
|
4851
|
+
const l = local.find(m => norm(m.Type) === norm(r.Type));
|
|
4852
|
+
if (l && !ProviderBase.metadataStampChanged(l, r)) {
|
|
4853
|
+
continue;
|
|
4854
|
+
}
|
|
4855
|
+
const owners = items.filter(i => norm(i.EntityName) === norm(r.Type));
|
|
4856
|
+
if (owners.length === 0 || owners.some(o => !family.has(o.Code))) {
|
|
4857
|
+
return null; // something outside the family moved, or a new item appeared
|
|
4858
|
+
}
|
|
4859
|
+
stale++;
|
|
4860
|
+
}
|
|
4861
|
+
if (local.some(l => norm(l.Type) !== rollup && !remote.some(r => norm(r.Type) === norm(l.Type)))) {
|
|
4862
|
+
return null; // an item left the dataset
|
|
4863
|
+
}
|
|
4864
|
+
if (stale === 0) {
|
|
4865
|
+
return null; // nothing attributable — the caller's own policy decides
|
|
4866
|
+
}
|
|
4867
|
+
const codes = [...new Set(items.map(i => i.Code).filter(c => family.has(c)))];
|
|
4868
|
+
return codes.length > 0 ? codes : null;
|
|
4869
|
+
}
|
|
4870
|
+
static metadataStampChanged(local, remote) {
|
|
4871
|
+
if (!local.UpdatedAt && !remote.UpdatedAt) {
|
|
4872
|
+
return false;
|
|
4873
|
+
}
|
|
4874
|
+
if (!local.UpdatedAt || !remote.UpdatedAt) {
|
|
4875
|
+
return true;
|
|
4876
|
+
}
|
|
4877
|
+
return new Date(local.UpdatedAt).getTime() !== new Date(remote.UpdatedAt).getTime() || local.RowCount !== remote.RowCount;
|
|
4878
|
+
}
|
|
4879
|
+
/**
|
|
4880
|
+
* Reloads only the saved-query family of MJ_Metadata and swaps it into the current metadata,
|
|
4881
|
+
* keeping every other array — and every object in them — as is. The dataset definition is
|
|
4882
|
+
* reused verbatim (same columns, where clauses and post-processing): every other item is
|
|
4883
|
+
* requested with a `1=0` filter, so it reads nothing. Always bypasses cache layers — the read
|
|
4884
|
+
* is small, and a cached answer is exactly what a staleness check cannot trust.
|
|
4885
|
+
* @returns true when the scoped reload was applied; false when it does not apply or any part
|
|
4886
|
+
* of it failed — the caller then runs the full reload, so a failure never leaves stale queries.
|
|
4887
|
+
*/
|
|
4888
|
+
async TryPartialMetadataReload(providerToUse) {
|
|
4889
|
+
const codes = this.PartialRefreshItemCodes();
|
|
4890
|
+
const current = this._localMetadata;
|
|
4891
|
+
const items = this._metadataDatasetItems;
|
|
4892
|
+
if (!codes || !current || !items) {
|
|
4893
|
+
return false;
|
|
4894
|
+
}
|
|
4895
|
+
try {
|
|
4896
|
+
const keep = new Set(codes);
|
|
4897
|
+
const skipOthers = items
|
|
4898
|
+
.filter(item => !keep.has(item.Code))
|
|
4899
|
+
.map(item => ({ ItemCode: item.Code, Filter: '1=0' }));
|
|
4900
|
+
const start = Date.now();
|
|
4901
|
+
const d = await this.GetDatasetByName(ProviderBase._mjMetadataDatasetName, skipOthers, this.CurrentUser, providerToUse, true);
|
|
4902
|
+
if (!d?.Success) {
|
|
4903
|
+
LogStatusEx({ message: `[Metadata] Scoped reload of ${codes.join(', ')} failed (${d?.Status ?? 'no result'}); running the full reload`, verboseOnly: true });
|
|
4904
|
+
return false;
|
|
4905
|
+
}
|
|
4906
|
+
const simple = {};
|
|
4907
|
+
for (const code of codes) {
|
|
4908
|
+
const item = d.Results?.find(r => r.Code === code);
|
|
4909
|
+
if (!item || item.Success === false || !Array.isArray(item.Results)) {
|
|
4910
|
+
LogStatusEx({ message: `[Metadata] Scoped reload: item ${code} ${item ? `failed (${item.Status ?? 'no rows'})` : 'missing'}; running the full reload`, verboseOnly: true });
|
|
4911
|
+
return false;
|
|
4912
|
+
}
|
|
4913
|
+
simple[code] = item.Results;
|
|
4914
|
+
}
|
|
4915
|
+
const reloaded = MetadataFromSimpleObjectWithoutUser(simple, this);
|
|
4916
|
+
if (!reloaded) {
|
|
4917
|
+
return false;
|
|
4918
|
+
}
|
|
4919
|
+
const next = new AllMetadata();
|
|
4920
|
+
next.CurrentUser = current.CurrentUser;
|
|
4921
|
+
const target = next;
|
|
4922
|
+
const kept = current;
|
|
4923
|
+
const fresh = reloaded;
|
|
4924
|
+
for (const m of AllMetadataArrays) {
|
|
4925
|
+
target[m.key] = keep.has(m.key.substring(3)) ? fresh[m.key] : kept[m.key];
|
|
4926
|
+
}
|
|
4927
|
+
this._metadataDatasetItems = items.map(existing => {
|
|
4928
|
+
if (!keep.has(existing.Code)) {
|
|
4929
|
+
return existing;
|
|
4930
|
+
}
|
|
4931
|
+
const updated = d.Results?.find(r => r.Code === existing.Code);
|
|
4932
|
+
return updated && Array.isArray(updated.Results)
|
|
4933
|
+
? {
|
|
4934
|
+
...existing,
|
|
4935
|
+
RowCount: updated.Results.length,
|
|
4936
|
+
LatestUpdateDate: updated.LatestUpdateDate ?? existing.LatestUpdateDate
|
|
4937
|
+
}
|
|
4938
|
+
: existing;
|
|
4939
|
+
});
|
|
4940
|
+
// Adopted like every other server snapshot. _configLoadedFromServer is NOT set: this read
|
|
4941
|
+
// fetched no current user (the existing one is carried over), so the next pre-validation
|
|
4942
|
+
// must still run its check.
|
|
4943
|
+
await this.adoptServerMetadata(next);
|
|
4944
|
+
LogStatusEx({ message: `[Metadata] Reloaded ${codes.join(', ')} only in ${Date.now() - start} ms; the rest of the metadata is unchanged`, verboseOnly: true });
|
|
4945
|
+
return true;
|
|
4946
|
+
}
|
|
4947
|
+
catch (e) {
|
|
4948
|
+
LogError(`[Metadata] Scoped reload failed; running the full reload: ${e instanceof Error ? e.message : String(e)}`);
|
|
4949
|
+
return false;
|
|
4950
|
+
}
|
|
4951
|
+
}
|
|
4206
4952
|
/**
|
|
4207
4953
|
* Background validation for the stale-while-revalidate fast-start pattern.
|
|
4208
4954
|
* Checks if local metadata is still current; if stale, fetches fresh metadata
|
|
@@ -4213,17 +4959,17 @@ export class ProviderBase {
|
|
|
4213
4959
|
try {
|
|
4214
4960
|
const needsRefresh = await this.CheckToSeeIfRefreshNeeded(providerToUse);
|
|
4215
4961
|
if (needsRefresh) {
|
|
4216
|
-
LogStatusEx({ message: `⚡ [Metadata Cache] Background check: metadata is stale — refreshing...`, verboseOnly:
|
|
4962
|
+
LogStatusEx({ message: `⚡ [Metadata Cache] Background check: metadata is stale — refreshing...`, verboseOnly: true });
|
|
4217
4963
|
const start = Date.now();
|
|
4218
4964
|
const res = await this.GetAllMetadata(providerToUse, false);
|
|
4219
4965
|
const elapsed = Date.now() - start;
|
|
4220
4966
|
if (res) {
|
|
4221
4967
|
await this.adoptServerMetadata(res);
|
|
4222
|
-
LogStatusEx({ message: `⚡ [Metadata Cache] Background refresh complete (${elapsed}ms) — metadata updated in place`, verboseOnly:
|
|
4968
|
+
LogStatusEx({ message: `⚡ [Metadata Cache] Background refresh complete (${elapsed}ms) — metadata updated in place`, verboseOnly: true });
|
|
4223
4969
|
}
|
|
4224
4970
|
}
|
|
4225
4971
|
else {
|
|
4226
|
-
LogStatusEx({ message: `⚡ [Metadata Cache] Background check: metadata is current — no refresh needed`, verboseOnly:
|
|
4972
|
+
LogStatusEx({ message: `⚡ [Metadata Cache] Background check: metadata is current — no refresh needed`, verboseOnly: true });
|
|
4227
4973
|
await this.RefreshCurrentUser();
|
|
4228
4974
|
}
|
|
4229
4975
|
}
|
|
@@ -4266,22 +5012,24 @@ export class ProviderBase {
|
|
|
4266
5012
|
return;
|
|
4267
5013
|
}
|
|
4268
5014
|
try {
|
|
4269
|
-
|
|
5015
|
+
// Run the staleness check and CurrentUser refresh concurrently on warm boot so
|
|
5016
|
+
// when metadata is current (99%+ of warm loads) we eliminate a sequential round-trip.
|
|
5017
|
+
const [needsRefresh] = await Promise.all([
|
|
5018
|
+
this.CheckToSeeIfRefreshNeeded(providerToUse),
|
|
5019
|
+
this.RefreshCurrentUser(),
|
|
5020
|
+
]);
|
|
4270
5021
|
if (needsRefresh) {
|
|
4271
|
-
|
|
5022
|
+
LogStatus(`⚡ [Metadata Cache] Pre-validation: metadata is stale — refreshing before engine startup`);
|
|
4272
5023
|
const start = Date.now();
|
|
4273
5024
|
const res = await this.GetAllMetadata(providerToUse, false);
|
|
4274
5025
|
const elapsed = Date.now() - start;
|
|
4275
5026
|
if (res) {
|
|
4276
5027
|
await this.adoptServerMetadata(res);
|
|
4277
|
-
|
|
5028
|
+
LogStatus(`⚡ [Metadata Cache] Pre-validation refresh complete (${elapsed}ms)`);
|
|
4278
5029
|
}
|
|
4279
5030
|
}
|
|
4280
5031
|
else {
|
|
4281
|
-
|
|
4282
|
-
// Even when entity metadata is current, refresh CurrentUser so user roles and permissions
|
|
4283
|
-
// stay in sync with the database without requiring a schema change or manual cache clear.
|
|
4284
|
-
await this.RefreshCurrentUser();
|
|
5032
|
+
LogStatus(`⚡ [Metadata Cache] Pre-validation: metadata is current`);
|
|
4285
5033
|
}
|
|
4286
5034
|
}
|
|
4287
5035
|
catch (e) {
|
|
@@ -4332,6 +5080,15 @@ export class ProviderBase {
|
|
|
4332
5080
|
* were on the client's global provider): treat Info objects and everything
|
|
4333
5081
|
* they own as read-only; copy before sorting.
|
|
4334
5082
|
*
|
|
5083
|
+
* Owning provider: the shared Info objects deliberately keep the
|
|
5084
|
+
* {@link BaseInfo.OwningProvider} of the provider that built them (the source
|
|
5085
|
+
* global provider); the shell does NOT re-stamp them. Re-stamping a shared
|
|
5086
|
+
* instance would re-point it for every other provider holding it. The sibling
|
|
5087
|
+
* lookups it drives (IS-A children, RLS filters, query fields, ...) therefore
|
|
5088
|
+
* read the source provider's graph — the same immutable graph this shell
|
|
5089
|
+
* points into, so the answers are identical. A provider that needs its own
|
|
5090
|
+
* graph (a different database) must load its own metadata, not adopt a shell.
|
|
5091
|
+
*
|
|
4335
5092
|
* Override precedence: if a subclass overrides BOTH this method and the
|
|
4336
5093
|
* deprecated {@link CloneAllMetadata}, the CloneAllMetadata override wins on
|
|
4337
5094
|
* the fast path (see {@link CopyMetadataFromGlobalProvider}) — the
|
|
@@ -4339,18 +5096,26 @@ export class ProviderBase {
|
|
|
4339
5096
|
* customized adoption through CloneAllMetadata. Remove the CloneAllMetadata
|
|
4340
5097
|
* override to activate a CreateSharedMetadataShell override.
|
|
4341
5098
|
*/
|
|
5099
|
+
/**
|
|
5100
|
+
* Builds this instance's AllMetadata as a zero-copy prototype-delegated shell over
|
|
5101
|
+
* another provider's already-loaded metadata (`Object.create(shared)`), keeping
|
|
5102
|
+
* `CurrentUser` as an own property on the shell instance.
|
|
5103
|
+
*
|
|
5104
|
+
* Why zero-copy prototype delegation is safe: the metadata graph (`AllMetadata` and its
|
|
5105
|
+
* arrays) is treated as immutable after `Config`. Refreshes swap the entire `AllMetadata`
|
|
5106
|
+
* reference via `UpdateLocalMetadata` rather than mutating the previous `AllMetadata`
|
|
5107
|
+
* instance in place, so in-flight shells continue reading a consistent snapshot while
|
|
5108
|
+
* avoiding 19 array shallow-copies on every per-request provider creation.
|
|
5109
|
+
*/
|
|
4342
5110
|
CreateSharedMetadataShell(shared) {
|
|
4343
|
-
const shell =
|
|
4344
|
-
for (const m of AllMetadataArrays) {
|
|
4345
|
-
shell[m.key] = [...(shared[m.key] ?? [])];
|
|
4346
|
-
}
|
|
5111
|
+
const shell = Object.create(shared);
|
|
4347
5112
|
shell.CurrentUser = this.CurrentUser; // same semantics the deep clone had — per-instance, not shared
|
|
4348
5113
|
return shell;
|
|
4349
5114
|
}
|
|
4350
5115
|
/**
|
|
4351
5116
|
* Adopts the global provider's metadata for this instance without reloading it
|
|
4352
5117
|
* from the server: shares the (immutable post-Config) metadata arrays by
|
|
4353
|
-
* reference via {@link CreateSharedMetadataShell} and builds this instance's
|
|
5118
|
+
* reference via {@link CreateSharedMetadataShell} and shares or builds this instance's
|
|
4354
5119
|
* entity lookup maps.
|
|
4355
5120
|
*/
|
|
4356
5121
|
CopyMetadataFromGlobalProvider() {
|
|
@@ -4359,7 +5124,8 @@ export class ProviderBase {
|
|
|
4359
5124
|
// registered-but-not-yet-configured global would otherwise donate an empty graph
|
|
4360
5125
|
// and this Config would "succeed" with zero entities. Falling through to the
|
|
4361
5126
|
// normal load path is the correct behavior in that case.
|
|
4362
|
-
const
|
|
5127
|
+
const globalProvider = Metadata.Provider !== this ? Metadata.Provider : undefined; // global-provider-ok: this method adopts metadata FROM the global provider on bootstrap
|
|
5128
|
+
const globalMetadata = globalProvider?.AllMetadata;
|
|
4363
5129
|
if ((globalMetadata?.AllEntities?.length ?? 0) > 0) {
|
|
4364
5130
|
// Back-compat: before #3083 this path called the overridable CloneAllMetadata,
|
|
4365
5131
|
// so external subclasses could customize adoption (e.g. tenant-filtered deep
|
|
@@ -4369,7 +5135,17 @@ export class ProviderBase {
|
|
|
4369
5135
|
const adopted = subclassOverridesClone
|
|
4370
5136
|
? this.CloneAllMetadata(globalMetadata)
|
|
4371
5137
|
: this.CreateSharedMetadataShell(globalMetadata);
|
|
4372
|
-
|
|
5138
|
+
if (!subclassOverridesClone &&
|
|
5139
|
+
globalProvider instanceof ProviderBase &&
|
|
5140
|
+
adopted.AllEntities === globalMetadata.AllEntities &&
|
|
5141
|
+
globalProvider._entityMapByName.size > 0) {
|
|
5142
|
+
this._localMetadata = adopted;
|
|
5143
|
+
this._entityMapByName = globalProvider._entityMapByName;
|
|
5144
|
+
this._entityMapByID = globalProvider._entityMapByID;
|
|
5145
|
+
}
|
|
5146
|
+
else {
|
|
5147
|
+
this.UpdateLocalMetadata(adopted);
|
|
5148
|
+
}
|
|
4373
5149
|
return true;
|
|
4374
5150
|
}
|
|
4375
5151
|
return false;
|
|
@@ -4451,7 +5227,7 @@ export class ProviderBase {
|
|
|
4451
5227
|
simpleMetadata[r.Code] = r.Results;
|
|
4452
5228
|
}
|
|
4453
5229
|
// Post Process Entities because there's some special handling of the sub-objects
|
|
4454
|
-
simpleMetadata.AllEntities = this.PostProcessEntityMetadata(simpleMetadata.Entities, simpleMetadata.EntityFields, simpleMetadata.EntityFieldValues, simpleMetadata.EntityPermissions, simpleMetadata.EntityRelationships, simpleMetadata.EntitySettings, simpleMetadata.EntityOrganicKeys, simpleMetadata.EntityOrganicKeyRelatedEntities, simpleMetadata.EntityFieldPermissions);
|
|
5230
|
+
simpleMetadata.AllEntities = this.PostProcessEntityMetadata(simpleMetadata.Entities, simpleMetadata.EntityFields, simpleMetadata.EntityFieldValues, simpleMetadata.EntityPermissions, simpleMetadata.EntityRelationships, simpleMetadata.EntitySettings, simpleMetadata.EntityOrganicKeys, simpleMetadata.EntityOrganicKeyRelatedEntities, simpleMetadata.EntityFieldPermissions, simpleMetadata.EntityAlternateBaseViews);
|
|
4455
5231
|
// Post Process the Applications, because we want to handle the sub-objects properly.
|
|
4456
5232
|
simpleMetadata.AllApplications = simpleMetadata.Applications.map((a) => {
|
|
4457
5233
|
a.ApplicationEntities = simpleMetadata.ApplicationEntities.filter((ae) => UUIDsEqual(ae.ApplicationID, a.ID));
|
|
@@ -4490,8 +5266,17 @@ export class ProviderBase {
|
|
|
4490
5266
|
try {
|
|
4491
5267
|
const user = await this.GetCurrentUser();
|
|
4492
5268
|
if (user && this._localMetadata) {
|
|
5269
|
+
const previousUserJson = this._localMetadata.CurrentUser
|
|
5270
|
+
? JSON.stringify(this._localMetadata.CurrentUser)
|
|
5271
|
+
: null;
|
|
4493
5272
|
this._localMetadata.CurrentUser = user;
|
|
4494
|
-
|
|
5273
|
+
// Only re-persist the full metadata snapshot when the user record or roles
|
|
5274
|
+
// actually changed; on warm boots where CurrentUser is unchanged, skipping
|
|
5275
|
+
// SaveLocalMetadataToStorage avoids a multi-MB JSON.stringify + gzip + IDB
|
|
5276
|
+
// write pass on the main thread during startup.
|
|
5277
|
+
if (previousUserJson !== JSON.stringify(user)) {
|
|
5278
|
+
void this.SaveLocalMetadataToStorage();
|
|
5279
|
+
}
|
|
4495
5280
|
return user;
|
|
4496
5281
|
}
|
|
4497
5282
|
}
|
|
@@ -4529,7 +5314,7 @@ export class ProviderBase {
|
|
|
4529
5314
|
}
|
|
4530
5315
|
return map;
|
|
4531
5316
|
}
|
|
4532
|
-
PostProcessEntityMetadata(entities, fields, fieldValues, permissions, relationships, settings, organicKeys, organicKeyRelatedEntities, fieldPermissions) {
|
|
5317
|
+
PostProcessEntityMetadata(entities, fields, fieldValues, permissions, relationships, settings, organicKeys, organicKeyRelatedEntities, fieldPermissions, alternateBaseViews) {
|
|
4533
5318
|
const result = [];
|
|
4534
5319
|
// Sort entities alphabetically by name to ensure deterministic ordering
|
|
4535
5320
|
// This prevents non-deterministic output in CodeGen and other metadata consumers
|
|
@@ -4567,6 +5352,9 @@ export class ProviderBase {
|
|
|
4567
5352
|
const activeOrganicKeysByEntityId = organicKeys
|
|
4568
5353
|
? this.groupByNormalizedUUID(organicKeys.filter(ok => ok.Status === 'Active'), ok => ok.EntityID)
|
|
4569
5354
|
: new Map();
|
|
5355
|
+
const alternateBaseViewsByEntityId = alternateBaseViews && alternateBaseViews.length > 0
|
|
5356
|
+
? this.groupByNormalizedUUID(alternateBaseViews, abv => abv.EntityID)
|
|
5357
|
+
: new Map();
|
|
4570
5358
|
for (const e of sortedEntities) {
|
|
4571
5359
|
const entityIdKey = NormalizeUUID(e.ID);
|
|
4572
5360
|
const entityFields = fieldsByEntityId.get(entityIdKey) || [];
|
|
@@ -4578,7 +5366,10 @@ export class ProviderBase {
|
|
|
4578
5366
|
if (organicKeys) {
|
|
4579
5367
|
e.EntityOrganicKeys = activeOrganicKeysByEntityId.get(entityIdKey) || [];
|
|
4580
5368
|
}
|
|
4581
|
-
|
|
5369
|
+
if (alternateBaseViews) {
|
|
5370
|
+
e.EntityAlternateBaseViews = alternateBaseViewsByEntityId.get(entityIdKey) || [];
|
|
5371
|
+
}
|
|
5372
|
+
result.push(new EntityInfo(e, this));
|
|
4582
5373
|
}
|
|
4583
5374
|
// Check for schema name collision: if both 'MJ' and 'MJCustom' schemas exist,
|
|
4584
5375
|
// the class name prefix for 'MJ' is 'MJCustom' which would collide with the
|
|
@@ -4872,8 +5663,13 @@ export class ProviderBase {
|
|
|
4872
5663
|
* @returns True if refresh was successful or not needed
|
|
4873
5664
|
*/
|
|
4874
5665
|
async RefreshIfNeeded(providerToUse, bypassMinCheckInterval) {
|
|
4875
|
-
if (await this.CheckToSeeIfRefreshNeeded(providerToUse, bypassMinCheckInterval))
|
|
5666
|
+
if (await this.CheckToSeeIfRefreshNeeded(providerToUse, bypassMinCheckInterval)) {
|
|
5667
|
+
// Stale only in the saved-query family → reload just that family (fresh timestamps, no
|
|
5668
|
+
// cache layer), falling back to the full hard reload if the scoped read fails.
|
|
5669
|
+
if (this.PartialRefreshItemCodes() !== null)
|
|
5670
|
+
return this.ReloadMetadata(true, providerToUse, true);
|
|
4876
5671
|
return this.Refresh(providerToUse);
|
|
5672
|
+
}
|
|
4877
5673
|
else
|
|
4878
5674
|
return true;
|
|
4879
5675
|
}
|
|
@@ -5358,18 +6154,22 @@ export class ProviderBase {
|
|
|
5358
6154
|
}
|
|
5359
6155
|
/**
|
|
5360
6156
|
* Rebuilds the O(1) entity lookup Maps from the current AllEntities array.
|
|
6157
|
+
* Allocates fresh Map instances so any per-request provider sharing a previous
|
|
6158
|
+
* snapshot's maps is unaffected by a subsequent refresh.
|
|
5361
6159
|
* Called automatically from UpdateLocalMetadata().
|
|
5362
6160
|
*/
|
|
5363
6161
|
RebuildEntityMaps() {
|
|
5364
6162
|
const entities = this._localMetadata?.AllEntities;
|
|
5365
|
-
|
|
5366
|
-
|
|
6163
|
+
const byName = new Map();
|
|
6164
|
+
const byID = new Map();
|
|
5367
6165
|
if (entities) {
|
|
5368
6166
|
for (const e of entities) {
|
|
5369
|
-
|
|
5370
|
-
|
|
6167
|
+
byName.set(e.Name.trim().toLowerCase(), e);
|
|
6168
|
+
byID.set(NormalizeUUID(e.ID), e);
|
|
5371
6169
|
}
|
|
5372
6170
|
}
|
|
6171
|
+
this._entityMapByName = byName;
|
|
6172
|
+
this._entityMapByID = byID;
|
|
5373
6173
|
}
|
|
5374
6174
|
/**
|
|
5375
6175
|
* Returns the filesystem provider for the current environment.
|
|
@@ -5528,17 +6328,18 @@ export class ProviderBase {
|
|
|
5528
6328
|
* Used for compressed metadata storage/retrieval.
|
|
5529
6329
|
*/
|
|
5530
6330
|
static arrayBufferToBase64(buffer) {
|
|
5531
|
-
// Node encodes natively.
|
|
5532
|
-
//
|
|
6331
|
+
// Node encodes natively. In browsers, process in 32KB chunks with spread to avoid
|
|
6332
|
+
// building a character-by-character rope across multi-MB payloads.
|
|
5533
6333
|
if (typeof Buffer !== 'undefined') {
|
|
5534
6334
|
return Buffer.from(buffer).toString('base64');
|
|
5535
6335
|
}
|
|
5536
6336
|
const bytes = new Uint8Array(buffer);
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
6337
|
+
const chunkSize = 0x8000;
|
|
6338
|
+
const chunks = [];
|
|
6339
|
+
for (let i = 0; i < bytes.byteLength; i += chunkSize) {
|
|
6340
|
+
chunks.push(String.fromCharCode(...bytes.subarray(i, i + chunkSize)));
|
|
5540
6341
|
}
|
|
5541
|
-
return btoa(
|
|
6342
|
+
return btoa(chunks.join(''));
|
|
5542
6343
|
}
|
|
5543
6344
|
/**
|
|
5544
6345
|
* This property will return the prefix to use for local storage keys. This is useful if you have multiple instances of a provider running in the same environment
|