@memberjunction/core 6.2.0-edge.5 → 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 +15 -0
- package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
- package/dist/generic/BaseEntitySaveQueue.js +21 -0
- package/dist/generic/BaseEntitySaveQueue.js.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 +53 -9
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +194 -35
- 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 +153 -17
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +345 -46
- package/dist/generic/baseEntity.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 +66 -5
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +159 -12
- 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/entityInfo.d.ts +228 -9
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +574 -65
- 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/interfaces.d.ts +14 -1
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +80 -11
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +209 -38
- 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/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 +254 -56
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +698 -195
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/queryInfo.d.ts +13 -0
- package/dist/generic/queryInfo.d.ts.map +1 -1
- package/dist/generic/queryInfo.js +13 -0
- 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/securityInfo.d.ts +11 -0
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +15 -0
- 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/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/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
|
@@ -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.
|
|
@@ -249,10 +252,11 @@ export class ProviderBase {
|
|
|
249
252
|
*/
|
|
250
253
|
this._metadataReadsJoinTransaction = false;
|
|
251
254
|
/**
|
|
252
|
-
* The MJ_Metadata dataset items (code + entity name) as of the last
|
|
255
|
+
* The MJ_Metadata dataset items (code + entity name + row count) as of the last load. The remote
|
|
253
256
|
* timestamps ({@link GetLatestMetadataUpdates}) are keyed by entity name while a dataset
|
|
254
257
|
* read is scoped by item code, so this is the bridge that lets a staleness check be turned
|
|
255
|
-
* into a scoped reload (see {@link PartialRefreshItemCodes})
|
|
258
|
+
* into a scoped reload (see {@link PartialRefreshItemCodes}) and lets {@link adoptServerMetadata}
|
|
259
|
+
* verify that the received dataset items' row counts match `_latestRemoteMetadataTimestamps`.
|
|
256
260
|
*/
|
|
257
261
|
this._metadataDatasetItems = null;
|
|
258
262
|
/** Debounce timer for {@link scheduleMetadataMemberRefresh}. */
|
|
@@ -266,6 +270,7 @@ export class ProviderBase {
|
|
|
266
270
|
this._peerMetadataNoticeTimer = null;
|
|
267
271
|
this._metadataMemberRefreshWaits = 0;
|
|
268
272
|
this._clientFingerprintMemo = new WeakMap();
|
|
273
|
+
this._requestDeniedReadFields = new WeakMap();
|
|
269
274
|
this._cachedVisibleExplorerNavigationItems = null;
|
|
270
275
|
/** Guard for {@link logMetadataSnapshotDisabledOnce}. */
|
|
271
276
|
this._metadataSnapshotSkipLogged = false;
|
|
@@ -556,7 +561,13 @@ export class ProviderBase {
|
|
|
556
561
|
this._metadataDatasetEntityNames = names;
|
|
557
562
|
this._metadataDatasetItems = (dataset.Results ?? [])
|
|
558
563
|
.filter(item => item.Code && item.EntityName)
|
|
559
|
-
.map(item => ({
|
|
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
|
+
}));
|
|
560
571
|
this.ensureInflightViewInvalidation();
|
|
561
572
|
}
|
|
562
573
|
/**
|
|
@@ -1119,7 +1130,10 @@ export class ProviderBase {
|
|
|
1119
1130
|
return this.executeRunViewsPipeline(params, contextUser);
|
|
1120
1131
|
}
|
|
1121
1132
|
// ── Coalescing: merge concurrent RunViews into one mega-batch ──
|
|
1122
|
-
|
|
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)) {
|
|
1123
1137
|
// Only coalesce on the client side where cache validation round-trips are expensive.
|
|
1124
1138
|
// Server-side trusts its cache (no network calls), so coalescing adds no benefit.
|
|
1125
1139
|
return this.enqueueCoalescedRunViews(params, contextUser);
|
|
@@ -1197,6 +1211,35 @@ export class ProviderBase {
|
|
|
1197
1211
|
async executeRunViewsPipeline(params, contextUser) {
|
|
1198
1212
|
// Pre-processing for batch
|
|
1199
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) {
|
|
1200
1243
|
// Check for smart cache check mode
|
|
1201
1244
|
if (preResult.useSmartCacheCheck && preResult.smartCacheCheckParams) {
|
|
1202
1245
|
return this.executeSmartCacheCheck(params, preResult, contextUser);
|
|
@@ -1534,14 +1577,58 @@ export class ProviderBase {
|
|
|
1534
1577
|
* - entities where server caching is disallowed
|
|
1535
1578
|
*/
|
|
1536
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
|
+
}
|
|
1537
1586
|
return !param.BypassCache &&
|
|
1538
1587
|
!param.AfterKey &&
|
|
1539
1588
|
!IsMaterializedDataSource(param.DataSource) &&
|
|
1589
|
+
!this.isAlternateBaseViewCacheBypassed(param) &&
|
|
1540
1590
|
param.ResultType !== 'count_only' &&
|
|
1541
1591
|
(param.CacheLocal === true || this.TrustLocalCacheCompletely) &&
|
|
1542
1592
|
this.IsServerCacheAllowedForEntity(param);
|
|
1543
|
-
// Deliberately not user-dependent:
|
|
1544
|
-
//
|
|
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;
|
|
1545
1632
|
}
|
|
1546
1633
|
/** True when the entity is a CodeGen materialized-query wrapper (materialized_vw*) whose snapshot is
|
|
1547
1634
|
* refreshed out-of-band — the same one IsServerCacheAllowedForEntity excludes from the server cache. */
|
|
@@ -1569,6 +1656,7 @@ export class ProviderBase {
|
|
|
1569
1656
|
return !param.BypassCache &&
|
|
1570
1657
|
!param.AfterKey &&
|
|
1571
1658
|
!IsMaterializedDataSource(param.DataSource) &&
|
|
1659
|
+
!this.isAlternateBaseViewCacheBypassed(param) &&
|
|
1572
1660
|
param.ResultType !== 'count_only' &&
|
|
1573
1661
|
param.CacheLocal === true &&
|
|
1574
1662
|
!this.isMaterializedWrapperEntity(param);
|
|
@@ -1637,6 +1725,8 @@ export class ProviderBase {
|
|
|
1637
1725
|
p.UserSearchString ?? '',
|
|
1638
1726
|
p.ViewID ?? '',
|
|
1639
1727
|
p.ViewName ?? '',
|
|
1728
|
+
IsExplicitBaseViewOverride(p) ? 'abv_override:base' : '',
|
|
1729
|
+
p.TimeZone ?? '',
|
|
1640
1730
|
contextUser?.ID ?? ''
|
|
1641
1731
|
].join('|');
|
|
1642
1732
|
return `${base}|${extras}`;
|
|
@@ -1698,15 +1788,15 @@ export class ProviderBase {
|
|
|
1698
1788
|
if (memoized) {
|
|
1699
1789
|
return memoized;
|
|
1700
1790
|
}
|
|
1701
|
-
// The signed-in user's row filter (rls:) and
|
|
1702
|
-
//
|
|
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.
|
|
1703
1793
|
// The browser cache outlives a session — one IndexedDB store per server URL — and the
|
|
1704
1794
|
// currency check compares only maxUpdatedAt and rowCount, which cannot tell one user's
|
|
1705
1795
|
// rows from another's, nor notice a column. Without these segments, a different user on
|
|
1706
1796
|
// the same browser could be served the previous user's rows, and a user whose field
|
|
1707
1797
|
// access is tightened would keep seeing a column just taken away. Both are empty for an
|
|
1708
1798
|
// unrestricted user, whose fingerprints are unchanged.
|
|
1709
|
-
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));
|
|
1710
1800
|
// Normalize a FULL-COVERAGE field list to '*' — in the FINGERPRINT only (B44).
|
|
1711
1801
|
//
|
|
1712
1802
|
// entity_object params get widened to an explicit list of every entity field
|
|
@@ -2095,9 +2185,36 @@ export class ProviderBase {
|
|
|
2095
2185
|
authorized: !user || qInfo.UserCanRun(user),
|
|
2096
2186
|
categoryPath: qInfo.CategoryPath,
|
|
2097
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)),
|
|
2098
2189
|
queryId: qInfo.ID
|
|
2099
2190
|
};
|
|
2100
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
|
+
}
|
|
2101
2218
|
async RunQuery(params, contextUser) {
|
|
2102
2219
|
// Shallow-clone for symmetry with RunView — pipeline must never mutate caller objects
|
|
2103
2220
|
params = { ...params };
|
|
@@ -2126,6 +2243,8 @@ export class ProviderBase {
|
|
|
2126
2243
|
// (the fingerprint carries no DataSource segment). Keeping materialized out of this layer
|
|
2127
2244
|
// means the only staleness is the refresh cadence the snapshot already advertises.
|
|
2128
2245
|
&& !IsMaterializedDataSource(params.DataSource)
|
|
2246
|
+
// A RequireApproved run must check the approval on every run; a cached slot would skip it.
|
|
2247
|
+
&& !params.RequireApproved
|
|
2129
2248
|
&& LocalCacheManager.Instance.IsInitialized;
|
|
2130
2249
|
let queryFingerprint;
|
|
2131
2250
|
let queryDefinition;
|
|
@@ -2137,25 +2256,31 @@ export class ProviderBase {
|
|
|
2137
2256
|
const queryAuth = this.ResolveQueryCacheAuthorization(params, contextUser);
|
|
2138
2257
|
// MaxRows/StartRow shape the result set — they MUST distinguish cache slots,
|
|
2139
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);
|
|
2140
2262
|
const fingerprintParams = {
|
|
2141
2263
|
...(params.Parameters ?? {}),
|
|
2142
2264
|
__maxRows: params.MaxRows ?? -1,
|
|
2143
|
-
__startRow: params.StartRow ?? 0
|
|
2265
|
+
__startRow: params.StartRow ?? 0,
|
|
2266
|
+
...serverSegment
|
|
2144
2267
|
};
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
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
|
+
}
|
|
2155
2280
|
queryDefinition = queryAuth.definition;
|
|
2156
2281
|
// TTL-enforced, and a miss when the slot was written under another query definition.
|
|
2157
|
-
const cached = await LocalCacheManager.Instance.GetRunQueryResult(queryFingerprint, queryDefinition);
|
|
2158
|
-
if (cached) {
|
|
2282
|
+
const cached = queryFingerprint ? await LocalCacheManager.Instance.GetRunQueryResult(queryFingerprint, queryDefinition) : null;
|
|
2283
|
+
if (cached && queryFingerprint) {
|
|
2159
2284
|
const serveFromSlot = () => ({
|
|
2160
2285
|
QueryID: cached.queryId ?? params.QueryID ?? '',
|
|
2161
2286
|
QueryName: params.QueryName ?? '',
|
|
@@ -2349,6 +2474,25 @@ export class ProviderBase {
|
|
|
2349
2474
|
}
|
|
2350
2475
|
EntityInfo.AssertEntityActiveStatus(entity, callerName);
|
|
2351
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
|
+
}
|
|
2352
2496
|
// Type aliases for cleaner code
|
|
2353
2497
|
get PreRunViewResult() { return this._preRunViewResultType; }
|
|
2354
2498
|
get PreRunViewsResult() { return this._preRunViewsResultType; }
|
|
@@ -2420,7 +2564,7 @@ export class ProviderBase {
|
|
|
2420
2564
|
return entity.GetEffectiveRowFilterWhereClause(user, EntityPermissionType.Read, '');
|
|
2421
2565
|
}
|
|
2422
2566
|
/**
|
|
2423
|
-
* The
|
|
2567
|
+
* The field-security cache key for one entity: a canonical list of the fields the user MAY read.
|
|
2424
2568
|
*
|
|
2425
2569
|
* Keyed on the ALLOWED set rather than the denied set for two reasons. Once metadata ships
|
|
2426
2570
|
* to browsers filtered to what a user may see ([#3485](https://github.com/MemberJunction/MJ/issues/3485)),
|
|
@@ -2432,18 +2576,20 @@ export class ProviderBase {
|
|
|
2432
2576
|
* Returns '' when the entity has field security off or the user is denied nothing, so
|
|
2433
2577
|
* unrestricted users keep byte-identical fingerprints and shared slots.
|
|
2434
2578
|
*
|
|
2435
|
-
*
|
|
2436
|
-
*
|
|
2437
|
-
*
|
|
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}
|
|
2438
2584
|
*/
|
|
2439
|
-
ComputeClientFLSAllowedKey(params) {
|
|
2440
|
-
const user = this.CurrentUser;
|
|
2441
|
-
if (!user
|
|
2585
|
+
ComputeClientFLSAllowedKey(params, contextUser) {
|
|
2586
|
+
const user = contextUser ?? this.CurrentUser;
|
|
2587
|
+
if (!user)
|
|
2442
2588
|
return '';
|
|
2443
|
-
const entity = this.
|
|
2444
|
-
if (!entity
|
|
2589
|
+
const entity = this.ResolveRunViewEntitySync(params);
|
|
2590
|
+
if (!entity)
|
|
2445
2591
|
return '';
|
|
2446
|
-
const denied =
|
|
2592
|
+
const denied = this.ResolveRequestDeniedReadFields(params, entity, user);
|
|
2447
2593
|
if (denied.size === 0)
|
|
2448
2594
|
return '';
|
|
2449
2595
|
return entity.Fields
|
|
@@ -2453,32 +2599,107 @@ export class ProviderBase {
|
|
|
2453
2599
|
.join(',');
|
|
2454
2600
|
}
|
|
2455
2601
|
/**
|
|
2456
|
-
* The field-security segment for a {@link LocalCacheManager} RunView fingerprint
|
|
2457
|
-
*
|
|
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.
|
|
2634
|
+
*
|
|
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.
|
|
2458
2648
|
*
|
|
2459
|
-
*
|
|
2460
|
-
*
|
|
2461
|
-
|
|
2462
|
-
|
|
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.
|
|
2463
2659
|
*
|
|
2464
|
-
*
|
|
2465
|
-
*
|
|
2466
|
-
*
|
|
2467
|
-
*
|
|
2468
|
-
* neither of which notices a column, so the server answers "current" and the browser keeps
|
|
2469
|
-
* 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.
|
|
2470
2664
|
*
|
|
2471
|
-
*
|
|
2472
|
-
*
|
|
2665
|
+
* @param params - The request
|
|
2666
|
+
* @param contextUser - The request's user; defaults to {@link CurrentUser}
|
|
2473
2667
|
*/
|
|
2474
|
-
|
|
2475
|
-
|
|
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;
|
|
2476
2696
|
}
|
|
2477
2697
|
/**
|
|
2478
2698
|
* The fetch-widening field list for a cache-eligible request: always every entity field.
|
|
2479
|
-
* One slot per (entity, filter, order) serves every caller regardless of
|
|
2480
|
-
* they asked for
|
|
2481
|
-
*
|
|
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}).
|
|
2482
2703
|
*/
|
|
2483
2704
|
ComputeRunViewFetchFields(entity, params) {
|
|
2484
2705
|
const includeBinary = params?.IncludeBinaryFields === true;
|
|
@@ -2533,69 +2754,78 @@ export class ProviderBase {
|
|
|
2533
2754
|
// FIELD-LEVEL SECURITY
|
|
2534
2755
|
// ========================================================================
|
|
2535
2756
|
/**
|
|
2536
|
-
* Rejects a RunView whose
|
|
2537
|
-
*
|
|
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`.
|
|
2538
2760
|
*
|
|
2539
2761
|
* Output projection alone is security theater. A user denied `Salary` can send
|
|
2540
2762
|
* `ExtraFilter: "Salary > 200000"` or `OrderBy: "Salary DESC"` and reconstruct the values
|
|
2541
2763
|
* from which rows come back and in what order — the column never appears in a result, so
|
|
2542
2764
|
* every output-stripping point reports "secure." Aggregates are the same channel in a purer
|
|
2543
2765
|
* form: `Aggregates: [{expression: 'MIN(Salary)'}]` under a narrow filter returns a denied
|
|
2544
|
-
* field's exact values directly.
|
|
2545
|
-
*
|
|
2546
|
-
* surface (`UserSearchString` is handled by excluding denied fields from the searched set,
|
|
2547
|
-
* 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.
|
|
2548
2768
|
*
|
|
2549
|
-
* Lives at the provider layer
|
|
2550
|
-
*
|
|
2551
|
-
*
|
|
2552
|
-
*
|
|
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.
|
|
2553
2773
|
*
|
|
2554
|
-
* The
|
|
2555
|
-
* see {@link ProviderBase.FieldSecurityDenialMessage}.
|
|
2774
|
+
* The reading of each clause, and the error it raises, is {@link FieldSecuritySQLGate}'s.
|
|
2556
2775
|
*/
|
|
2557
2776
|
AssertPredicatesRespectFieldSecurity(params, contextUser) {
|
|
2558
|
-
if (!contextUser
|
|
2777
|
+
if (!contextUser) {
|
|
2559
2778
|
return;
|
|
2560
2779
|
}
|
|
2561
|
-
const entity = this.
|
|
2562
|
-
if (!entity
|
|
2563
|
-
return; // the overwhelmingly common case — one boolean, no allocation
|
|
2564
|
-
}
|
|
2565
|
-
const denied = entity.GetDeniedReadFields(contextUser);
|
|
2566
|
-
if (denied.size === 0) {
|
|
2780
|
+
const entity = this.ResolveRunViewEntitySync(params);
|
|
2781
|
+
if (!entity) {
|
|
2567
2782
|
return;
|
|
2568
2783
|
}
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
for (const field of entity.Fields) {
|
|
2572
|
-
if (denied.has(field.Name.trim().toLowerCase())) {
|
|
2573
|
-
deniedByName.set(field.Name.trim().toLowerCase(), field.Name);
|
|
2574
|
-
}
|
|
2575
|
-
}
|
|
2576
|
-
// Aliases are deliberately not scanned — an alias names the output column, no data
|
|
2577
|
-
// flows through it.
|
|
2578
|
-
const clauses = [
|
|
2784
|
+
const view = params.ViewEntity;
|
|
2785
|
+
this.AssertClausesRespectFieldSecurityForEntity(entity, [
|
|
2579
2786
|
params.ExtraFilter,
|
|
2580
|
-
params.
|
|
2787
|
+
params.OverrideExcludeFilter,
|
|
2581
2788
|
...(params.Aggregates ?? []).map(a => a?.expression),
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
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) {
|
|
2585
2821
|
continue;
|
|
2586
2822
|
}
|
|
2587
|
-
const
|
|
2588
|
-
if (
|
|
2589
|
-
LogDebug(`[FieldSecurity] Rejected
|
|
2590
|
-
|
|
2591
|
-
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);
|
|
2592
2827
|
}
|
|
2593
2828
|
}
|
|
2594
|
-
// UserSearchString is not a predicate the caller authored — it is a search term the
|
|
2595
|
-
// platform expands across IncludeInUserSearchAPI fields. There is nothing to reject;
|
|
2596
|
-
// the correct handling is to not search the denied fields, which the SQL builder does
|
|
2597
|
-
// by consulting the same denied set. Rejecting here would make the search box fail
|
|
2598
|
-
// for a term that merely happens to match a restricted field's name.
|
|
2599
2829
|
}
|
|
2600
2830
|
/**
|
|
2601
2831
|
* The single wording for "you cannot use this field," modeled on SQL Server's posture of
|
|
@@ -2615,15 +2845,13 @@ export class ProviderBase {
|
|
|
2615
2845
|
/**
|
|
2616
2846
|
* Strips fields the user cannot read from plain-object result rows.
|
|
2617
2847
|
*
|
|
2618
|
-
*
|
|
2619
|
-
*
|
|
2620
|
-
*
|
|
2621
|
-
*
|
|
2622
|
-
*
|
|
2623
|
-
*
|
|
2624
|
-
*
|
|
2625
|
-
* restricted service account has no business holding: the `simple`-path SELECT-list
|
|
2626
|
-
* 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.
|
|
2627
2855
|
*
|
|
2628
2856
|
* NEVER applied to `entity_object` results. Those become `BaseEntity` instances whose
|
|
2629
2857
|
* fields round-trip through `GenerateSaveSQL`, which iterates ALL `IsSPParameter` fields
|
|
@@ -2633,11 +2861,11 @@ export class ProviderBase {
|
|
|
2633
2861
|
* output, which the GraphQL layer enforces separately.
|
|
2634
2862
|
*/
|
|
2635
2863
|
ApplyFieldSecurityProjection(rows, params, contextUser) {
|
|
2636
|
-
if (!rows?.length || !contextUser ||
|
|
2864
|
+
if (!rows?.length || !contextUser || params.ResultType === 'entity_object') {
|
|
2637
2865
|
return rows;
|
|
2638
2866
|
}
|
|
2639
|
-
const entity = this.
|
|
2640
|
-
if (!entity?.
|
|
2867
|
+
const entity = this.ResolveRunViewEntitySync(params);
|
|
2868
|
+
if (!entity?.FieldLevelSecurityApplies) {
|
|
2641
2869
|
return rows;
|
|
2642
2870
|
}
|
|
2643
2871
|
const denied = entity.GetDeniedReadFields(contextUser);
|
|
@@ -2648,32 +2876,78 @@ export class ProviderBase {
|
|
|
2648
2876
|
}
|
|
2649
2877
|
/**
|
|
2650
2878
|
* Row-loop half of {@link ApplyFieldSecurityProjection}, split out so the policy above reads
|
|
2651
|
-
* as policy. Mirrors {@link ProjectRowsToFields}' matching semantics (trim + lowercase)
|
|
2652
|
-
*
|
|
2653
|
-
*
|
|
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.
|
|
2654
2889
|
*
|
|
2655
2890
|
* @param deniedLowercase field names to omit, already lowercased
|
|
2656
2891
|
*/
|
|
2657
2892
|
static omitFieldsFromRows(rows, deniedLowercase) {
|
|
2658
|
-
const
|
|
2659
|
-
|
|
2660
|
-
|
|
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;
|
|
2661
2906
|
}
|
|
2662
|
-
const
|
|
2663
|
-
|
|
2664
|
-
|
|
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;
|
|
2665
2912
|
}
|
|
2666
|
-
const
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
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;
|
|
2674
2948
|
}
|
|
2675
|
-
|
|
2676
|
-
|
|
2949
|
+
}
|
|
2950
|
+
return true;
|
|
2677
2951
|
}
|
|
2678
2952
|
/**
|
|
2679
2953
|
* Strips or narrows the `MJ: Record Changes` payload columns a user may not read, using the
|
|
@@ -2685,6 +2959,9 @@ export class ProviderBase {
|
|
|
2685
2959
|
* Everything about the per-row denied set, the payload treatment, and the fail-closed decision
|
|
2686
2960
|
* lives in {@link RecordChangeFieldSecurityProjector}; this is only the RunView wiring.
|
|
2687
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
|
+
*
|
|
2688
2965
|
* Runs at all four RunView projection points, matching the main projection: both cache-hit
|
|
2689
2966
|
* paths and both cache-miss paths. The cache-hit path is not optional — it is the exact path
|
|
2690
2967
|
* the original cross-user leak runs through, an unrestricted user warming a full-width slot
|
|
@@ -2707,9 +2984,13 @@ export class ProviderBase {
|
|
|
2707
2984
|
if (!rows?.length || !contextUser || params.ResultType === 'entity_object') {
|
|
2708
2985
|
return rows;
|
|
2709
2986
|
}
|
|
2710
|
-
|
|
2987
|
+
const entity = this.ResolveRunViewEntitySync(params);
|
|
2988
|
+
if (entity && !RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entity)) {
|
|
2711
2989
|
return rows;
|
|
2712
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.
|
|
2713
2994
|
return new RecordChangeFieldSecurityProjector(this, contextUser).ProjectRows(rows);
|
|
2714
2995
|
}
|
|
2715
2996
|
/**
|
|
@@ -2741,6 +3022,9 @@ export class ProviderBase {
|
|
|
2741
3022
|
// Hooks run after PlatformSQL resolution so they see plain-string filters,
|
|
2742
3023
|
// and before cache fingerprinting so injected filters affect the cache key.
|
|
2743
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);
|
|
2744
3028
|
// Field-level security: reject predicates referencing unreadable fields. Runs AFTER
|
|
2745
3029
|
// hooks so injected filters are scanned too, and BEFORE the cache lookup so a rejected
|
|
2746
3030
|
// request never consults (or warms) a cache slot.
|
|
@@ -2752,6 +3036,7 @@ export class ProviderBase {
|
|
|
2752
3036
|
EntityName: params.EntityName,
|
|
2753
3037
|
ViewID: params.ViewID,
|
|
2754
3038
|
ViewName: params.ViewName,
|
|
3039
|
+
AlternateBaseView: GetEffectiveAlternateBaseViewName(params),
|
|
2755
3040
|
ExtraFilter: params.ExtraFilter,
|
|
2756
3041
|
OrderBy: params.OrderBy,
|
|
2757
3042
|
ResultType: params.ResultType,
|
|
@@ -2771,8 +3056,10 @@ export class ProviderBase {
|
|
|
2771
3056
|
await this.EntityStatusCheck(params, 'PreRunView', contextUser);
|
|
2772
3057
|
const entityCheckTime = performance.now() - entityCheckStart;
|
|
2773
3058
|
// Save the caller's original Fields request for post-cache filtering.
|
|
2774
|
-
// We
|
|
2775
|
-
// 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.
|
|
2776
3063
|
const entityLookupStart = performance.now();
|
|
2777
3064
|
let callerRequestedFields = params.Fields && params.Fields.length > 0
|
|
2778
3065
|
? params.Fields.map(f => f.trim().toLowerCase())
|
|
@@ -2780,8 +3067,8 @@ export class ProviderBase {
|
|
|
2780
3067
|
// Only override Fields to the widened fetch set when caching will actually happen
|
|
2781
3068
|
// for this call. For non-cached calls we respect the caller's narrow Fields
|
|
2782
3069
|
// end-to-end — there's no cache-coherence concern to preserve. The widened set is
|
|
2783
|
-
// ALL entity fields
|
|
2784
|
-
//
|
|
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).
|
|
2785
3072
|
const entity = params.EntityName ? this.EntityByName(params.EntityName) : null;
|
|
2786
3073
|
const willCache = this.runViewCacheEligible(params);
|
|
2787
3074
|
// entity_object ALWAYS widens, cache-eligible or not: a BaseEntity must hydrate from
|
|
@@ -2820,9 +3107,7 @@ export class ProviderBase {
|
|
|
2820
3107
|
const cacheReadDenied = (!!entity && !!contextUser && !(entity.GetUserPermisions(contextUser)?.CanRead ?? false)) ||
|
|
2821
3108
|
this.cacheDeniedForViewOnlyRequest(params, contextUser);
|
|
2822
3109
|
if (willCache && !cacheReadDenied && LocalCacheManager.Instance.IsInitialized) {
|
|
2823
|
-
|
|
2824
|
-
const flsFieldsKey = this.ComputeRunViewFLSFingerprintKey(params);
|
|
2825
|
-
fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, rlsWhereClause, undefined, flsFieldsKey);
|
|
3110
|
+
fingerprint = this.ComputeRunViewCacheFingerprint(params, contextUser);
|
|
2826
3111
|
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
2827
3112
|
if (cached) {
|
|
2828
3113
|
// These rows are the cache's shared, deep-frozen objects — the runtime freeze is
|
|
@@ -2834,14 +3119,12 @@ export class ProviderBase {
|
|
|
2834
3119
|
}
|
|
2835
3120
|
// Field-level security MUST be applied here, not only in PostRunView: this path
|
|
2836
3121
|
// RETURNS (see RunView) without ever calling PostRunView, and it is the exact
|
|
2837
|
-
// path
|
|
2838
|
-
//
|
|
2839
|
-
// column
|
|
2840
|
-
// 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.
|
|
2841
3125
|
results = this.ApplyFieldSecurityProjection(results, params, contextUser);
|
|
2842
|
-
// Record Changes payload security, on the same cache-hit path
|
|
2843
|
-
//
|
|
2844
|
-
// 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.
|
|
2845
3128
|
results = this.ApplyRecordChangeFieldSecurityProjection(results, params, contextUser);
|
|
2846
3129
|
// Reconstruct RunViewResult from cached data
|
|
2847
3130
|
cachedResult = {
|
|
@@ -2857,6 +3140,7 @@ export class ProviderBase {
|
|
|
2857
3140
|
AggregateResults: LocalCacheManager.Instance.ReorderAggregateResultsToRequest(cached.aggregateResults, params.Aggregates)
|
|
2858
3141
|
};
|
|
2859
3142
|
cacheStatus = 'hit';
|
|
3143
|
+
this.logDeprecatedAlternateBaseViewIfAny(params, 'PreRunView');
|
|
2860
3144
|
if (!params.CacheLocal && this.TrustLocalCacheCompletely) {
|
|
2861
3145
|
LogStatusEx({ message: ` ✅ [Server Cache HIT] RunView "${params.EntityName || params.ViewName || 'unknown'}" — ${results.length} rows served from server cache (no DB query)`, verboseOnly: true });
|
|
2862
3146
|
}
|
|
@@ -2868,7 +3152,7 @@ export class ProviderBase {
|
|
|
2868
3152
|
const cacheCheckTime = performance.now() - cacheCheckStart;
|
|
2869
3153
|
const totalPreTime = performance.now() - preViewStart;
|
|
2870
3154
|
if (totalPreTime > 50) {
|
|
2871
|
-
|
|
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 });
|
|
2872
3156
|
}
|
|
2873
3157
|
return {
|
|
2874
3158
|
telemetryEventId,
|
|
@@ -2883,23 +3167,20 @@ export class ProviderBase {
|
|
|
2883
3167
|
/**
|
|
2884
3168
|
* Pre-processing hook for RunViews (batch).
|
|
2885
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.
|
|
2886
3172
|
* @param params - Array of view parameters
|
|
2887
3173
|
* @param contextUser - Optional user context
|
|
2888
|
-
* @returns Pre-processing result with cache status for each view
|
|
3174
|
+
* @returns Pre-processing result with cache status for each admitted view
|
|
2889
3175
|
*/
|
|
2890
3176
|
async PreRunViews(params, contextUser) {
|
|
2891
3177
|
// Resolve any PlatformSQL values to plain strings for the active platform
|
|
2892
3178
|
for (const p of params) {
|
|
2893
3179
|
this.ResolvePlatformSQLInParams(p);
|
|
2894
3180
|
}
|
|
2895
|
-
// Run registered PreRunView hooks on each param in the batch
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
// Field-level security predicate gate, same contract as the single-view path.
|
|
2899
|
-
// One bad view rejects the whole batch — a batch is one caller request, and
|
|
2900
|
-
// silently returning partial results would hide the denial.
|
|
2901
|
-
this.AssertPredicatesRespectFieldSecurity(params[i], contextUser);
|
|
2902
|
-
}
|
|
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);
|
|
2903
3184
|
// Start telemetry tracking for batch operation
|
|
2904
3185
|
const fromEngine = params.some(p => p._fromEngine);
|
|
2905
3186
|
const telemetryEventId = TelemetryManager.Instance.StartEvent('RunView', 'ProviderBase.RunViews', {
|
|
@@ -2921,6 +3202,7 @@ export class ProviderBase {
|
|
|
2921
3202
|
// client), which is exactly what a keyset sweep does on every page.
|
|
2922
3203
|
StartRows: params.map(p => p.StartRow),
|
|
2923
3204
|
AfterKeys: params.map(p => p.AfterKey?.ToConcatenatedString()),
|
|
3205
|
+
AlternateBaseViews: params.map(p => GetEffectiveAlternateBaseViewName(p)),
|
|
2924
3206
|
// Exemption was threaded through the DEPRECATED batch twin but not this one, so
|
|
2925
3207
|
// RunViewParams.Telemetry.Exempt was silently ineffective for every batch RunView —
|
|
2926
3208
|
// and, because RunView() delegates here whenever BypassCache or AfterKey is set,
|
|
@@ -2934,17 +3216,69 @@ export class ProviderBase {
|
|
|
2934
3216
|
ExemptReason: params.find(p => p.Telemetry?.Reason)?.Telemetry?.Reason,
|
|
2935
3217
|
_fromEngine: fromEngine
|
|
2936
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
|
+
}
|
|
2937
3265
|
// Client-side providers route any CacheLocal params through smart-cache-check:
|
|
2938
3266
|
// a single batched GraphQL call sends fingerprints (hashes + counts) per view,
|
|
2939
3267
|
// the server confirms current vs stale, and only stale views return data.
|
|
2940
3268
|
// Server-side providers trust the cache completely and fall through to the
|
|
2941
3269
|
// traditional flow which returns cached data immediately on hit.
|
|
2942
3270
|
if (!this.TrustLocalCacheCompletely) {
|
|
2943
|
-
|
|
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);
|
|
2944
3274
|
if (useSmartCacheCheck && LocalCacheManager.Instance.IsInitialized) {
|
|
2945
3275
|
return this.prepareSmartCacheCheckParams(params, telemetryEventId, contextUser);
|
|
2946
3276
|
}
|
|
2947
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) {
|
|
2948
3282
|
// Traditional caching flow
|
|
2949
3283
|
const cacheStatusMap = new Map();
|
|
2950
3284
|
const callerFieldsMap = new Map();
|
|
@@ -2964,7 +3298,7 @@ export class ProviderBase {
|
|
|
2964
3298
|
// Only override Fields to the widened fetch set when caching will actually happen
|
|
2965
3299
|
// for this call. For non-cached calls we respect the caller's narrow Fields
|
|
2966
3300
|
// end-to-end — there's no cache-coherence concern to preserve. Widened = ALL
|
|
2967
|
-
// fields
|
|
3301
|
+
// fields (see PreRunView).
|
|
2968
3302
|
const batchEntity = param.EntityName ? this.EntityByName(param.EntityName) : null;
|
|
2969
3303
|
const batchWillCache = this.runViewCacheEligible(param);
|
|
2970
3304
|
// Same entity_object-always-widens rule as the single-view path above.
|
|
@@ -2991,9 +3325,7 @@ export class ProviderBase {
|
|
|
2991
3325
|
const batchCacheReadDenied = (!!batchEntity && !!contextUser && !(batchEntity.GetUserPermisions(contextUser)?.CanRead ?? false)) ||
|
|
2992
3326
|
this.cacheDeniedForViewOnlyRequest(param, contextUser);
|
|
2993
3327
|
if (batchWillCache && !batchCacheReadDenied && LocalCacheManager.Instance.IsInitialized) {
|
|
2994
|
-
const
|
|
2995
|
-
const flsFieldsKey = this.ComputeRunViewFLSFingerprintKey(param);
|
|
2996
|
-
const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString, rlsWhereClause, undefined, flsFieldsKey);
|
|
3328
|
+
const fingerprint = this.ComputeRunViewCacheFingerprint(param, contextUser);
|
|
2997
3329
|
fingerprintMap.set(i, fingerprint);
|
|
2998
3330
|
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
2999
3331
|
if (cached) {
|
|
@@ -3004,7 +3336,7 @@ export class ProviderBase {
|
|
|
3004
3336
|
}
|
|
3005
3337
|
// Field-level security on the batch cache-hit path — same reasoning as the
|
|
3006
3338
|
// single-view path in PreRunView: this result is returned without
|
|
3007
|
-
// PostRunViews projecting it, and
|
|
3339
|
+
// PostRunViews projecting it, and a slot can be wider than its class.
|
|
3008
3340
|
results = this.ApplyFieldSecurityProjection(results, param, contextUser);
|
|
3009
3341
|
results = this.ApplyRecordChangeFieldSecurityProjection(results, param, contextUser);
|
|
3010
3342
|
const cachedViewResult = {
|
|
@@ -3021,6 +3353,7 @@ export class ProviderBase {
|
|
|
3021
3353
|
};
|
|
3022
3354
|
// if needed this will transform each result into an entity object
|
|
3023
3355
|
await this.TransformSimpleObjectToEntityObject(param, cachedViewResult, contextUser);
|
|
3356
|
+
this.logDeprecatedAlternateBaseViewIfAny(param, 'PreRunViews');
|
|
3024
3357
|
if (!param.CacheLocal && this.TrustLocalCacheCompletely) {
|
|
3025
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 });
|
|
3026
3359
|
}
|
|
@@ -3105,6 +3438,8 @@ export class ProviderBase {
|
|
|
3105
3438
|
smartCacheCheckParams[paramIndex].cacheStatus = {
|
|
3106
3439
|
maxUpdatedAt: cached.maxUpdatedAt,
|
|
3107
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),
|
|
3108
3443
|
};
|
|
3109
3444
|
}
|
|
3110
3445
|
}
|
|
@@ -3444,14 +3779,15 @@ export class ProviderBase {
|
|
|
3444
3779
|
// Server-side auto-cache: small, unfiltered, unsorted results are
|
|
3445
3780
|
// automatically cached even without explicit CacheLocal. These are
|
|
3446
3781
|
// safe for in-place upsert on entity changes (no filter to evaluate).
|
|
3447
|
-
const fingerprint = preResult.fingerprint ||
|
|
3782
|
+
const fingerprint = preResult.fingerprint || this.ComputeRunViewCacheFingerprint(params, contextUser);
|
|
3448
3783
|
const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
|
|
3449
3784
|
await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
|
|
3450
3785
|
LogStatusEx({ message: ` 📦 [Auto-Cache] RunView "${params.EntityName || params.ViewName || 'unknown'}" — ${result.Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
|
|
3451
3786
|
}
|
|
3452
3787
|
// Project cache-miss DB results back down to the caller's originally requested
|
|
3453
|
-
// fields. PreRunView widened params.Fields to ALL entity fields
|
|
3454
|
-
//
|
|
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
|
|
3455
3791
|
// the same shape on a miss as they do on a hit (which projects from cache).
|
|
3456
3792
|
// Must run AFTER the cache writes (cache keeps the superset) and only for
|
|
3457
3793
|
// plain-object results (entity objects need all fields).
|
|
@@ -3459,10 +3795,9 @@ export class ProviderBase {
|
|
|
3459
3795
|
result.Results = ProjectRowsToFields(result.Results, preResult.callerRequestedFields);
|
|
3460
3796
|
}
|
|
3461
3797
|
// Field-level security on the cache-MISS path (the hit path is enforced in PreRunView,
|
|
3462
|
-
// which returns before this hook ever runs).
|
|
3463
|
-
// the
|
|
3464
|
-
//
|
|
3465
|
-
// 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.
|
|
3466
3801
|
if (result.Success) {
|
|
3467
3802
|
result.Results = this.ApplyFieldSecurityProjection(result.Results, params, contextUser);
|
|
3468
3803
|
result.Results = this.ApplyRecordChangeFieldSecurityProjection(result.Results, params, contextUser);
|
|
@@ -3521,7 +3856,7 @@ export class ProviderBase {
|
|
|
3521
3856
|
// per item. Compute lazily only for indexes PreRunViews skipped (cache
|
|
3522
3857
|
// was disabled for them but auto-cache/OnDataChanged may still need it).
|
|
3523
3858
|
const fingerprint = preResult.fingerprintMap?.get(i)
|
|
3524
|
-
??
|
|
3859
|
+
?? this.ComputeRunViewCacheFingerprint(params[i], contextUser);
|
|
3525
3860
|
// CRITICAL: must be the SAME eligibility predicate PreRunViews used to decide
|
|
3526
3861
|
// whether to widen Fields. Writing a non-widened (narrow or keyset-paged)
|
|
3527
3862
|
// result here poisons the Fields-agnostic superset slot — this exact gate
|
|
@@ -3543,7 +3878,8 @@ export class ProviderBase {
|
|
|
3543
3878
|
await Promise.all(cachePromises);
|
|
3544
3879
|
// Project cache-miss DB results back down to each caller's originally
|
|
3545
3880
|
// requested fields. PreRunViews widened those params' Fields to ALL entity
|
|
3546
|
-
// 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
|
|
3547
3883
|
// callers must receive the same shape on a miss as on a hit (which projects
|
|
3548
3884
|
// from cache). Skip hits (already projected) and entity_object results
|
|
3549
3885
|
// (need all fields).
|
|
@@ -3602,6 +3938,18 @@ export class ProviderBase {
|
|
|
3602
3938
|
});
|
|
3603
3939
|
}
|
|
3604
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
|
+
}
|
|
3605
3953
|
/**
|
|
3606
3954
|
* Runs all registered PreRunView hooks against a single RunViewParams,
|
|
3607
3955
|
* returning the (possibly mutated) params.
|
|
@@ -3800,11 +4148,16 @@ export class ProviderBase {
|
|
|
3800
4148
|
return false;
|
|
3801
4149
|
// Only auto-cache unfiltered, unsorted queries — these are safe for
|
|
3802
4150
|
// in-place upsert because LocalCacheManager doesn't need to evaluate
|
|
3803
|
-
// 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.
|
|
3804
4153
|
const filter = typeof params.ExtraFilter === 'string' ? params.ExtraFilter.trim() : '';
|
|
3805
4154
|
const orderBy = typeof params.OrderBy === 'string' ? params.OrderBy.trim() : '';
|
|
3806
4155
|
if (filter.length > 0 || orderBy.length > 0)
|
|
3807
4156
|
return false;
|
|
4157
|
+
if (GetEffectiveAlternateBaseViewName(params))
|
|
4158
|
+
return false;
|
|
4159
|
+
if (Boolean(params.ViewID || params.ViewName || params.ViewEntity))
|
|
4160
|
+
return false;
|
|
3808
4161
|
return true;
|
|
3809
4162
|
}
|
|
3810
4163
|
extractMaxUpdatedAt(results) {
|
|
@@ -4237,7 +4590,7 @@ export class ProviderBase {
|
|
|
4237
4590
|
if (!this.TrustLocalCacheCompletely && !hardRefresh && !this._localMetadata?.AllEntities?.length) {
|
|
4238
4591
|
await Promise.all([localCacheInitPromise, this.LoadLocalMetadataFromStorage()]);
|
|
4239
4592
|
if (this._localMetadata?.AllEntities?.length) {
|
|
4240
|
-
|
|
4593
|
+
LogStatus(`⚡ [Metadata Cache] Loaded ${this._localMetadata.AllEntities.length} entities from local cache — pre-validating before engine startup`);
|
|
4241
4594
|
// Do NOT kick off background validation here — it writes to IndexedDB
|
|
4242
4595
|
// which contends with engine RunView cache reads during StartupManager.
|
|
4243
4596
|
// Instead, the caller (setupGraphQLClient) should call
|
|
@@ -4325,7 +4678,7 @@ export class ProviderBase {
|
|
|
4325
4678
|
const start = new Date().getTime();
|
|
4326
4679
|
const res = await this.GetAllMetadata(providerToUse, effectiveHardRefresh);
|
|
4327
4680
|
const end = new Date().getTime();
|
|
4328
|
-
|
|
4681
|
+
LogStatus(`GetAllMetadata() took ${end - start} ms`);
|
|
4329
4682
|
if (res) {
|
|
4330
4683
|
await this.adoptServerMetadata(res);
|
|
4331
4684
|
this._configLoadedFromServer = true;
|
|
@@ -4353,7 +4706,8 @@ export class ProviderBase {
|
|
|
4353
4706
|
}
|
|
4354
4707
|
/**
|
|
4355
4708
|
* Adopts a metadata snapshot just loaded from the server: swaps it in, records that the
|
|
4356
|
-
* 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.
|
|
4357
4711
|
*
|
|
4358
4712
|
* The swap goes through UpdateLocalMetadata — a single property assignment, atomic in
|
|
4359
4713
|
* JavaScript, so readers see the old snapshot until the new one is complete — which keeps
|
|
@@ -4361,9 +4715,116 @@ export class ProviderBase {
|
|
|
4361
4715
|
*/
|
|
4362
4716
|
async adoptServerMetadata(res) {
|
|
4363
4717
|
this.UpdateLocalMetadata(res);
|
|
4364
|
-
this._latestLocalMetadataTimestamps = this.
|
|
4718
|
+
this._latestLocalMetadataTimestamps = this.reconcileAdoptedMetadataTimestamps(res);
|
|
4365
4719
|
await this.SaveLocalMetadataToStorage();
|
|
4366
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
|
+
}
|
|
4367
4828
|
/**
|
|
4368
4829
|
* Decides whether the current staleness can be served by a scoped reload of the saved-query
|
|
4369
4830
|
* family. Compares local and remote timestamps item by item (the same test as
|
|
@@ -4439,14 +4900,14 @@ export class ProviderBase {
|
|
|
4439
4900
|
const start = Date.now();
|
|
4440
4901
|
const d = await this.GetDatasetByName(ProviderBase._mjMetadataDatasetName, skipOthers, this.CurrentUser, providerToUse, true);
|
|
4441
4902
|
if (!d?.Success) {
|
|
4442
|
-
LogStatusEx({ message: `[Metadata] Scoped reload of ${codes.join(', ')} failed (${d?.Status ?? 'no result'}); running the full reload`, verboseOnly:
|
|
4903
|
+
LogStatusEx({ message: `[Metadata] Scoped reload of ${codes.join(', ')} failed (${d?.Status ?? 'no result'}); running the full reload`, verboseOnly: true });
|
|
4443
4904
|
return false;
|
|
4444
4905
|
}
|
|
4445
4906
|
const simple = {};
|
|
4446
4907
|
for (const code of codes) {
|
|
4447
4908
|
const item = d.Results?.find(r => r.Code === code);
|
|
4448
4909
|
if (!item || item.Success === false || !Array.isArray(item.Results)) {
|
|
4449
|
-
LogStatusEx({ message: `[Metadata] Scoped reload: item ${code} ${item ? `failed (${item.Status ?? 'no rows'})` : 'missing'}; running the full reload`, verboseOnly:
|
|
4910
|
+
LogStatusEx({ message: `[Metadata] Scoped reload: item ${code} ${item ? `failed (${item.Status ?? 'no rows'})` : 'missing'}; running the full reload`, verboseOnly: true });
|
|
4450
4911
|
return false;
|
|
4451
4912
|
}
|
|
4452
4913
|
simple[code] = item.Results;
|
|
@@ -4463,6 +4924,19 @@ export class ProviderBase {
|
|
|
4463
4924
|
for (const m of AllMetadataArrays) {
|
|
4464
4925
|
target[m.key] = keep.has(m.key.substring(3)) ? fresh[m.key] : kept[m.key];
|
|
4465
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
|
+
});
|
|
4466
4940
|
// Adopted like every other server snapshot. _configLoadedFromServer is NOT set: this read
|
|
4467
4941
|
// fetched no current user (the existing one is carried over), so the next pre-validation
|
|
4468
4942
|
// must still run its check.
|
|
@@ -4485,17 +4959,17 @@ export class ProviderBase {
|
|
|
4485
4959
|
try {
|
|
4486
4960
|
const needsRefresh = await this.CheckToSeeIfRefreshNeeded(providerToUse);
|
|
4487
4961
|
if (needsRefresh) {
|
|
4488
|
-
LogStatusEx({ message: `⚡ [Metadata Cache] Background check: metadata is stale — refreshing...`, verboseOnly:
|
|
4962
|
+
LogStatusEx({ message: `⚡ [Metadata Cache] Background check: metadata is stale — refreshing...`, verboseOnly: true });
|
|
4489
4963
|
const start = Date.now();
|
|
4490
4964
|
const res = await this.GetAllMetadata(providerToUse, false);
|
|
4491
4965
|
const elapsed = Date.now() - start;
|
|
4492
4966
|
if (res) {
|
|
4493
4967
|
await this.adoptServerMetadata(res);
|
|
4494
|
-
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 });
|
|
4495
4969
|
}
|
|
4496
4970
|
}
|
|
4497
4971
|
else {
|
|
4498
|
-
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 });
|
|
4499
4973
|
await this.RefreshCurrentUser();
|
|
4500
4974
|
}
|
|
4501
4975
|
}
|
|
@@ -4545,17 +5019,17 @@ export class ProviderBase {
|
|
|
4545
5019
|
this.RefreshCurrentUser(),
|
|
4546
5020
|
]);
|
|
4547
5021
|
if (needsRefresh) {
|
|
4548
|
-
|
|
5022
|
+
LogStatus(`⚡ [Metadata Cache] Pre-validation: metadata is stale — refreshing before engine startup`);
|
|
4549
5023
|
const start = Date.now();
|
|
4550
5024
|
const res = await this.GetAllMetadata(providerToUse, false);
|
|
4551
5025
|
const elapsed = Date.now() - start;
|
|
4552
5026
|
if (res) {
|
|
4553
5027
|
await this.adoptServerMetadata(res);
|
|
4554
|
-
|
|
5028
|
+
LogStatus(`⚡ [Metadata Cache] Pre-validation refresh complete (${elapsed}ms)`);
|
|
4555
5029
|
}
|
|
4556
5030
|
}
|
|
4557
5031
|
else {
|
|
4558
|
-
|
|
5032
|
+
LogStatus(`⚡ [Metadata Cache] Pre-validation: metadata is current`);
|
|
4559
5033
|
}
|
|
4560
5034
|
}
|
|
4561
5035
|
catch (e) {
|
|
@@ -4622,18 +5096,26 @@ export class ProviderBase {
|
|
|
4622
5096
|
* customized adoption through CloneAllMetadata. Remove the CloneAllMetadata
|
|
4623
5097
|
* override to activate a CreateSharedMetadataShell override.
|
|
4624
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
|
+
*/
|
|
4625
5110
|
CreateSharedMetadataShell(shared) {
|
|
4626
|
-
const shell =
|
|
4627
|
-
for (const m of AllMetadataArrays) {
|
|
4628
|
-
shell[m.key] = [...(shared[m.key] ?? [])];
|
|
4629
|
-
}
|
|
5111
|
+
const shell = Object.create(shared);
|
|
4630
5112
|
shell.CurrentUser = this.CurrentUser; // same semantics the deep clone had — per-instance, not shared
|
|
4631
5113
|
return shell;
|
|
4632
5114
|
}
|
|
4633
5115
|
/**
|
|
4634
5116
|
* Adopts the global provider's metadata for this instance without reloading it
|
|
4635
5117
|
* from the server: shares the (immutable post-Config) metadata arrays by
|
|
4636
|
-
* reference via {@link CreateSharedMetadataShell} and builds this instance's
|
|
5118
|
+
* reference via {@link CreateSharedMetadataShell} and shares or builds this instance's
|
|
4637
5119
|
* entity lookup maps.
|
|
4638
5120
|
*/
|
|
4639
5121
|
CopyMetadataFromGlobalProvider() {
|
|
@@ -4642,7 +5124,8 @@ export class ProviderBase {
|
|
|
4642
5124
|
// registered-but-not-yet-configured global would otherwise donate an empty graph
|
|
4643
5125
|
// and this Config would "succeed" with zero entities. Falling through to the
|
|
4644
5126
|
// normal load path is the correct behavior in that case.
|
|
4645
|
-
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;
|
|
4646
5129
|
if ((globalMetadata?.AllEntities?.length ?? 0) > 0) {
|
|
4647
5130
|
// Back-compat: before #3083 this path called the overridable CloneAllMetadata,
|
|
4648
5131
|
// so external subclasses could customize adoption (e.g. tenant-filtered deep
|
|
@@ -4652,7 +5135,17 @@ export class ProviderBase {
|
|
|
4652
5135
|
const adopted = subclassOverridesClone
|
|
4653
5136
|
? this.CloneAllMetadata(globalMetadata)
|
|
4654
5137
|
: this.CreateSharedMetadataShell(globalMetadata);
|
|
4655
|
-
|
|
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
|
+
}
|
|
4656
5149
|
return true;
|
|
4657
5150
|
}
|
|
4658
5151
|
return false;
|
|
@@ -4734,7 +5227,7 @@ export class ProviderBase {
|
|
|
4734
5227
|
simpleMetadata[r.Code] = r.Results;
|
|
4735
5228
|
}
|
|
4736
5229
|
// Post Process Entities because there's some special handling of the sub-objects
|
|
4737
|
-
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);
|
|
4738
5231
|
// Post Process the Applications, because we want to handle the sub-objects properly.
|
|
4739
5232
|
simpleMetadata.AllApplications = simpleMetadata.Applications.map((a) => {
|
|
4740
5233
|
a.ApplicationEntities = simpleMetadata.ApplicationEntities.filter((ae) => UUIDsEqual(ae.ApplicationID, a.ID));
|
|
@@ -4821,7 +5314,7 @@ export class ProviderBase {
|
|
|
4821
5314
|
}
|
|
4822
5315
|
return map;
|
|
4823
5316
|
}
|
|
4824
|
-
PostProcessEntityMetadata(entities, fields, fieldValues, permissions, relationships, settings, organicKeys, organicKeyRelatedEntities, fieldPermissions) {
|
|
5317
|
+
PostProcessEntityMetadata(entities, fields, fieldValues, permissions, relationships, settings, organicKeys, organicKeyRelatedEntities, fieldPermissions, alternateBaseViews) {
|
|
4825
5318
|
const result = [];
|
|
4826
5319
|
// Sort entities alphabetically by name to ensure deterministic ordering
|
|
4827
5320
|
// This prevents non-deterministic output in CodeGen and other metadata consumers
|
|
@@ -4859,6 +5352,9 @@ export class ProviderBase {
|
|
|
4859
5352
|
const activeOrganicKeysByEntityId = organicKeys
|
|
4860
5353
|
? this.groupByNormalizedUUID(organicKeys.filter(ok => ok.Status === 'Active'), ok => ok.EntityID)
|
|
4861
5354
|
: new Map();
|
|
5355
|
+
const alternateBaseViewsByEntityId = alternateBaseViews && alternateBaseViews.length > 0
|
|
5356
|
+
? this.groupByNormalizedUUID(alternateBaseViews, abv => abv.EntityID)
|
|
5357
|
+
: new Map();
|
|
4862
5358
|
for (const e of sortedEntities) {
|
|
4863
5359
|
const entityIdKey = NormalizeUUID(e.ID);
|
|
4864
5360
|
const entityFields = fieldsByEntityId.get(entityIdKey) || [];
|
|
@@ -4870,6 +5366,9 @@ export class ProviderBase {
|
|
|
4870
5366
|
if (organicKeys) {
|
|
4871
5367
|
e.EntityOrganicKeys = activeOrganicKeysByEntityId.get(entityIdKey) || [];
|
|
4872
5368
|
}
|
|
5369
|
+
if (alternateBaseViews) {
|
|
5370
|
+
e.EntityAlternateBaseViews = alternateBaseViewsByEntityId.get(entityIdKey) || [];
|
|
5371
|
+
}
|
|
4873
5372
|
result.push(new EntityInfo(e, this));
|
|
4874
5373
|
}
|
|
4875
5374
|
// Check for schema name collision: if both 'MJ' and 'MJCustom' schemas exist,
|
|
@@ -5655,18 +6154,22 @@ export class ProviderBase {
|
|
|
5655
6154
|
}
|
|
5656
6155
|
/**
|
|
5657
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.
|
|
5658
6159
|
* Called automatically from UpdateLocalMetadata().
|
|
5659
6160
|
*/
|
|
5660
6161
|
RebuildEntityMaps() {
|
|
5661
6162
|
const entities = this._localMetadata?.AllEntities;
|
|
5662
|
-
|
|
5663
|
-
|
|
6163
|
+
const byName = new Map();
|
|
6164
|
+
const byID = new Map();
|
|
5664
6165
|
if (entities) {
|
|
5665
6166
|
for (const e of entities) {
|
|
5666
|
-
|
|
5667
|
-
|
|
6167
|
+
byName.set(e.Name.trim().toLowerCase(), e);
|
|
6168
|
+
byID.set(NormalizeUUID(e.ID), e);
|
|
5668
6169
|
}
|
|
5669
6170
|
}
|
|
6171
|
+
this._entityMapByName = byName;
|
|
6172
|
+
this._entityMapByID = byID;
|
|
5670
6173
|
}
|
|
5671
6174
|
/**
|
|
5672
6175
|
* Returns the filesystem provider for the current environment.
|