@memberjunction/core 6.2.0-edge.5 → 6.2.0-edge.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generic/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/EntityFieldRules.d.ts.map +1 -1
- package/dist/generic/EntityFieldRules.js +1 -0
- package/dist/generic/EntityFieldRules.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 +171 -17
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +722 -117
- 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 +216 -21
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +969 -100
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/baseRemotableOperation.d.ts +95 -6
- package/dist/generic/baseRemotableOperation.d.ts.map +1 -1
- package/dist/generic/baseRemotableOperation.js +112 -8
- package/dist/generic/baseRemotableOperation.js.map +1 -1
- package/dist/generic/contextTokens.d.ts +136 -0
- package/dist/generic/contextTokens.d.ts.map +1 -0
- package/dist/generic/contextTokens.js +319 -0
- package/dist/generic/contextTokens.js.map +1 -0
- package/dist/generic/databaseProviderBase.d.ts +103 -5
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +381 -52
- 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 +234 -9
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +592 -77
- 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/externalDataSourceReadRouter.d.ts +10 -0
- package/dist/generic/externalDataSourceReadRouter.d.ts.map +1 -1
- package/dist/generic/externalDataSourceReadRouter.js +12 -0
- package/dist/generic/externalDataSourceReadRouter.js.map +1 -1
- package/dist/generic/fieldSecuritySQLGate.d.ts +359 -0
- package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -0
- package/dist/generic/fieldSecuritySQLGate.js +902 -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 +64 -2
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/listMembershipFilter.d.ts +5 -0
- package/dist/generic/listMembershipFilter.d.ts.map +1 -0
- package/dist/generic/listMembershipFilter.js +23 -0
- package/dist/generic/listMembershipFilter.js.map +1 -0
- package/dist/generic/localCacheManager.d.ts +126 -14
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +348 -48
- 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 +408 -57
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +1051 -310
- 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 +18 -0
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
- package/dist/generic/relatedRecordCollection.js +55 -7
- package/dist/generic/relatedRecordCollection.js.map +1 -1
- package/dist/generic/remoteOperationDispatch.d.ts.map +1 -1
- package/dist/generic/remoteOperationDispatch.js +14 -2
- package/dist/generic/remoteOperationDispatch.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 +33 -6
- package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.js +119 -29
- 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/generic/transportError.d.ts +41 -0
- package/dist/generic/transportError.d.ts.map +1 -0
- package/dist/generic/transportError.js +167 -0
- package/dist/generic/transportError.js.map +1 -0
- package/dist/generic/util.d.ts +12 -0
- package/dist/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +52 -3
- package/dist/generic/util.js.map +1 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +102 -0
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +40 -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,17 @@
|
|
|
1
1
|
import { BaseEntity } from "./baseEntity.js";
|
|
2
|
+
import { IsTransportError } from './transportError.js';
|
|
2
3
|
import { EntityDocumentTypeInfo, EntityFieldTSType, EntityInfo, EntityPermissionType, FieldSecurityDenialMessage, FieldSecurityError } from "./entityInfo.js";
|
|
3
|
-
import {
|
|
4
|
+
import { GetEffectiveAlternateBaseViewName, IsExplicitBaseViewOverride } from "./alternateBaseViews.js";
|
|
5
|
+
import { ProviderType, AllMetadata } from "./interfaces.js";
|
|
4
6
|
import { RecordChangeFieldSecurityProjector } from "./recordChangeFieldSecurity.js";
|
|
5
7
|
import { ComputeRRF } from "./scoring/ReciprocalRankFusion.js";
|
|
6
8
|
import { LocalCacheManager, CacheCategory } from "./localCacheManager.js";
|
|
7
9
|
import { ApplicationInfo } from "../generic/applicationInfo.js";
|
|
8
10
|
import { AuditLogTypeInfo, AuthorizationInfo, AuthorizationRoleInfo, RoleInfo, RowLevelSecurityFilterInfo, UserInfo } from "./securityInfo.js";
|
|
9
|
-
import { MJGlobal, MJEventType, NormalizeUUID, UUIDsEqual, EscapeSQLString, ordinalCompare } from "@memberjunction/global";
|
|
10
|
-
import {
|
|
11
|
+
import { MJGlobal, MJEventType, NormalizeUUID, UUIDsEqual, IndexByUUID, EscapeSQLString, ordinalCompare } from "@memberjunction/global";
|
|
12
|
+
import { FieldSecuritySQLGate } from "./fieldSecuritySQLGate.js";
|
|
11
13
|
import { TelemetryManager } from "./telemetryManager.js";
|
|
12
|
-
import { LogDebug, LogError, LogStatus, LogStatusEx } from "./logging.js";
|
|
14
|
+
import { LogDebug, LogError, LogStatus, LogStatusEx, LogWarning } from "./logging.js";
|
|
13
15
|
import { QueryCategoryInfo, QueryFieldInfo, QueryInfo, QueryPermissionInfo, QueryEntityInfo, QueryParameterInfo, QueryDependencyInfo, SQLDialectInfo, QuerySQLInfo } from "./queryInfo.js";
|
|
14
16
|
import { LibraryInfo } from "./libraryInfo.js";
|
|
15
17
|
import { CompositeKey } from "./compositeKey.js";
|
|
@@ -20,6 +22,8 @@ import { IsPlatformSQL } from "./platformSQL.js";
|
|
|
20
22
|
import { GetDataHooks } from "./dataHooks.js";
|
|
21
23
|
import { TransformSimpleObjectToEntityObject } from "./util.js";
|
|
22
24
|
import { LoadRelatedRecordsBatched } from "./relatedRecordBatchLoader.js";
|
|
25
|
+
import { GetLocalTimeZone } from "./contextTokens.js";
|
|
26
|
+
import { FindQueryContextTokens, QueryContextCacheValues } from "./queryTemplateContext.js";
|
|
23
27
|
/**
|
|
24
28
|
* Creates a new instance of AllMetadata from a simple object.
|
|
25
29
|
* Handles deserialization and proper instantiation of all metadata classes.
|
|
@@ -249,10 +253,11 @@ export class ProviderBase {
|
|
|
249
253
|
*/
|
|
250
254
|
this._metadataReadsJoinTransaction = false;
|
|
251
255
|
/**
|
|
252
|
-
* The MJ_Metadata dataset items (code + entity name) as of the last
|
|
256
|
+
* The MJ_Metadata dataset items (code + entity name + row count) as of the last load. The remote
|
|
253
257
|
* timestamps ({@link GetLatestMetadataUpdates}) are keyed by entity name while a dataset
|
|
254
258
|
* 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})
|
|
259
|
+
* into a scoped reload (see {@link PartialRefreshItemCodes}) and lets {@link adoptServerMetadata}
|
|
260
|
+
* verify that the received dataset items' row counts match `_latestRemoteMetadataTimestamps`.
|
|
256
261
|
*/
|
|
257
262
|
this._metadataDatasetItems = null;
|
|
258
263
|
/** Debounce timer for {@link scheduleMetadataMemberRefresh}. */
|
|
@@ -266,7 +271,16 @@ export class ProviderBase {
|
|
|
266
271
|
this._peerMetadataNoticeTimer = null;
|
|
267
272
|
this._metadataMemberRefreshWaits = 0;
|
|
268
273
|
this._clientFingerprintMemo = new WeakMap();
|
|
274
|
+
this._requestDeniedReadFields = new WeakMap();
|
|
269
275
|
this._cachedVisibleExplorerNavigationItems = null;
|
|
276
|
+
/**
|
|
277
|
+
* Whether the most recent {@link RefreshRemoteMetadataTimestamps} actually REACHED the
|
|
278
|
+
* server, regardless of whether it came back with usable timestamps.
|
|
279
|
+
*
|
|
280
|
+
* Starts `true` so that nothing reads "unreachable" before a read has been attempted.
|
|
281
|
+
* See {@link ReadRemoteMetadataTimestamps} for why the distinction exists.
|
|
282
|
+
*/
|
|
283
|
+
this._lastRemoteTimestampReadReached = true;
|
|
270
284
|
/** Guard for {@link logMetadataSnapshotDisabledOnce}. */
|
|
271
285
|
this._metadataSnapshotSkipLogged = false;
|
|
272
286
|
}
|
|
@@ -556,7 +570,13 @@ export class ProviderBase {
|
|
|
556
570
|
this._metadataDatasetEntityNames = names;
|
|
557
571
|
this._metadataDatasetItems = (dataset.Results ?? [])
|
|
558
572
|
.filter(item => item.Code && item.EntityName)
|
|
559
|
-
.map(item => ({
|
|
573
|
+
.map(item => ({
|
|
574
|
+
Code: item.Code,
|
|
575
|
+
EntityName: item.EntityName,
|
|
576
|
+
EntityID: item.EntityID,
|
|
577
|
+
RowCount: Array.isArray(item.Results) ? item.Results.length : undefined,
|
|
578
|
+
LatestUpdateDate: item.LatestUpdateDate
|
|
579
|
+
}));
|
|
560
580
|
this.ensureInflightViewInvalidation();
|
|
561
581
|
}
|
|
562
582
|
/**
|
|
@@ -1076,6 +1096,7 @@ export class ProviderBase {
|
|
|
1076
1096
|
if (params.OnDataChanged && preResult.fingerprint) {
|
|
1077
1097
|
preResult.cachedResult.Unsubscribe = LocalCacheManager.Instance.RegisterChangeCallback(preResult.fingerprint, params.OnDataChanged);
|
|
1078
1098
|
}
|
|
1099
|
+
ProviderBase.clearSkippedTotalRowCount(params, preResult.cachedResult);
|
|
1079
1100
|
return preResult.cachedResult;
|
|
1080
1101
|
}
|
|
1081
1102
|
// Cache miss — execute query, then post-process (stores in cache)
|
|
@@ -1084,6 +1105,7 @@ export class ProviderBase {
|
|
|
1084
1105
|
// PostRunView copies any hook-supplied replacement onto `result` in place, so this
|
|
1085
1106
|
// reference reflects the hook chain's output.
|
|
1086
1107
|
await this.PostRunView(result, params, preResult, contextUser);
|
|
1108
|
+
ProviderBase.clearSkippedTotalRowCount(params, result);
|
|
1087
1109
|
return result;
|
|
1088
1110
|
}
|
|
1089
1111
|
// Delegate to RunViews, which uses the smart cache check (lightweight maxUpdatedAt +
|
|
@@ -1119,7 +1141,10 @@ export class ProviderBase {
|
|
|
1119
1141
|
return this.executeRunViewsPipeline(params, contextUser);
|
|
1120
1142
|
}
|
|
1121
1143
|
// ── Coalescing: merge concurrent RunViews into one mega-batch ──
|
|
1122
|
-
|
|
1144
|
+
// A run carrying view properties is never merged: a merged batch with any CacheLocal member
|
|
1145
|
+
// goes through the cache-check transport, which carries dynamic runs only and would drop the
|
|
1146
|
+
// view and its properties.
|
|
1147
|
+
if (ProviderBase.CoalesceWindowMs > 0 && !this.TrustLocalCacheCompletely && !params.some(ProviderBase.carriesViewProperties)) {
|
|
1123
1148
|
// Only coalesce on the client side where cache validation round-trips are expensive.
|
|
1124
1149
|
// Server-side trusts its cache (no network calls), so coalescing adds no benefit.
|
|
1125
1150
|
return this.enqueueCoalescedRunViews(params, contextUser);
|
|
@@ -1153,27 +1178,7 @@ export class ProviderBase {
|
|
|
1153
1178
|
// ── Fresh execution ──
|
|
1154
1179
|
const promise = this.executeRunViewsPipeline(params, contextUser)
|
|
1155
1180
|
.then(results => {
|
|
1156
|
-
|
|
1157
|
-
// extreme churn (hundreds of distinct keys resolving within one linger
|
|
1158
|
-
// window) skip lingering rather than hold more result arrays in memory —
|
|
1159
|
-
// the linger is a latency optimization, never a correctness requirement.
|
|
1160
|
-
const entry = this._inflightViews.get(key);
|
|
1161
|
-
if (entry && entry.promise === promise) {
|
|
1162
|
-
if (ProviderBase.DedupLingerMs > 0 && this._inflightViews.size <= ProviderBase.MaxLingerEntries) {
|
|
1163
|
-
entry.resolvedResults = results;
|
|
1164
|
-
entry.resolvedAt = Date.now();
|
|
1165
|
-
// Schedule cleanup after linger expires
|
|
1166
|
-
setTimeout(() => {
|
|
1167
|
-
const current = this._inflightViews.get(key);
|
|
1168
|
-
if (current && current.promise === promise) {
|
|
1169
|
-
this._inflightViews.delete(key);
|
|
1170
|
-
}
|
|
1171
|
-
}, ProviderBase.DedupLingerMs);
|
|
1172
|
-
}
|
|
1173
|
-
else {
|
|
1174
|
-
this._inflightViews.delete(key);
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1181
|
+
this.stashInflightLingerResult(key, promise, results, true);
|
|
1177
1182
|
return results;
|
|
1178
1183
|
})
|
|
1179
1184
|
.catch(err => {
|
|
@@ -1190,6 +1195,36 @@ export class ProviderBase {
|
|
|
1190
1195
|
return results.map(r => this.shallowCopyResult(r));
|
|
1191
1196
|
}
|
|
1192
1197
|
// ── Dedup helpers ──────────────────────────────────────────────────
|
|
1198
|
+
/**
|
|
1199
|
+
* Stashes resolved RunViews results on `_inflightViews` for `DedupLingerMs` and schedules
|
|
1200
|
+
* cleanup via an isolated static `WeakRef` timer helper so the timer closure's V8 lexical
|
|
1201
|
+
* context never retains `this`, `promise`, or `results` on short-lived per-request providers.
|
|
1202
|
+
*/
|
|
1203
|
+
stashInflightLingerResult(key, promise, results, enforceMaxEntries) {
|
|
1204
|
+
const entry = this._inflightViews.get(key);
|
|
1205
|
+
if (!entry || entry.promise !== promise) {
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
if (ProviderBase.DedupLingerMs > 0 && (!enforceMaxEntries || this._inflightViews.size <= ProviderBase.MaxLingerEntries)) {
|
|
1209
|
+
const resolvedAt = Date.now();
|
|
1210
|
+
entry.resolvedResults = results;
|
|
1211
|
+
entry.resolvedAt = resolvedAt;
|
|
1212
|
+
ProviderBase.scheduleInflightLingerExpiry(new WeakRef(this), key, resolvedAt);
|
|
1213
|
+
}
|
|
1214
|
+
else {
|
|
1215
|
+
this._inflightViews.delete(key);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
static scheduleInflightLingerExpiry(selfRef, key, resolvedAt) {
|
|
1219
|
+
const timer = setTimeout(() => {
|
|
1220
|
+
const self = selfRef.deref();
|
|
1221
|
+
const current = self?._inflightViews.get(key);
|
|
1222
|
+
if (current && current.resolvedAt === resolvedAt) {
|
|
1223
|
+
self?._inflightViews.delete(key);
|
|
1224
|
+
}
|
|
1225
|
+
}, ProviderBase.DedupLingerMs);
|
|
1226
|
+
timer?.unref?.();
|
|
1227
|
+
}
|
|
1193
1228
|
/**
|
|
1194
1229
|
* The original RunViews execution pipeline (pre-processing, cache,
|
|
1195
1230
|
* internal execution, post-processing).
|
|
@@ -1197,6 +1232,55 @@ export class ProviderBase {
|
|
|
1197
1232
|
async executeRunViewsPipeline(params, contextUser) {
|
|
1198
1233
|
// Pre-processing for batch
|
|
1199
1234
|
const preResult = await this.PreRunViews(params, contextUser);
|
|
1235
|
+
const refused = preResult.refusedResults;
|
|
1236
|
+
if (!refused) {
|
|
1237
|
+
return this.runAdmittedViews(params, preResult, contextUser);
|
|
1238
|
+
}
|
|
1239
|
+
// Every later stage works on the admitted views only — the same subset, in the same
|
|
1240
|
+
// order, that PreRunViews built its cache maps and smart-cache-check params from.
|
|
1241
|
+
const admitted = params.filter((_, i) => !refused.has(i));
|
|
1242
|
+
const admittedResults = await this.runAdmittedViews(admitted, preResult, contextUser);
|
|
1243
|
+
return ProviderBase.interleaveRefusedResults(admittedResults, refused, params.length);
|
|
1244
|
+
}
|
|
1245
|
+
/**
|
|
1246
|
+
* Puts each refused view's result back at its own index of the caller's batch, with the
|
|
1247
|
+
* admitted views' results filling the remaining indexes in order.
|
|
1248
|
+
*/
|
|
1249
|
+
static interleaveRefusedResults(admittedResults, refused, batchSize) {
|
|
1250
|
+
const merged = [];
|
|
1251
|
+
let next = 0;
|
|
1252
|
+
for (let i = 0; i < batchSize; i++) {
|
|
1253
|
+
const refusal = refused.get(i);
|
|
1254
|
+
merged.push(refusal ? refusal : admittedResults[next++]);
|
|
1255
|
+
}
|
|
1256
|
+
return merged;
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* Keeps the `TotalRowCount` contract on every way out of the pipeline: a caller that passed
|
|
1260
|
+
* `SkipTotalRowCount` gets `undefined`, never a number. Cache hits, the smart-cache check and
|
|
1261
|
+
* the GraphQL wire each rebuild a result and each have a reason to fill in a row count, so the
|
|
1262
|
+
* contract is applied once here rather than at each of those sites.
|
|
1263
|
+
*/
|
|
1264
|
+
static clearSkippedTotalRowCount(params, result) {
|
|
1265
|
+
if (result && params.SkipTotalRowCount === true && params.ResultType !== 'count_only') {
|
|
1266
|
+
result.TotalRowCount = undefined;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Runs a RunViews batch of the views the field-security predicate gate admitted, and applies
|
|
1271
|
+
* the `TotalRowCount` contract to whichever stage produced each result.
|
|
1272
|
+
*/
|
|
1273
|
+
async runAdmittedViews(params, preResult, contextUser) {
|
|
1274
|
+
const results = await this.runAdmittedViewStages(params, preResult, contextUser);
|
|
1275
|
+
params.forEach((param, i) => ProviderBase.clearSkippedTotalRowCount(param, results[i]));
|
|
1276
|
+
return results;
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* The cache, database and post-processing stages of a RunViews batch, for the views the
|
|
1280
|
+
* field-security predicate gate admitted. `params` and every index map in `preResult`
|
|
1281
|
+
* describe the same admitted views.
|
|
1282
|
+
*/
|
|
1283
|
+
async runAdmittedViewStages(params, preResult, contextUser) {
|
|
1200
1284
|
// Check for smart cache check mode
|
|
1201
1285
|
if (preResult.useSmartCacheCheck && preResult.smartCacheCheckParams) {
|
|
1202
1286
|
return this.executeSmartCacheCheck(params, preResult, contextUser);
|
|
@@ -1466,22 +1550,7 @@ export class ProviderBase {
|
|
|
1466
1550
|
// ── Fresh execution ──
|
|
1467
1551
|
const promise = this.executeRunViewsPipeline(params, contextUser)
|
|
1468
1552
|
.then(results => {
|
|
1469
|
-
|
|
1470
|
-
if (entry && entry.promise === promise) {
|
|
1471
|
-
entry.resolvedResults = results;
|
|
1472
|
-
entry.resolvedAt = Date.now();
|
|
1473
|
-
if (ProviderBase.DedupLingerMs > 0) {
|
|
1474
|
-
setTimeout(() => {
|
|
1475
|
-
const current = this._inflightViews.get(key);
|
|
1476
|
-
if (current && current.promise === promise) {
|
|
1477
|
-
this._inflightViews.delete(key);
|
|
1478
|
-
}
|
|
1479
|
-
}, ProviderBase.DedupLingerMs);
|
|
1480
|
-
}
|
|
1481
|
-
else {
|
|
1482
|
-
this._inflightViews.delete(key);
|
|
1483
|
-
}
|
|
1484
|
-
}
|
|
1553
|
+
this.stashInflightLingerResult(key, promise, results, false);
|
|
1485
1554
|
return results;
|
|
1486
1555
|
})
|
|
1487
1556
|
.catch(err => {
|
|
@@ -1534,14 +1603,80 @@ export class ProviderBase {
|
|
|
1534
1603
|
* - entities where server caching is disallowed
|
|
1535
1604
|
*/
|
|
1536
1605
|
runViewCacheEligible(param) {
|
|
1606
|
+
// In the browser a run carrying view properties is never cached: the server works out values
|
|
1607
|
+
// the browser cannot see (token defaults, server-set values), so a browser slot could go stale
|
|
1608
|
+
// without any row changing. The server caches it, keyed on the resolved values.
|
|
1609
|
+
if (!this.TrustLocalCacheCompletely && ProviderBase.carriesViewProperties(param)) {
|
|
1610
|
+
return false;
|
|
1611
|
+
}
|
|
1537
1612
|
return !param.BypassCache &&
|
|
1613
|
+
param.CacheLocal !== false &&
|
|
1538
1614
|
!param.AfterKey &&
|
|
1539
1615
|
!IsMaterializedDataSource(param.DataSource) &&
|
|
1616
|
+
!this.isAlternateBaseViewCacheBypassed(param) &&
|
|
1540
1617
|
param.ResultType !== 'count_only' &&
|
|
1541
1618
|
(param.CacheLocal === true || this.TrustLocalCacheCompletely) &&
|
|
1542
1619
|
this.IsServerCacheAllowedForEntity(param);
|
|
1543
|
-
// Deliberately not user-dependent:
|
|
1544
|
-
//
|
|
1620
|
+
// Deliberately not user-dependent: field security changes which slot a request uses
|
|
1621
|
+
// (the fls: segment), never whether it is cacheable.
|
|
1622
|
+
}
|
|
1623
|
+
/**
|
|
1624
|
+
* True when the caller explicitly requested a non-empty `Fields` projection on a plain-object
|
|
1625
|
+
* (`ResultType !== 'entity_object'`) read.
|
|
1626
|
+
*/
|
|
1627
|
+
hasNarrowFieldsOverride(param) {
|
|
1628
|
+
return Boolean(param.Fields && param.Fields.length > 0 && param.ResultType !== 'entity_object');
|
|
1629
|
+
}
|
|
1630
|
+
/**
|
|
1631
|
+
* True when a cache-miss read should widen `Fields` to the full entity superset AND populate
|
|
1632
|
+
* `LocalCacheManager` with that superset.
|
|
1633
|
+
*
|
|
1634
|
+
* On the server (`TrustLocalCacheCompletely === true`), cache-allowed entities (`AllowCaching === true`)
|
|
1635
|
+
* widen on miss by default so the Fields-agnostic superset slot serves any future `Fields` subset
|
|
1636
|
+
* (`server-cache` S1 -> S2 -> S3), UNLESS the caller explicitly opts out via `CacheLocal: false` or
|
|
1637
|
+
* `BypassCache: true` (in which case narrow SQL `SELECT` pushdown is preserved end-to-end without
|
|
1638
|
+
* polluting the cache). On the client (`TrustLocalCacheCompletely === false`), only `CacheLocal: true`
|
|
1639
|
+
* widens and caches.
|
|
1640
|
+
*/
|
|
1641
|
+
shouldWidenAndPopulateCache(param) {
|
|
1642
|
+
return this.runViewCacheEligible(param);
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
* True when the request targets an `AlternateBaseView` (explicitly on `param.AlternateBaseView`
|
|
1646
|
+
* or pinned on `param.ViewEntity`) whose `CachePolicy` (or an ancestor's `CachePolicy` in its
|
|
1647
|
+
* `ParentID` chain) is `'Bypass'` (`IsBypassCache === true`), when the requested alternate
|
|
1648
|
+
* base view does not resolve to a usable view on the entity, or when a saved User View is
|
|
1649
|
+
* referenced only by `ViewID`/`ViewName` without a loaded `ViewEntity` (fail-closed: we cannot
|
|
1650
|
+
* synchronously inspect `DisplayState.alternateBaseView` until the view is loaded).
|
|
1651
|
+
*/
|
|
1652
|
+
isAlternateBaseViewCacheBypassed(param) {
|
|
1653
|
+
if (IsExplicitBaseViewOverride(param)) {
|
|
1654
|
+
return false;
|
|
1655
|
+
}
|
|
1656
|
+
const effectiveAbv = GetEffectiveAlternateBaseViewName(param);
|
|
1657
|
+
if (!effectiveAbv) {
|
|
1658
|
+
if (!param.ViewEntity && Boolean(param.ViewID || param.ViewName)) {
|
|
1659
|
+
const knownEntity = this.ResolveRunViewEntitySync(param);
|
|
1660
|
+
if (knownEntity) {
|
|
1661
|
+
return knownEntity.AlternateBaseViews.some(v => v.IsBypassCache || !v.IsUsable);
|
|
1662
|
+
}
|
|
1663
|
+
return this.Entities.some(e => e.AlternateBaseViews.some(v => v.IsBypassCache || !v.IsUsable));
|
|
1664
|
+
}
|
|
1665
|
+
return false;
|
|
1666
|
+
}
|
|
1667
|
+
const entity = this.ResolveRunViewEntitySync(param);
|
|
1668
|
+
if (!entity) {
|
|
1669
|
+
return true; // Fail closed when entity cannot be resolved synchronously
|
|
1670
|
+
}
|
|
1671
|
+
const abv = entity.GetAlternateBaseView(effectiveAbv);
|
|
1672
|
+
if (!abv || !abv.IsUsable) {
|
|
1673
|
+
return true;
|
|
1674
|
+
}
|
|
1675
|
+
return abv.IsBypassCache;
|
|
1676
|
+
}
|
|
1677
|
+
/** True when a run carries view properties (even an empty map): the browser sends it to the server as it is. */
|
|
1678
|
+
static carriesViewProperties(param) {
|
|
1679
|
+
return param.Properties !== undefined || param.ServerProperties !== undefined;
|
|
1545
1680
|
}
|
|
1546
1681
|
/** True when the entity is a CodeGen materialized-query wrapper (materialized_vw*) whose snapshot is
|
|
1547
1682
|
* refreshed out-of-band — the same one IsServerCacheAllowedForEntity excludes from the server cache. */
|
|
@@ -1569,6 +1704,7 @@ export class ProviderBase {
|
|
|
1569
1704
|
return !param.BypassCache &&
|
|
1570
1705
|
!param.AfterKey &&
|
|
1571
1706
|
!IsMaterializedDataSource(param.DataSource) &&
|
|
1707
|
+
!this.isAlternateBaseViewCacheBypassed(param) &&
|
|
1572
1708
|
param.ResultType !== 'count_only' &&
|
|
1573
1709
|
param.CacheLocal === true &&
|
|
1574
1710
|
!this.isMaterializedWrapperEntity(param);
|
|
@@ -1637,6 +1773,8 @@ export class ProviderBase {
|
|
|
1637
1773
|
p.UserSearchString ?? '',
|
|
1638
1774
|
p.ViewID ?? '',
|
|
1639
1775
|
p.ViewName ?? '',
|
|
1776
|
+
IsExplicitBaseViewOverride(p) ? 'abv_override:base' : '',
|
|
1777
|
+
p.TimeZone ?? '',
|
|
1640
1778
|
contextUser?.ID ?? ''
|
|
1641
1779
|
].join('|');
|
|
1642
1780
|
return `${base}|${extras}`;
|
|
@@ -1698,15 +1836,15 @@ export class ProviderBase {
|
|
|
1698
1836
|
if (memoized) {
|
|
1699
1837
|
return memoized;
|
|
1700
1838
|
}
|
|
1701
|
-
// The signed-in user's row filter (rls:) and
|
|
1702
|
-
//
|
|
1839
|
+
// The signed-in user's row filter (rls:) and field-security key (fls:, see
|
|
1840
|
+
// ComputeRunViewFLSFingerprintKey) are part of client slot identity, as they are on the server.
|
|
1703
1841
|
// The browser cache outlives a session — one IndexedDB store per server URL — and the
|
|
1704
1842
|
// currency check compares only maxUpdatedAt and rowCount, which cannot tell one user's
|
|
1705
1843
|
// rows from another's, nor notice a column. Without these segments, a different user on
|
|
1706
1844
|
// the same browser could be served the previous user's rows, and a user whose field
|
|
1707
1845
|
// access is tightened would keep seeing a column just taken away. Both are empty for an
|
|
1708
1846
|
// unrestricted user, whose fingerprints are unchanged.
|
|
1709
|
-
const base = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString, this.ComputeRunViewRLSWhereClause(param), undefined, this.
|
|
1847
|
+
const base = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString, this.ComputeRunViewRLSWhereClause(param), undefined, this.ComputeRunViewFLSFingerprintKey(param));
|
|
1710
1848
|
// Normalize a FULL-COVERAGE field list to '*' — in the FINGERPRINT only (B44).
|
|
1711
1849
|
//
|
|
1712
1850
|
// entity_object params get widened to an explicit list of every entity field
|
|
@@ -1894,6 +2032,7 @@ export class ProviderBase {
|
|
|
1894
2032
|
ExtraFilter: filter,
|
|
1895
2033
|
ResultType: 'simple',
|
|
1896
2034
|
MaxRows: 1,
|
|
2035
|
+
SkipTotalRowCount: true,
|
|
1897
2036
|
}, contextUser);
|
|
1898
2037
|
if (!r.Success || (r.Results?.length ?? 0) === 0)
|
|
1899
2038
|
return null;
|
|
@@ -2002,6 +2141,7 @@ export class ProviderBase {
|
|
|
2002
2141
|
Fields: entity.PrimaryKeys.map(pk => pk.Name),
|
|
2003
2142
|
ResultType: 'simple',
|
|
2004
2143
|
MaxRows: ids.length,
|
|
2144
|
+
SkipTotalRowCount: true,
|
|
2005
2145
|
}, contextUser);
|
|
2006
2146
|
if (!r.Success)
|
|
2007
2147
|
return new Set();
|
|
@@ -2022,7 +2162,10 @@ export class ProviderBase {
|
|
|
2022
2162
|
// revoked grants stay visible through the linger window.
|
|
2023
2163
|
// SaveViewResults bypasses for the original reason: it creates DB
|
|
2024
2164
|
// records and must not be deduplicated.
|
|
2025
|
-
|
|
2165
|
+
// CacheLocal:false is the server-side opt-out from the RunView cache (read AND write). A
|
|
2166
|
+
// linger hit would hand back rows that were themselves served from that cache, so the
|
|
2167
|
+
// opt-out has to cover the dedup window as well.
|
|
2168
|
+
return params.some(p => p.SaveViewResults === true || p.BypassCache === true || p.CacheLocal === false);
|
|
2026
2169
|
}
|
|
2027
2170
|
/**
|
|
2028
2171
|
* Returns a shallow copy of a RunViewResult: the Results array is a
|
|
@@ -2095,9 +2238,36 @@ export class ProviderBase {
|
|
|
2095
2238
|
authorized: !user || qInfo.UserCanRun(user),
|
|
2096
2239
|
categoryPath: qInfo.CategoryPath,
|
|
2097
2240
|
queryName: qInfo.Name,
|
|
2241
|
+
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
2242
|
queryId: qInfo.ID
|
|
2099
2243
|
};
|
|
2100
2244
|
}
|
|
2245
|
+
/**
|
|
2246
|
+
* The fingerprint segment for what the server sets on a run: `ServerParameters`, and the
|
|
2247
|
+
* `Context.*` values the query reads, worked out for this user, zone and day. Empty when the
|
|
2248
|
+
* run has neither, so the fingerprint is exactly what it was before either existed.
|
|
2249
|
+
* @returns null when the query reads `Context.Now`: such a result is never reused
|
|
2250
|
+
*/
|
|
2251
|
+
queryCacheServerSegment(params, contextUser, auth) {
|
|
2252
|
+
const segment = {};
|
|
2253
|
+
if (params.ServerParameters && Object.keys(params.ServerParameters).length > 0) {
|
|
2254
|
+
segment.__serverParameters = params.ServerParameters;
|
|
2255
|
+
}
|
|
2256
|
+
// ponytail: an unresolvable query (not in metadata) adds no Context segment. The TTL gate then
|
|
2257
|
+
// serves only the slot's warmer, so users stay apart, but a date value can be stale for up to
|
|
2258
|
+
// the slot's TTL across midnight. Resolve the SQL here too if that ever matters.
|
|
2259
|
+
const tokens = auth.resolvable ? auth.contextTokens ?? [] : [];
|
|
2260
|
+
if (tokens.length > 0) {
|
|
2261
|
+
// A browser's server works out dates in the zone the browser sends; a server run uses its own TimeZone or UTC.
|
|
2262
|
+
const timeZone = params.TimeZone ?? (this.ProviderType === ProviderType.Network ? GetLocalTimeZone() : undefined);
|
|
2263
|
+
const values = QueryContextCacheValues(tokens, { User: contextUser ?? this.CurrentUser, TimeZone: timeZone });
|
|
2264
|
+
if (values === null) {
|
|
2265
|
+
return null;
|
|
2266
|
+
}
|
|
2267
|
+
segment.__context = values;
|
|
2268
|
+
}
|
|
2269
|
+
return segment;
|
|
2270
|
+
}
|
|
2101
2271
|
async RunQuery(params, contextUser) {
|
|
2102
2272
|
// Shallow-clone for symmetry with RunView — pipeline must never mutate caller objects
|
|
2103
2273
|
params = { ...params };
|
|
@@ -2126,6 +2296,8 @@ export class ProviderBase {
|
|
|
2126
2296
|
// (the fingerprint carries no DataSource segment). Keeping materialized out of this layer
|
|
2127
2297
|
// means the only staleness is the refresh cadence the snapshot already advertises.
|
|
2128
2298
|
&& !IsMaterializedDataSource(params.DataSource)
|
|
2299
|
+
// A RequireApproved run must check the approval on every run; a cached slot would skip it.
|
|
2300
|
+
&& !params.RequireApproved
|
|
2129
2301
|
&& LocalCacheManager.Instance.IsInitialized;
|
|
2130
2302
|
let queryFingerprint;
|
|
2131
2303
|
let queryDefinition;
|
|
@@ -2137,25 +2309,32 @@ export class ProviderBase {
|
|
|
2137
2309
|
const queryAuth = this.ResolveQueryCacheAuthorization(params, contextUser);
|
|
2138
2310
|
// MaxRows/StartRow shape the result set — they MUST distinguish cache slots,
|
|
2139
2311
|
// so fold them into the parameters portion of the fingerprint.
|
|
2312
|
+
// Server-set values (ServerParameters, the Context.* values the query reads) shape the
|
|
2313
|
+
// result too, so one user's slot never answers another user or another day.
|
|
2314
|
+
const serverSegment = this.queryCacheServerSegment(params, contextUser, queryAuth);
|
|
2140
2315
|
const fingerprintParams = {
|
|
2141
2316
|
...(params.Parameters ?? {}),
|
|
2142
2317
|
__maxRows: params.MaxRows ?? -1,
|
|
2143
|
-
__startRow: params.StartRow ?? 0
|
|
2318
|
+
__startRow: params.StartRow ?? 0,
|
|
2319
|
+
...serverSegment,
|
|
2320
|
+
...(queryAuth.fieldSecurityKey ? { __fieldSecurity: queryAuth.fieldSecurityKey } : {})
|
|
2144
2321
|
};
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2322
|
+
if (serverSegment !== null) {
|
|
2323
|
+
queryFingerprint = LocalCacheManager.Instance.GenerateRunQueryFingerprint(
|
|
2324
|
+
// The RESOLVED identity when metadata resolves, so a request by ID, by name, or by
|
|
2325
|
+
// both shares one slot (and a query's slots can be found by its ID). The caller's
|
|
2326
|
+
// identity otherwise.
|
|
2327
|
+
queryAuth.resolvable ? queryAuth.queryId : params.QueryID, queryAuth.resolvable ? queryAuth.queryName : params.QueryName, fingerprintParams, this.InstanceConnectionString,
|
|
2328
|
+
// B46: the full category path is a DISTINGUISHING element. Canonical resolved
|
|
2329
|
+
// path when metadata resolves; the caller-stated path otherwise (runtime-created
|
|
2330
|
+
// queries) — either way, same-named queries in different categories get
|
|
2331
|
+
// different slots.
|
|
2332
|
+
queryAuth.resolvable ? queryAuth.categoryPath : params.CategoryPath);
|
|
2333
|
+
}
|
|
2155
2334
|
queryDefinition = queryAuth.definition;
|
|
2156
2335
|
// 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) {
|
|
2336
|
+
const cached = queryFingerprint ? await LocalCacheManager.Instance.GetRunQueryResult(queryFingerprint, queryDefinition) : null;
|
|
2337
|
+
if (cached && queryFingerprint) {
|
|
2159
2338
|
const serveFromSlot = () => ({
|
|
2160
2339
|
QueryID: cached.queryId ?? params.QueryID ?? '',
|
|
2161
2340
|
QueryName: params.QueryName ?? '',
|
|
@@ -2349,6 +2528,25 @@ export class ProviderBase {
|
|
|
2349
2528
|
}
|
|
2350
2529
|
EntityInfo.AssertEntityActiveStatus(entity, callerName);
|
|
2351
2530
|
}
|
|
2531
|
+
/**
|
|
2532
|
+
* Emits a `[Deprecated]` warning when a cache-hit RunView served rows for a deprecated
|
|
2533
|
+
* `AlternateBaseView` (or an alternate view whose ancestor in `ParentID` is deprecated).
|
|
2534
|
+
* Cache misses log deprecation once inside `ResolveAlternateBaseView` during execution.
|
|
2535
|
+
*/
|
|
2536
|
+
logDeprecatedAlternateBaseViewIfAny(params, callerName) {
|
|
2537
|
+
const effectiveAbv = GetEffectiveAlternateBaseViewName(params);
|
|
2538
|
+
if (!effectiveAbv) {
|
|
2539
|
+
return;
|
|
2540
|
+
}
|
|
2541
|
+
const entity = this.ResolveRunViewEntitySync(params);
|
|
2542
|
+
const abv = entity?.GetAlternateBaseView(effectiveAbv);
|
|
2543
|
+
if (entity && abv?.IsDeprecated) {
|
|
2544
|
+
LogStatusEx({
|
|
2545
|
+
message: `[Deprecated] AlternateBaseView '${abv.Name}' on entity '${entity.Name}' is deprecated (called from ${callerName}).`,
|
|
2546
|
+
verboseOnly: false,
|
|
2547
|
+
});
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2352
2550
|
// Type aliases for cleaner code
|
|
2353
2551
|
get PreRunViewResult() { return this._preRunViewResultType; }
|
|
2354
2552
|
get PreRunViewsResult() { return this._preRunViewsResultType; }
|
|
@@ -2420,7 +2618,7 @@ export class ProviderBase {
|
|
|
2420
2618
|
return entity.GetEffectiveRowFilterWhereClause(user, EntityPermissionType.Read, '');
|
|
2421
2619
|
}
|
|
2422
2620
|
/**
|
|
2423
|
-
* The
|
|
2621
|
+
* The field-security cache key for one entity: a canonical list of the fields the user MAY read.
|
|
2424
2622
|
*
|
|
2425
2623
|
* Keyed on the ALLOWED set rather than the denied set for two reasons. Once metadata ships
|
|
2426
2624
|
* to browsers filtered to what a user may see ([#3485](https://github.com/MemberJunction/MJ/issues/3485)),
|
|
@@ -2432,18 +2630,20 @@ export class ProviderBase {
|
|
|
2432
2630
|
* Returns '' when the entity has field security off or the user is denied nothing, so
|
|
2433
2631
|
* unrestricted users keep byte-identical fingerprints and shared slots.
|
|
2434
2632
|
*
|
|
2435
|
-
*
|
|
2436
|
-
*
|
|
2437
|
-
*
|
|
2633
|
+
* Reads the denied set through {@link ResolveRequestDeniedReadFields}, so the key and the
|
|
2634
|
+
* SELECT list a database provider builds for the same request describe the same columns.
|
|
2635
|
+
*
|
|
2636
|
+
* @param params - The request; also the memo the denied set is shared through
|
|
2637
|
+
* @param contextUser - The request's user; defaults to {@link CurrentUser}
|
|
2438
2638
|
*/
|
|
2439
|
-
ComputeClientFLSAllowedKey(params) {
|
|
2440
|
-
const user = this.CurrentUser;
|
|
2441
|
-
if (!user
|
|
2639
|
+
ComputeClientFLSAllowedKey(params, contextUser) {
|
|
2640
|
+
const user = contextUser ?? this.CurrentUser;
|
|
2641
|
+
if (!user)
|
|
2442
2642
|
return '';
|
|
2443
|
-
const entity = this.
|
|
2444
|
-
if (!entity
|
|
2643
|
+
const entity = this.ResolveRunViewEntitySync(params);
|
|
2644
|
+
if (!entity)
|
|
2445
2645
|
return '';
|
|
2446
|
-
const denied =
|
|
2646
|
+
const denied = this.ResolveRequestDeniedReadFields(params, entity, user);
|
|
2447
2647
|
if (denied.size === 0)
|
|
2448
2648
|
return '';
|
|
2449
2649
|
return entity.Fields
|
|
@@ -2453,32 +2653,107 @@ export class ProviderBase {
|
|
|
2453
2653
|
.join(',');
|
|
2454
2654
|
}
|
|
2455
2655
|
/**
|
|
2456
|
-
* The field-security segment for a {@link LocalCacheManager} RunView fingerprint
|
|
2457
|
-
*
|
|
2656
|
+
* The field-security segment for a {@link LocalCacheManager} RunView fingerprint, on both
|
|
2657
|
+
* tiers: the slot a request reads and fills belongs to the user's permission class.
|
|
2658
|
+
*
|
|
2659
|
+
* - **The entity's allowed columns** ({@link ComputeClientFLSAllowedKey}). A database
|
|
2660
|
+
* provider selects only the columns the reader may see, so a restricted reader's fill is
|
|
2661
|
+
* narrower than an unrestricted one's, and the two must never share a slot. A browser
|
|
2662
|
+
* stores rows exactly as the server returned them and never projects on read, so its
|
|
2663
|
+
* slots need the same identity. It also keeps user searches apart: which rows match a
|
|
2664
|
+
* search depends on which columns the reader may search.
|
|
2665
|
+
* - **For a saved view, the user's denials on every entity.** A view's stored clause can
|
|
2666
|
+
* reach other entities (nested view filters), and those clauses are only screened when the
|
|
2667
|
+
* database runs them, on a miss. Keying the slot on the whole denial profile means a hit
|
|
2668
|
+
* is only ever served from a fill by a user whose clauses screen identically.
|
|
2669
|
+
*
|
|
2670
|
+
* Empty for a user denied nothing, so unrestricted users keep byte-identical fingerprints
|
|
2671
|
+
* and share slots. Read-time projection ({@link ApplyFieldSecurityProjection}) still runs on
|
|
2672
|
+
* every hit and miss, as a backstop.
|
|
2673
|
+
*
|
|
2674
|
+
* @param params - The request
|
|
2675
|
+
* @param contextUser - The request's user; defaults to {@link CurrentUser}. On a server
|
|
2676
|
+
* this must be the request's user, never the process user.
|
|
2677
|
+
*/
|
|
2678
|
+
ComputeRunViewFLSFingerprintKey(params, contextUser) {
|
|
2679
|
+
const user = contextUser ?? this.CurrentUser;
|
|
2680
|
+
const allowed = this.ComputeClientFLSAllowedKey(params, user);
|
|
2681
|
+
const namesView = !!(params.ViewID || params.ViewName || params.ViewEntity);
|
|
2682
|
+
const profile = namesView && user ? this.ComputeFieldSecurityProfileKey(user) : '';
|
|
2683
|
+
return profile ? `${allowed}#view:${profile}` : allowed;
|
|
2684
|
+
}
|
|
2685
|
+
/**
|
|
2686
|
+
* Every field `user` may not read, across all entities: one `entityID:field,field` entry per
|
|
2687
|
+
* entity on which the user is denied something, sorted. '' for a user denied nothing.
|
|
2688
|
+
*
|
|
2689
|
+
* @param user - The user whose denials to describe
|
|
2690
|
+
*/
|
|
2691
|
+
ComputeFieldSecurityProfileKey(user) {
|
|
2692
|
+
// Memoized per (catalog, user) and recomputed whenever field-security configuration or the
|
|
2693
|
+
// user's roles change, so a saved-view RunView does not walk the catalog each time.
|
|
2694
|
+
return FieldSecuritySQLGate.CatalogDenialProfileKey(this.Entities, user);
|
|
2695
|
+
}
|
|
2696
|
+
/**
|
|
2697
|
+
* The fingerprint of the server-cache slot a RunView reads and fills for `contextUser`:
|
|
2698
|
+
* the request, this provider's connection, the user's row-level-security clause and the
|
|
2699
|
+
* user's field-security key. Every server path that reads or writes a RunView slot builds
|
|
2700
|
+
* its fingerprint here, so none of them can leave a segment out — a path that omitted the
|
|
2701
|
+
* user's segments would read or fill another permission class's slot.
|
|
2702
|
+
*
|
|
2703
|
+
* @param params - The request
|
|
2704
|
+
* @param contextUser - The request's user; defaults to {@link CurrentUser}
|
|
2705
|
+
*/
|
|
2706
|
+
ComputeRunViewCacheFingerprint(params, contextUser) {
|
|
2707
|
+
return LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, this.ComputeRunViewRLSWhereClause(params, contextUser), undefined, this.ComputeRunViewFLSFingerprintKey(params, contextUser));
|
|
2708
|
+
}
|
|
2709
|
+
/**
|
|
2710
|
+
* The field-security key a browser sends with a cached slot's status, and the one a server
|
|
2711
|
+
* compares it with: a hash of {@link ComputeRunViewFLSFingerprintKey}, or '' for a user
|
|
2712
|
+
* denied nothing.
|
|
2458
2713
|
*
|
|
2459
|
-
*
|
|
2460
|
-
*
|
|
2461
|
-
*
|
|
2462
|
-
*
|
|
2714
|
+
* The currency check otherwise compares only `maxUpdatedAt` and `rowCount`, and neither
|
|
2715
|
+
* changes when a user loses a column. A browser whose metadata has not refreshed yet keeps
|
|
2716
|
+
* its old slot (same fingerprint) and would be told "current" while still showing the
|
|
2717
|
+
* column; with this key the server sees the mismatch and answers "stale" with fresh rows.
|
|
2463
2718
|
*
|
|
2464
|
-
*
|
|
2465
|
-
*
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2719
|
+
* @param params - The request
|
|
2720
|
+
* @param contextUser - The request's user; defaults to {@link CurrentUser}
|
|
2721
|
+
*/
|
|
2722
|
+
ComputeFieldSecurityCacheStatusKey(params, contextUser) {
|
|
2723
|
+
const key = this.ComputeRunViewFLSFingerprintKey(params, contextUser);
|
|
2724
|
+
return key ? LocalCacheManager.Instance.HashSecuritySegment(key) : '';
|
|
2725
|
+
}
|
|
2726
|
+
/**
|
|
2727
|
+
* The fields `user` may not read on `entity` for this one request, resolved once and reused by
|
|
2728
|
+
* everything that asks again for the same request (`params` object), user object and entity.
|
|
2470
2729
|
*
|
|
2471
|
-
*
|
|
2472
|
-
*
|
|
2730
|
+
* The cache key and the SELECT list are built at different moments of a request. Read
|
|
2731
|
+
* independently, a metadata refresh landing between the two would key a slot for one
|
|
2732
|
+
* permission class and fill it with another's columns. Sharing one answer per request rules
|
|
2733
|
+
* that out. Empty when field security does not apply.
|
|
2734
|
+
*
|
|
2735
|
+
* @param params - The request; the memo is keyed on this object
|
|
2736
|
+
* @param entity - The entity being read
|
|
2737
|
+
* @param user - The request's user
|
|
2473
2738
|
*/
|
|
2474
|
-
|
|
2475
|
-
|
|
2739
|
+
ResolveRequestDeniedReadFields(params, entity, user) {
|
|
2740
|
+
if (!user) {
|
|
2741
|
+
return new Set();
|
|
2742
|
+
}
|
|
2743
|
+
const memo = this._requestDeniedReadFields.get(params);
|
|
2744
|
+
if (memo && memo.User === user && UUIDsEqual(memo.EntityID, entity.ID)) {
|
|
2745
|
+
return memo.Denied;
|
|
2746
|
+
}
|
|
2747
|
+
const denied = entity.FieldLevelSecurityApplies ? entity.GetDeniedReadFields(user) : new Set();
|
|
2748
|
+
this._requestDeniedReadFields.set(params, { User: user, EntityID: entity.ID, Denied: denied });
|
|
2749
|
+
return denied;
|
|
2476
2750
|
}
|
|
2477
2751
|
/**
|
|
2478
2752
|
* 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
|
-
*
|
|
2753
|
+
* One slot per (entity, filter, order, permission class) serves every caller regardless of
|
|
2754
|
+
* the field subset they asked for. Field security is not applied here: a database provider
|
|
2755
|
+
* leaves the user's denied columns out of the SELECT it builds from this list, and the slot
|
|
2756
|
+
* key carries the user's class ({@link ComputeRunViewFLSFingerprintKey}).
|
|
2482
2757
|
*/
|
|
2483
2758
|
ComputeRunViewFetchFields(entity, params) {
|
|
2484
2759
|
const includeBinary = params?.IncludeBinaryFields === true;
|
|
@@ -2533,69 +2808,78 @@ export class ProviderBase {
|
|
|
2533
2808
|
// FIELD-LEVEL SECURITY
|
|
2534
2809
|
// ========================================================================
|
|
2535
2810
|
/**
|
|
2536
|
-
* Rejects a RunView whose
|
|
2537
|
-
*
|
|
2811
|
+
* Rejects a RunView whose caller-authored SQL reads a field the user cannot read: `ExtraFilter`,
|
|
2812
|
+
* `OrderBy`, `OverrideExcludeFilter`, every `Aggregates[].expression`, and — when the request
|
|
2813
|
+
* carries a loaded `ViewEntity` — that view's stored `WhereClause` and `OrderByClause`.
|
|
2538
2814
|
*
|
|
2539
2815
|
* Output projection alone is security theater. A user denied `Salary` can send
|
|
2540
2816
|
* `ExtraFilter: "Salary > 200000"` or `OrderBy: "Salary DESC"` and reconstruct the values
|
|
2541
2817
|
* from which rows come back and in what order — the column never appears in a result, so
|
|
2542
2818
|
* every output-stripping point reports "secure." Aggregates are the same channel in a purer
|
|
2543
2819
|
* 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).
|
|
2820
|
+
* field's exact values directly. `UserSearchString` is handled by excluding denied fields from
|
|
2821
|
+
* the searched set, not by rejection.
|
|
2548
2822
|
*
|
|
2549
|
-
* Lives at the provider layer
|
|
2550
|
-
*
|
|
2551
|
-
*
|
|
2552
|
-
*
|
|
2823
|
+
* Lives at the provider layer because every RunView funnels through here — the batch path,
|
|
2824
|
+
* server-internal agents and actions running under a restricted `contextUser`, and the
|
|
2825
|
+
* resolver alike. A database provider checks the clauses it actually runs a second time, once
|
|
2826
|
+
* a saved view is resolved, so a request that names only a `ViewID` is covered too.
|
|
2553
2827
|
*
|
|
2554
|
-
* The
|
|
2555
|
-
* see {@link ProviderBase.FieldSecurityDenialMessage}.
|
|
2828
|
+
* The reading of each clause, and the error it raises, is {@link FieldSecuritySQLGate}'s.
|
|
2556
2829
|
*/
|
|
2557
2830
|
AssertPredicatesRespectFieldSecurity(params, contextUser) {
|
|
2558
|
-
if (!contextUser
|
|
2831
|
+
if (!contextUser) {
|
|
2559
2832
|
return;
|
|
2560
2833
|
}
|
|
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) {
|
|
2834
|
+
const entity = this.ResolveRunViewEntitySync(params);
|
|
2835
|
+
if (!entity) {
|
|
2567
2836
|
return;
|
|
2568
2837
|
}
|
|
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 = [
|
|
2838
|
+
const view = params.ViewEntity;
|
|
2839
|
+
this.AssertClausesRespectFieldSecurityForEntity(entity, [
|
|
2579
2840
|
params.ExtraFilter,
|
|
2580
|
-
params.
|
|
2841
|
+
params.OverrideExcludeFilter,
|
|
2581
2842
|
...(params.Aggregates ?? []).map(a => a?.expression),
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2843
|
+
view ? view.Get('WhereClause') : undefined,
|
|
2844
|
+
], contextUser);
|
|
2845
|
+
this.AssertSortRespectsFieldSecurityForEntity(entity, [
|
|
2846
|
+
params.OrderBy,
|
|
2847
|
+
view?.OrderByClause,
|
|
2848
|
+
], contextUser);
|
|
2849
|
+
}
|
|
2850
|
+
/**
|
|
2851
|
+
* Throws a {@link FieldSecurityError} when any of `clauses` — SQL fragments that run against
|
|
2852
|
+
* `entity`'s view — reads a field `user` cannot read, reads whole rows of an entity the user is
|
|
2853
|
+
* denied fields on, or cannot be read well enough to tell. Non-string and blank entries are
|
|
2854
|
+
* skipped. A no-op, with nothing scanned, for a user denied nothing on any entity.
|
|
2855
|
+
*
|
|
2856
|
+
* The shared entry point for every clause slot a provider runs: RunView parameters, a saved
|
|
2857
|
+
* view's stored clauses, dataset item filters, user-search text spliced as SQL.
|
|
2858
|
+
*/
|
|
2859
|
+
AssertClausesRespectFieldSecurityForEntity(entity, clauses, user) {
|
|
2860
|
+
this.assertFragmentsAllowed(entity, clauses, user, (sql, context) => FieldSecuritySQLGate.FindViolation(sql, context));
|
|
2861
|
+
}
|
|
2862
|
+
/**
|
|
2863
|
+
* {@link AssertClausesRespectFieldSecurityForEntity} for ORDER BY fragments, which also refuses
|
|
2864
|
+
* a sort by column position for a user denied a field on `entity`
|
|
2865
|
+
* ({@link FieldSecuritySQLGate.FindSortViolation}).
|
|
2866
|
+
*/
|
|
2867
|
+
AssertSortRespectsFieldSecurityForEntity(entity, orderBys, user) {
|
|
2868
|
+
this.assertFragmentsAllowed(entity, orderBys, user, (sql, context) => FieldSecuritySQLGate.FindSortViolation(sql, context));
|
|
2869
|
+
}
|
|
2870
|
+
/** Throws the field-security error for the first of `fragments` that `find` refuses. */
|
|
2871
|
+
assertFragmentsAllowed(entity, fragments, user, find) {
|
|
2872
|
+
const context = { User: user, Dialect: this.PlatformKey, Catalog: this.Entities, TargetEntity: entity };
|
|
2873
|
+
for (const clause of fragments) {
|
|
2874
|
+
if (typeof clause !== 'string' || clause.trim().length === 0) {
|
|
2585
2875
|
continue;
|
|
2586
2876
|
}
|
|
2587
|
-
const
|
|
2588
|
-
if (
|
|
2589
|
-
LogDebug(`[FieldSecurity] Rejected
|
|
2590
|
-
|
|
2591
|
-
throw new FieldSecurityError(hits[0], entity.Name);
|
|
2877
|
+
const violation = find(clause, context);
|
|
2878
|
+
if (violation) {
|
|
2879
|
+
LogDebug(`[FieldSecurity] Rejected SQL on '${entity.Name}' for user ${user.Email}: ${violation.Kind} ${violation.Reference}`);
|
|
2880
|
+
throw FieldSecuritySQLGate.ErrorForViolation(violation, entity.Name);
|
|
2592
2881
|
}
|
|
2593
2882
|
}
|
|
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
2883
|
}
|
|
2600
2884
|
/**
|
|
2601
2885
|
* The single wording for "you cannot use this field," modeled on SQL Server's posture of
|
|
@@ -2615,15 +2899,13 @@ export class ProviderBase {
|
|
|
2615
2899
|
/**
|
|
2616
2900
|
* Strips fields the user cannot read from plain-object result rows.
|
|
2617
2901
|
*
|
|
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.
|
|
2902
|
+
* The backstop behind slot segmentation: cache slots are keyed by the reader's permission
|
|
2903
|
+
* class ({@link ComputeRunViewFLSFingerprintKey}) and filled with only that class's columns,
|
|
2904
|
+
* and this still runs on BOTH cache hits and cache misses, checking every row. A slot can hold
|
|
2905
|
+
* more than its class may read (an `entity_object` fill selects every column, and a slot keyed
|
|
2906
|
+
* just before a permission change keeps its old width), and this is what keeps such a slot
|
|
2907
|
+
* from reaching a reader. It reads live metadata, so a tightened permission applies here from
|
|
2908
|
+
* the next metadata refresh.
|
|
2627
2909
|
*
|
|
2628
2910
|
* NEVER applied to `entity_object` results. Those become `BaseEntity` instances whose
|
|
2629
2911
|
* fields round-trip through `GenerateSaveSQL`, which iterates ALL `IsSPParameter` fields
|
|
@@ -2633,11 +2915,11 @@ export class ProviderBase {
|
|
|
2633
2915
|
* output, which the GraphQL layer enforces separately.
|
|
2634
2916
|
*/
|
|
2635
2917
|
ApplyFieldSecurityProjection(rows, params, contextUser) {
|
|
2636
|
-
if (!rows?.length || !contextUser ||
|
|
2918
|
+
if (!rows?.length || !contextUser || params.ResultType === 'entity_object') {
|
|
2637
2919
|
return rows;
|
|
2638
2920
|
}
|
|
2639
|
-
const entity = this.
|
|
2640
|
-
if (!entity?.
|
|
2921
|
+
const entity = this.ResolveRunViewEntitySync(params);
|
|
2922
|
+
if (!entity?.FieldLevelSecurityApplies) {
|
|
2641
2923
|
return rows;
|
|
2642
2924
|
}
|
|
2643
2925
|
const denied = entity.GetDeniedReadFields(contextUser);
|
|
@@ -2648,32 +2930,78 @@ export class ProviderBase {
|
|
|
2648
2930
|
}
|
|
2649
2931
|
/**
|
|
2650
2932
|
* 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
|
-
*
|
|
2933
|
+
* as policy. Mirrors {@link ProjectRowsToFields}' matching semantics (trim + lowercase).
|
|
2934
|
+
*
|
|
2935
|
+
* Every row is checked on its own: rows in one result need not share a shape (a slot can mix
|
|
2936
|
+
* rows written by different paths), so no row stands in for the others. A row carrying no
|
|
2937
|
+
* denied key is kept as is, and when none do the input array itself is returned.
|
|
2938
|
+
*
|
|
2939
|
+
* The per-key work (trim, lowercase, set lookup) is done once per key SHAPE rather than once
|
|
2940
|
+
* per row: rows with the same keys in the same order share one {@link RowProjectionPlan}. A
|
|
2941
|
+
* result is almost always a single shape, so a restricted user's 10k-row result pays for one
|
|
2942
|
+
* row's worth of key matching plus a copy per row.
|
|
2654
2943
|
*
|
|
2655
2944
|
* @param deniedLowercase field names to omit, already lowercased
|
|
2656
2945
|
*/
|
|
2657
2946
|
static omitFieldsFromRows(rows, deniedLowercase) {
|
|
2658
|
-
const
|
|
2659
|
-
|
|
2660
|
-
|
|
2947
|
+
const plans = { Last: null, BySignature: new Map() };
|
|
2948
|
+
let changed = false;
|
|
2949
|
+
const projected = rows.map((row) => {
|
|
2950
|
+
const narrowed = ProviderBase.omitFieldsFromRow(row, deniedLowercase, plans);
|
|
2951
|
+
changed = changed || narrowed !== row;
|
|
2952
|
+
return narrowed;
|
|
2953
|
+
});
|
|
2954
|
+
return changed ? projected : rows;
|
|
2955
|
+
}
|
|
2956
|
+
/** One row of {@link omitFieldsFromRows}: the row itself when it carries no denied key, else a copy without them. */
|
|
2957
|
+
static omitFieldsFromRow(row, deniedLowercase, plans) {
|
|
2958
|
+
if (!row || typeof row !== 'object') {
|
|
2959
|
+
return row;
|
|
2661
2960
|
}
|
|
2662
|
-
const
|
|
2663
|
-
|
|
2664
|
-
|
|
2961
|
+
const source = row;
|
|
2962
|
+
const keys = Object.keys(source);
|
|
2963
|
+
const plan = ProviderBase.rowProjectionPlanFor(keys, deniedLowercase, plans);
|
|
2964
|
+
if (!plan.Keep) {
|
|
2965
|
+
return row;
|
|
2665
2966
|
}
|
|
2666
|
-
const
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2967
|
+
const filtered = {};
|
|
2968
|
+
for (const key of plan.Keep) {
|
|
2969
|
+
filtered[key] = source[key];
|
|
2970
|
+
}
|
|
2971
|
+
return filtered;
|
|
2972
|
+
}
|
|
2973
|
+
/**
|
|
2974
|
+
* The plan for a row with exactly these keys, built on first sight of the shape. The last plan
|
|
2975
|
+
* used is tried first; otherwise plans are found by the joined key list. Either way the plan's
|
|
2976
|
+
* own key list is compared with the row's before it is used, so two different shapes can
|
|
2977
|
+
* never share a plan.
|
|
2978
|
+
*/
|
|
2979
|
+
static rowProjectionPlanFor(keys, deniedLowercase, plans) {
|
|
2980
|
+
if (plans.Last && ProviderBase.sameKeys(plans.Last.Keys, keys)) {
|
|
2981
|
+
return plans.Last; // consecutive rows nearly always share a shape: no signature to build
|
|
2982
|
+
}
|
|
2983
|
+
const signature = keys.join('\u0000');
|
|
2984
|
+
let plan = plans.BySignature.get(signature);
|
|
2985
|
+
if (!plan || !ProviderBase.sameKeys(plan.Keys, keys)) {
|
|
2986
|
+
const keep = keys.some(k => deniedLowercase.has(k.trim().toLowerCase()))
|
|
2987
|
+
? keys.filter(k => !deniedLowercase.has(k.trim().toLowerCase()))
|
|
2988
|
+
: null;
|
|
2989
|
+
plan = { Keys: keys, Keep: keep };
|
|
2990
|
+
plans.BySignature.set(signature, plan);
|
|
2991
|
+
}
|
|
2992
|
+
plans.Last = plan;
|
|
2993
|
+
return plan;
|
|
2994
|
+
}
|
|
2995
|
+
static sameKeys(a, b) {
|
|
2996
|
+
if (a.length !== b.length) {
|
|
2997
|
+
return false;
|
|
2998
|
+
}
|
|
2999
|
+
for (let i = 0; i < a.length; i++) {
|
|
3000
|
+
if (a[i] !== b[i]) {
|
|
3001
|
+
return false;
|
|
2674
3002
|
}
|
|
2675
|
-
|
|
2676
|
-
|
|
3003
|
+
}
|
|
3004
|
+
return true;
|
|
2677
3005
|
}
|
|
2678
3006
|
/**
|
|
2679
3007
|
* Strips or narrows the `MJ: Record Changes` payload columns a user may not read, using the
|
|
@@ -2685,6 +3013,9 @@ export class ProviderBase {
|
|
|
2685
3013
|
* Everything about the per-row denied set, the payload treatment, and the fail-closed decision
|
|
2686
3014
|
* lives in {@link RecordChangeFieldSecurityProjector}; this is only the RunView wiring.
|
|
2687
3015
|
*
|
|
3016
|
+
* A request whose entity cannot be resolved without I/O (only a `ViewID` or `ViewName`) is
|
|
3017
|
+
* projected too, so a saved view over Record Changes is not a way around it.
|
|
3018
|
+
*
|
|
2688
3019
|
* Runs at all four RunView projection points, matching the main projection: both cache-hit
|
|
2689
3020
|
* paths and both cache-miss paths. The cache-hit path is not optional — it is the exact path
|
|
2690
3021
|
* the original cross-user leak runs through, an unrestricted user warming a full-width slot
|
|
@@ -2707,9 +3038,13 @@ export class ProviderBase {
|
|
|
2707
3038
|
if (!rows?.length || !contextUser || params.ResultType === 'entity_object') {
|
|
2708
3039
|
return rows;
|
|
2709
3040
|
}
|
|
2710
|
-
|
|
3041
|
+
const entity = this.ResolveRunViewEntitySync(params);
|
|
3042
|
+
if (entity && !RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entity)) {
|
|
2711
3043
|
return rows;
|
|
2712
3044
|
}
|
|
3045
|
+
// An entity that does not resolve here (a ViewID/ViewName-only request) may be Record
|
|
3046
|
+
// Changes. The projector decides per row from the row's own EntityID and fails closed on a
|
|
3047
|
+
// row without one; rows that carry no payload column come back untouched.
|
|
2713
3048
|
return new RecordChangeFieldSecurityProjector(this, contextUser).ProjectRows(rows);
|
|
2714
3049
|
}
|
|
2715
3050
|
/**
|
|
@@ -2741,6 +3076,9 @@ export class ProviderBase {
|
|
|
2741
3076
|
// Hooks run after PlatformSQL resolution so they see plain-string filters,
|
|
2742
3077
|
// and before cache fingerprinting so injected filters affect the cache key.
|
|
2743
3078
|
params = await this.RunPreRunViewHooks(params, contextUser);
|
|
3079
|
+
// A stored view's property values are worked out before the cache lookup, so the
|
|
3080
|
+
// fingerprint keys on them. After hooks, so a hook can set ServerProperties.
|
|
3081
|
+
await this.PrepareRunViewProperties(params, contextUser);
|
|
2744
3082
|
// Field-level security: reject predicates referencing unreadable fields. Runs AFTER
|
|
2745
3083
|
// hooks so injected filters are scanned too, and BEFORE the cache lookup so a rejected
|
|
2746
3084
|
// request never consults (or warms) a cache slot.
|
|
@@ -2752,6 +3090,7 @@ export class ProviderBase {
|
|
|
2752
3090
|
EntityName: params.EntityName,
|
|
2753
3091
|
ViewID: params.ViewID,
|
|
2754
3092
|
ViewName: params.ViewName,
|
|
3093
|
+
AlternateBaseView: GetEffectiveAlternateBaseViewName(params),
|
|
2755
3094
|
ExtraFilter: params.ExtraFilter,
|
|
2756
3095
|
OrderBy: params.OrderBy,
|
|
2757
3096
|
ResultType: params.ResultType,
|
|
@@ -2771,39 +3110,29 @@ export class ProviderBase {
|
|
|
2771
3110
|
await this.EntityStatusCheck(params, 'PreRunView', contextUser);
|
|
2772
3111
|
const entityCheckTime = performance.now() - entityCheckStart;
|
|
2773
3112
|
// Save the caller's original Fields request for post-cache filtering.
|
|
2774
|
-
//
|
|
2775
|
-
//
|
|
3113
|
+
// When widening happens (full-row read, a cache-eligible read on the server, explicit
|
|
3114
|
+
// CacheLocal: true, or entity_object), we fetch every field this user's permission class
|
|
3115
|
+
// may read so the cache entry is a superset for that class, and project the result back to
|
|
3116
|
+
// the caller's Fields. A narrow Fields read first checks any already-warm superset slot
|
|
3117
|
+
// (0-SQL hit). Only CacheLocal: false or BypassCache: true keep the caller's narrow SQL
|
|
3118
|
+
// projection on a miss, and then nothing is written to the cache.
|
|
2776
3119
|
const entityLookupStart = performance.now();
|
|
2777
|
-
|
|
3120
|
+
const rawCallerRequestedFields = params.Fields && params.Fields.length > 0
|
|
2778
3121
|
? params.Fields.map(f => f.trim().toLowerCase())
|
|
2779
3122
|
: null; // null = caller wants all fields
|
|
2780
|
-
// Only override Fields to the widened fetch set when caching will actually happen
|
|
2781
|
-
// for this call. For non-cached calls we respect the caller's narrow Fields
|
|
2782
|
-
// end-to-end — there's no cache-coherence concern to preserve. The widened set is
|
|
2783
|
-
// ALL entity fields, for every user: the server cache holds full-width slots and
|
|
2784
|
-
// narrows per request at read time (ApplyFieldSecurityProjection).
|
|
2785
3123
|
const entity = params.EntityName ? this.EntityByName(params.EntityName) : null;
|
|
2786
|
-
const willCache = this.runViewCacheEligible(params);
|
|
2787
|
-
// entity_object ALWAYS widens, cache-eligible or not: a BaseEntity must hydrate from
|
|
2788
|
-
// every column it is entitled to, or it round-trips partial state into a save. This
|
|
2789
|
-
// must happen here rather than only in the server's PreProcessRunView, which is not on
|
|
2790
|
-
// the client's path — a client entity_object request without CacheLocal would otherwise
|
|
2791
|
-
// ship the caller's narrow Fields over the wire and materialize partial entities. What a
|
|
2792
|
-
// field-restricted user RECEIVES is still their allowed set: the server strips denied
|
|
2793
|
-
// columns on the wire, and the missing keys mark those fields not-loaded.
|
|
2794
|
-
const widenForEntityObject = params.ResultType === 'entity_object';
|
|
2795
3124
|
if (entity)
|
|
2796
3125
|
this.ResolveIncludeBinaryFields(params, entity);
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
3126
|
+
const projectedCallerFields = (rawCallerRequestedFields && entity)
|
|
3127
|
+
? ProviderBase.UnionFieldsWithPrimaryKeys(rawCallerRequestedFields, entity)
|
|
3128
|
+
: rawCallerRequestedFields;
|
|
3129
|
+
const canReadFromCache = this.runViewCacheEligible(params);
|
|
3130
|
+
const willPopulateCache = this.shouldWidenAndPopulateCache(params) && (!this.hasNarrowFieldsOverride(params) || Boolean(entity));
|
|
3131
|
+
// entity_object ALWAYS widens, cache-eligible or not: a BaseEntity must hydrate from
|
|
3132
|
+
// every column it is entitled to, or it round-trips partial state into a save.
|
|
3133
|
+
const widenForEntityObject = params.ResultType === 'entity_object';
|
|
2805
3134
|
const entityLookupTime = performance.now() - entityLookupStart;
|
|
2806
|
-
// Check local cache if enabled
|
|
3135
|
+
// Check local cache FIRST if enabled (before any cache-miss Fields widening)
|
|
2807
3136
|
const cacheCheckStart = performance.now();
|
|
2808
3137
|
let cacheStatus = 'disabled';
|
|
2809
3138
|
let cachedResult;
|
|
@@ -2819,29 +3148,26 @@ export class ProviderBase {
|
|
|
2819
3148
|
// fails closed (or resolves ViewEntity synchronously) to plug that hole.
|
|
2820
3149
|
const cacheReadDenied = (!!entity && !!contextUser && !(entity.GetUserPermisions(contextUser)?.CanRead ?? false)) ||
|
|
2821
3150
|
this.cacheDeniedForViewOnlyRequest(params, contextUser);
|
|
2822
|
-
if (
|
|
2823
|
-
|
|
2824
|
-
const flsFieldsKey = this.ComputeRunViewFLSFingerprintKey(params);
|
|
2825
|
-
fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, rlsWhereClause, undefined, flsFieldsKey);
|
|
3151
|
+
if (canReadFromCache && !cacheReadDenied && LocalCacheManager.Instance.IsInitialized) {
|
|
3152
|
+
fingerprint = this.ComputeRunViewCacheFingerprint(params, contextUser);
|
|
2826
3153
|
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
2827
3154
|
if (cached) {
|
|
2828
3155
|
// These rows are the cache's shared, deep-frozen objects — the runtime freeze is
|
|
2829
3156
|
// what stops a consumer from corrupting the cache. Anything that needs to
|
|
2830
3157
|
// transform them must map onto copies.
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
3158
|
+
// Materialize any encrypted fields stored as ciphertext at rest before projection.
|
|
3159
|
+
let results = await this.MaterializeCachedRowsForCaller(cached.results, entity, contextUser, Boolean(params.PreserveRestrictedApiCiphertext));
|
|
3160
|
+
if (projectedCallerFields && params.ResultType !== 'entity_object') {
|
|
3161
|
+
results = ProjectRowsToFields(results, projectedCallerFields);
|
|
2834
3162
|
}
|
|
2835
3163
|
// Field-level security MUST be applied here, not only in PostRunView: this path
|
|
2836
3164
|
// 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.
|
|
3165
|
+
// path a cross-user leak would run through. Slots are keyed by permission class,
|
|
3166
|
+
// but a slot can still be wider than its class (an entity_object fill selects
|
|
3167
|
+
// every column), and the caller's Fields list is unrelated to security.
|
|
2841
3168
|
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.
|
|
3169
|
+
// Record Changes payload security, on the same cache-hit path: its denials belong
|
|
3170
|
+
// to the entity each row is about, which the Record Changes slot key cannot carry.
|
|
2845
3171
|
results = this.ApplyRecordChangeFieldSecurityProjection(results, params, contextUser);
|
|
2846
3172
|
// Reconstruct RunViewResult from cached data
|
|
2847
3173
|
cachedResult = {
|
|
@@ -2857,6 +3183,7 @@ export class ProviderBase {
|
|
|
2857
3183
|
AggregateResults: LocalCacheManager.Instance.ReorderAggregateResultsToRequest(cached.aggregateResults, params.Aggregates)
|
|
2858
3184
|
};
|
|
2859
3185
|
cacheStatus = 'hit';
|
|
3186
|
+
this.logDeprecatedAlternateBaseViewIfAny(params, 'PreRunView');
|
|
2860
3187
|
if (!params.CacheLocal && this.TrustLocalCacheCompletely) {
|
|
2861
3188
|
LogStatusEx({ message: ` ✅ [Server Cache HIT] RunView "${params.EntityName || params.ViewName || 'unknown'}" — ${results.length} rows served from server cache (no DB query)`, verboseOnly: true });
|
|
2862
3189
|
}
|
|
@@ -2865,41 +3192,48 @@ export class ProviderBase {
|
|
|
2865
3192
|
cacheStatus = 'miss';
|
|
2866
3193
|
}
|
|
2867
3194
|
}
|
|
3195
|
+
// On cache MISS (or uninitialized cache), widen Fields ONLY when populating a superset
|
|
3196
|
+
// cache slot or materializing full BaseEntity objects.
|
|
3197
|
+
let callerRequestedFieldsForPost = null;
|
|
3198
|
+
if (cacheStatus !== 'hit' && entity && (willPopulateCache || widenForEntityObject)) {
|
|
3199
|
+
params.Fields = this.ComputeRunViewFetchFields(entity, params);
|
|
3200
|
+
if (willPopulateCache && projectedCallerFields) {
|
|
3201
|
+
callerRequestedFieldsForPost = projectedCallerFields;
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
2868
3204
|
const cacheCheckTime = performance.now() - cacheCheckStart;
|
|
2869
3205
|
const totalPreTime = performance.now() - preViewStart;
|
|
2870
3206
|
if (totalPreTime > 50) {
|
|
2871
|
-
|
|
3207
|
+
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
3208
|
}
|
|
2873
3209
|
return {
|
|
2874
3210
|
telemetryEventId,
|
|
2875
3211
|
cacheStatus,
|
|
2876
3212
|
cachedResult,
|
|
2877
3213
|
fingerprint,
|
|
2878
|
-
// Only non-null when params.Fields was actually widened
|
|
3214
|
+
// Only non-null when params.Fields was actually widened for cache population — tells
|
|
2879
3215
|
// PostRunView to project cache-miss DB results back to the caller's shape
|
|
2880
|
-
callerRequestedFields:
|
|
3216
|
+
callerRequestedFields: callerRequestedFieldsForPost,
|
|
3217
|
+
populateCache: willPopulateCache,
|
|
2881
3218
|
};
|
|
2882
3219
|
}
|
|
2883
3220
|
/**
|
|
2884
3221
|
* Pre-processing hook for RunViews (batch).
|
|
2885
3222
|
* Handles telemetry, validation, and cache lookup for multiple views.
|
|
3223
|
+
* A view the field-security predicate gate refuses is left out of every later stage and
|
|
3224
|
+
* reported in `refusedResults`; the cache status describes the admitted views only.
|
|
2886
3225
|
* @param params - Array of view parameters
|
|
2887
3226
|
* @param contextUser - Optional user context
|
|
2888
|
-
* @returns Pre-processing result with cache status for each view
|
|
3227
|
+
* @returns Pre-processing result with cache status for each admitted view
|
|
2889
3228
|
*/
|
|
2890
3229
|
async PreRunViews(params, contextUser) {
|
|
2891
3230
|
// Resolve any PlatformSQL values to plain strings for the active platform
|
|
2892
3231
|
for (const p of params) {
|
|
2893
3232
|
this.ResolvePlatformSQLInParams(p);
|
|
2894
3233
|
}
|
|
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
|
-
}
|
|
3234
|
+
// Run registered PreRunView hooks on each param in the batch, then the field-security
|
|
3235
|
+
// predicate gate (after the hooks, so injected filters are scanned too).
|
|
3236
|
+
const refusedResults = await this.runBatchHooksAndPredicateGate(params, contextUser);
|
|
2903
3237
|
// Start telemetry tracking for batch operation
|
|
2904
3238
|
const fromEngine = params.some(p => p._fromEngine);
|
|
2905
3239
|
const telemetryEventId = TelemetryManager.Instance.StartEvent('RunView', 'ProviderBase.RunViews', {
|
|
@@ -2921,6 +3255,7 @@ export class ProviderBase {
|
|
|
2921
3255
|
// client), which is exactly what a keyset sweep does on every page.
|
|
2922
3256
|
StartRows: params.map(p => p.StartRow),
|
|
2923
3257
|
AfterKeys: params.map(p => p.AfterKey?.ToConcatenatedString()),
|
|
3258
|
+
AlternateBaseViews: params.map(p => GetEffectiveAlternateBaseViewName(p)),
|
|
2924
3259
|
// Exemption was threaded through the DEPRECATED batch twin but not this one, so
|
|
2925
3260
|
// RunViewParams.Telemetry.Exempt was silently ineffective for every batch RunView —
|
|
2926
3261
|
// and, because RunView() delegates here whenever BypassCache or AfterKey is set,
|
|
@@ -2934,21 +3269,74 @@ export class ProviderBase {
|
|
|
2934
3269
|
ExemptReason: params.find(p => p.Telemetry?.Reason)?.Telemetry?.Reason,
|
|
2935
3270
|
_fromEngine: fromEngine
|
|
2936
3271
|
}, contextUser?.ID);
|
|
3272
|
+
// A refused view never reaches a cache lookup, the smart cache check or the database.
|
|
3273
|
+
const admitted = refusedResults ? params.filter((_, i) => !refusedResults.has(i)) : params;
|
|
3274
|
+
const preResult = await this.lookUpAdmittedViews(admitted, telemetryEventId, contextUser);
|
|
3275
|
+
return refusedResults ? { ...preResult, refusedResults } : preResult;
|
|
3276
|
+
}
|
|
3277
|
+
/**
|
|
3278
|
+
* Runs each view's PreRunView hooks, replacing `params[i]` with the hooked params, then the
|
|
3279
|
+
* field-security predicate gate — the same contract as the single-view path, except that a
|
|
3280
|
+
* refusal fails only its own view: it becomes that view's `Success: false` result, carrying
|
|
3281
|
+
* the refusal's ambiguous message, so the views batched with it still run. A batch is often
|
|
3282
|
+
* several independent reads (a dashboard's panels), and one unreadable column in one of them
|
|
3283
|
+
* must not blank the rest; the refusal is still reported, at the refused view's index. Any
|
|
3284
|
+
* other error from the gate is thrown and fails the batch.
|
|
3285
|
+
* @returns The refused views' results keyed by batch index, or `undefined` when none was refused.
|
|
3286
|
+
*/
|
|
3287
|
+
async runBatchHooksAndPredicateGate(params, contextUser) {
|
|
3288
|
+
let refused;
|
|
3289
|
+
for (let i = 0; i < params.length; i++) {
|
|
3290
|
+
params[i] = await this.RunPreRunViewHooks(params[i], contextUser);
|
|
3291
|
+
await this.PrepareRunViewProperties(params[i], contextUser);
|
|
3292
|
+
try {
|
|
3293
|
+
this.AssertPredicatesRespectFieldSecurity(params[i], contextUser);
|
|
3294
|
+
}
|
|
3295
|
+
catch (error) {
|
|
3296
|
+
refused ??= new Map();
|
|
3297
|
+
refused.set(i, ProviderBase.fieldSecurityRefusalAsResult(error));
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
return refused;
|
|
3301
|
+
}
|
|
3302
|
+
/** `error` as a failed view result when it is a field-security refusal; otherwise rethrows it. */
|
|
3303
|
+
static fieldSecurityRefusalAsResult(error) {
|
|
3304
|
+
if (!(error instanceof Error) || (!(error instanceof FieldSecurityError) && error.name !== FieldSecurityError.ErrorName)) {
|
|
3305
|
+
throw error;
|
|
3306
|
+
}
|
|
3307
|
+
return { Success: false, Results: [], RowCount: 0, TotalRowCount: 0, ExecutionTime: 0, UserViewRunID: '', ErrorMessage: error.message };
|
|
3308
|
+
}
|
|
3309
|
+
/**
|
|
3310
|
+
* The cache stage of PreRunViews for the views the predicate gate admitted. An empty batch
|
|
3311
|
+
* (every view refused) is reported as fully cached, so the pipeline returns without a
|
|
3312
|
+
* database call and still ends the batch's telemetry event.
|
|
3313
|
+
*/
|
|
3314
|
+
async lookUpAdmittedViews(params, telemetryEventId, contextUser) {
|
|
3315
|
+
if (params.length === 0) {
|
|
3316
|
+
return { telemetryEventId, allCached: true, cachedResults: [] };
|
|
3317
|
+
}
|
|
2937
3318
|
// Client-side providers route any CacheLocal params through smart-cache-check:
|
|
2938
3319
|
// a single batched GraphQL call sends fingerprints (hashes + counts) per view,
|
|
2939
3320
|
// the server confirms current vs stale, and only stale views return data.
|
|
2940
3321
|
// Server-side providers trust the cache completely and fall through to the
|
|
2941
3322
|
// traditional flow which returns cached data immediately on hit.
|
|
2942
3323
|
if (!this.TrustLocalCacheCompletely) {
|
|
2943
|
-
|
|
3324
|
+
// The cache-check transport carries dynamic runs only, so a batch holding a run with view
|
|
3325
|
+
// properties takes the ordinary path, where the view and its properties reach the server.
|
|
3326
|
+
const useSmartCacheCheck = params.some(p => p.CacheLocal) && !params.some(ProviderBase.carriesViewProperties);
|
|
2944
3327
|
if (useSmartCacheCheck && LocalCacheManager.Instance.IsInitialized) {
|
|
2945
3328
|
return this.prepareSmartCacheCheckParams(params, telemetryEventId, contextUser);
|
|
2946
3329
|
}
|
|
2947
3330
|
}
|
|
3331
|
+
return this.lookUpBatchCache(params, telemetryEventId, contextUser);
|
|
3332
|
+
}
|
|
3333
|
+
/** The server's trusted-cache lookup for a batch: serves hits, and lists the misses to run. */
|
|
3334
|
+
async lookUpBatchCache(params, telemetryEventId, contextUser) {
|
|
2948
3335
|
// Traditional caching flow
|
|
2949
3336
|
const cacheStatusMap = new Map();
|
|
2950
3337
|
const callerFieldsMap = new Map();
|
|
2951
3338
|
const fingerprintMap = new Map();
|
|
3339
|
+
const populateCacheSet = new Set();
|
|
2952
3340
|
const uncachedParams = [];
|
|
2953
3341
|
const cachedResults = [];
|
|
2954
3342
|
let allCached = true;
|
|
@@ -2956,31 +3344,22 @@ export class ProviderBase {
|
|
|
2956
3344
|
const param = params[i];
|
|
2957
3345
|
// Entity status check
|
|
2958
3346
|
await this.EntityStatusCheck(param, 'PreRunViews', contextUser);
|
|
2959
|
-
// Save caller's original Fields
|
|
2960
|
-
//
|
|
2961
|
-
|
|
3347
|
+
// Save caller's original Fields. Consult warm superset cache slots first (0-SQL hit),
|
|
3348
|
+
// and on cache miss widen Fields ONLY when populating a superset cache slot or
|
|
3349
|
+
// materializing BaseEntity instances.
|
|
3350
|
+
const rawCallerFields = param.Fields && param.Fields.length > 0
|
|
2962
3351
|
? param.Fields.map(f => f.trim().toLowerCase())
|
|
2963
3352
|
: null;
|
|
2964
|
-
// Only override Fields to the widened fetch set when caching will actually happen
|
|
2965
|
-
// for this call. For non-cached calls we respect the caller's narrow Fields
|
|
2966
|
-
// end-to-end — there's no cache-coherence concern to preserve. Widened = ALL
|
|
2967
|
-
// fields, for every user (see PreRunView).
|
|
2968
3353
|
const batchEntity = param.EntityName ? this.EntityByName(param.EntityName) : null;
|
|
2969
|
-
const batchWillCache = this.runViewCacheEligible(param);
|
|
2970
|
-
// Same entity_object-always-widens rule as the single-view path above.
|
|
2971
|
-
const batchWidenForEntityObject = param.ResultType === 'entity_object';
|
|
2972
3354
|
if (batchEntity)
|
|
2973
3355
|
this.ResolveIncludeBinaryFields(param, batchEntity);
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
callerFieldsMap.set(i, callerFields);
|
|
2982
|
-
}
|
|
2983
|
-
}
|
|
3356
|
+
const projectedCallerFields = (rawCallerFields && batchEntity)
|
|
3357
|
+
? ProviderBase.UnionFieldsWithPrimaryKeys(rawCallerFields, batchEntity)
|
|
3358
|
+
: rawCallerFields;
|
|
3359
|
+
const batchCanReadCache = this.runViewCacheEligible(param);
|
|
3360
|
+
const batchWillPopulateCache = this.shouldWidenAndPopulateCache(param) && (!this.hasNarrowFieldsOverride(param) || Boolean(batchEntity));
|
|
3361
|
+
// Same entity_object-always-widens rule as the single-view path above.
|
|
3362
|
+
const batchWidenForEntityObject = param.ResultType === 'entity_object';
|
|
2984
3363
|
// Check local cache if enabled or if server trusts its cache completely
|
|
2985
3364
|
// BypassCache skips cache entirely — used by maintenance actions querying for
|
|
2986
3365
|
// records that were inserted via direct SQL (bypassing BaseEntity.Save())
|
|
@@ -2990,21 +3369,19 @@ export class ProviderBase {
|
|
|
2990
3369
|
// view-only fail-closed gate for ViewID/ViewName-only requests (no resolvable entity).
|
|
2991
3370
|
const batchCacheReadDenied = (!!batchEntity && !!contextUser && !(batchEntity.GetUserPermisions(contextUser)?.CanRead ?? false)) ||
|
|
2992
3371
|
this.cacheDeniedForViewOnlyRequest(param, contextUser);
|
|
2993
|
-
if (
|
|
2994
|
-
const
|
|
2995
|
-
const flsFieldsKey = this.ComputeRunViewFLSFingerprintKey(param);
|
|
2996
|
-
const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString, rlsWhereClause, undefined, flsFieldsKey);
|
|
3372
|
+
if (batchCanReadCache && !batchCacheReadDenied && LocalCacheManager.Instance.IsInitialized) {
|
|
3373
|
+
const fingerprint = this.ComputeRunViewCacheFingerprint(param, contextUser);
|
|
2997
3374
|
fingerprintMap.set(i, fingerprint);
|
|
2998
3375
|
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
2999
3376
|
if (cached) {
|
|
3000
3377
|
// Shared, deep-frozen cache rows — same contract as the single-view hit path.
|
|
3001
|
-
let results = cached.results;
|
|
3002
|
-
if (
|
|
3003
|
-
results = ProjectRowsToFields(results,
|
|
3378
|
+
let results = await this.MaterializeCachedRowsForCaller(cached.results, batchEntity, contextUser, Boolean(param.PreserveRestrictedApiCiphertext));
|
|
3379
|
+
if (projectedCallerFields && param.ResultType !== 'entity_object') {
|
|
3380
|
+
results = ProjectRowsToFields(results, projectedCallerFields);
|
|
3004
3381
|
}
|
|
3005
3382
|
// Field-level security on the batch cache-hit path — same reasoning as the
|
|
3006
3383
|
// single-view path in PreRunView: this result is returned without
|
|
3007
|
-
// PostRunViews projecting it, and
|
|
3384
|
+
// PostRunViews projecting it, and a slot can be wider than its class.
|
|
3008
3385
|
results = this.ApplyFieldSecurityProjection(results, param, contextUser);
|
|
3009
3386
|
results = this.ApplyRecordChangeFieldSecurityProjection(results, param, contextUser);
|
|
3010
3387
|
const cachedViewResult = {
|
|
@@ -3021,6 +3398,7 @@ export class ProviderBase {
|
|
|
3021
3398
|
};
|
|
3022
3399
|
// if needed this will transform each result into an entity object
|
|
3023
3400
|
await this.TransformSimpleObjectToEntityObject(param, cachedViewResult, contextUser);
|
|
3401
|
+
this.logDeprecatedAlternateBaseViewIfAny(param, 'PreRunViews');
|
|
3024
3402
|
if (!param.CacheLocal && this.TrustLocalCacheCompletely) {
|
|
3025
3403
|
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
3404
|
}
|
|
@@ -3035,6 +3413,15 @@ export class ProviderBase {
|
|
|
3035
3413
|
else {
|
|
3036
3414
|
cacheStatusMap.set(i, { status: 'disabled' });
|
|
3037
3415
|
}
|
|
3416
|
+
if (batchWillPopulateCache) {
|
|
3417
|
+
populateCacheSet.add(i);
|
|
3418
|
+
}
|
|
3419
|
+
if (batchEntity && (batchWillPopulateCache || batchWidenForEntityObject)) {
|
|
3420
|
+
param.Fields = this.ComputeRunViewFetchFields(batchEntity, param);
|
|
3421
|
+
if (batchWillPopulateCache && projectedCallerFields) {
|
|
3422
|
+
callerFieldsMap.set(i, projectedCallerFields);
|
|
3423
|
+
}
|
|
3424
|
+
}
|
|
3038
3425
|
allCached = false;
|
|
3039
3426
|
uncachedParams.push(param);
|
|
3040
3427
|
cachedResults.push(null); // Placeholder for uncached
|
|
@@ -3049,7 +3436,8 @@ export class ProviderBase {
|
|
|
3049
3436
|
uncachedParams: allCached ? undefined : uncachedParams,
|
|
3050
3437
|
cacheStatusMap,
|
|
3051
3438
|
callerFieldsMap: callerFieldsMap.size > 0 ? callerFieldsMap : undefined,
|
|
3052
|
-
fingerprintMap: fingerprintMap.size > 0 ? fingerprintMap : undefined
|
|
3439
|
+
fingerprintMap: fingerprintMap.size > 0 ? fingerprintMap : undefined,
|
|
3440
|
+
populateCacheSet,
|
|
3053
3441
|
};
|
|
3054
3442
|
}
|
|
3055
3443
|
/**
|
|
@@ -3105,6 +3493,8 @@ export class ProviderBase {
|
|
|
3105
3493
|
smartCacheCheckParams[paramIndex].cacheStatus = {
|
|
3106
3494
|
maxUpdatedAt: cached.maxUpdatedAt,
|
|
3107
3495
|
rowCount: cached.rowCount,
|
|
3496
|
+
// Lets the server answer "stale" when this user's columns changed but no row did.
|
|
3497
|
+
fieldSecurityKey: this.ComputeFieldSecurityCacheStatusKey(params[paramIndex], contextUser),
|
|
3108
3498
|
};
|
|
3109
3499
|
}
|
|
3110
3500
|
}
|
|
@@ -3427,31 +3817,53 @@ export class ProviderBase {
|
|
|
3427
3817
|
* @param preResult - The pre-processing result
|
|
3428
3818
|
* @param contextUser - Optional user context
|
|
3429
3819
|
*/
|
|
3820
|
+
/**
|
|
3821
|
+
* Hook allowing server database providers to substitute pre-decryption (ciphertext-preserving)
|
|
3822
|
+
* rows before storing view results in {@link LocalCacheManager} / Redis, ensuring decrypted
|
|
3823
|
+
* plaintext secrets are never persisted in local or shared caches.
|
|
3824
|
+
*
|
|
3825
|
+
* Client-side providers inherit this default identity implementation.
|
|
3826
|
+
*/
|
|
3827
|
+
PrepareRowsForCacheStorage(rows, _entityInfo) {
|
|
3828
|
+
return rows;
|
|
3829
|
+
}
|
|
3830
|
+
/**
|
|
3831
|
+
* Hook allowing server database providers to decrypt ciphertext-at-rest rows retrieved from
|
|
3832
|
+
* {@link LocalCacheManager} for in-process callers (or selectively preserve restricted
|
|
3833
|
+
* ciphertext when `preserveRestrictedApiCiphertext` is `true`).
|
|
3834
|
+
*
|
|
3835
|
+
* Client-side providers inherit this default identity implementation.
|
|
3836
|
+
*/
|
|
3837
|
+
async MaterializeCachedRowsForCaller(rows, _entityInfo, _contextUser, _preserveRestrictedApiCiphertext) {
|
|
3838
|
+
return rows;
|
|
3839
|
+
}
|
|
3430
3840
|
async PostRunView(result, params, preResult, contextUser) {
|
|
3431
3841
|
// Store in local cache BEFORE entity transformation — the cache needs
|
|
3432
3842
|
// plain JSON-serializable objects. BaseEntity objects contain RxJS Subjects
|
|
3433
3843
|
// with circular subscriber references that break JSON.stringify.
|
|
3434
3844
|
// On cache read, TransformSimpleObjectToEntityObject is called to restore
|
|
3435
3845
|
// entity objects when ResultType === 'entity_object'.
|
|
3436
|
-
//
|
|
3437
|
-
//
|
|
3438
|
-
|
|
3439
|
-
|
|
3846
|
+
// preResult.populateCache records PreRunView's widening decision so narrow Fields
|
|
3847
|
+
// queries (without CacheLocal: true) never poison the Fields-agnostic superset slot.
|
|
3848
|
+
const resolvedEntity = this.resolveEntityForRunViewParams(params);
|
|
3849
|
+
const shouldPopulateSlot = preResult.populateCache ?? this.shouldWidenAndPopulateCache(params);
|
|
3850
|
+
if (shouldPopulateSlot && result.Success && preResult.fingerprint && LocalCacheManager.Instance.IsInitialized) {
|
|
3440
3851
|
const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
|
|
3441
|
-
await LocalCacheManager.Instance.SetRunViewResult(preResult.fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
|
|
3852
|
+
await LocalCacheManager.Instance.SetRunViewResult(preResult.fingerprint, params, this.PrepareRowsForCacheStorage(result.Results, resolvedEntity), maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
|
|
3442
3853
|
}
|
|
3443
3854
|
else if (this.shouldAutoCache(params, result, contextUser)) {
|
|
3444
3855
|
// Server-side auto-cache: small, unfiltered, unsorted results are
|
|
3445
3856
|
// automatically cached even without explicit CacheLocal. These are
|
|
3446
3857
|
// safe for in-place upsert on entity changes (no filter to evaluate).
|
|
3447
|
-
const fingerprint = preResult.fingerprint ||
|
|
3858
|
+
const fingerprint = preResult.fingerprint || this.ComputeRunViewCacheFingerprint(params, contextUser);
|
|
3448
3859
|
const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
|
|
3449
|
-
await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
|
|
3860
|
+
await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, this.PrepareRowsForCacheStorage(result.Results, resolvedEntity), maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
|
|
3450
3861
|
LogStatusEx({ message: ` 📦 [Auto-Cache] RunView "${params.EntityName || params.ViewName || 'unknown'}" — ${result.Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
|
|
3451
3862
|
}
|
|
3452
3863
|
// Project cache-miss DB results back down to the caller's originally requested
|
|
3453
|
-
// fields. PreRunView widened params.Fields to ALL entity fields
|
|
3454
|
-
//
|
|
3864
|
+
// fields. PreRunView widened params.Fields to ALL entity fields (less the columns this
|
|
3865
|
+
// user's permission class may not read) so the cache entry written above is a superset
|
|
3866
|
+
// for that class — but the caller must receive
|
|
3455
3867
|
// the same shape on a miss as they do on a hit (which projects from cache).
|
|
3456
3868
|
// Must run AFTER the cache writes (cache keeps the superset) and only for
|
|
3457
3869
|
// plain-object results (entity objects need all fields).
|
|
@@ -3459,10 +3871,9 @@ export class ProviderBase {
|
|
|
3459
3871
|
result.Results = ProjectRowsToFields(result.Results, preResult.callerRequestedFields);
|
|
3460
3872
|
}
|
|
3461
3873
|
// 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.
|
|
3874
|
+
// which returns before this hook ever runs). Runs AFTER the cache write above, as on the
|
|
3875
|
+
// hit path: the slot keeps what the database returned for this permission class, and
|
|
3876
|
+
// every reader is projected against live metadata.
|
|
3466
3877
|
if (result.Success) {
|
|
3467
3878
|
result.Results = this.ApplyFieldSecurityProjection(result.Results, params, contextUser);
|
|
3468
3879
|
result.Results = this.ApplyRecordChangeFieldSecurityProjection(result.Results, params, contextUser);
|
|
@@ -3521,18 +3932,21 @@ export class ProviderBase {
|
|
|
3521
3932
|
// per item. Compute lazily only for indexes PreRunViews skipped (cache
|
|
3522
3933
|
// was disabled for them but auto-cache/OnDataChanged may still need it).
|
|
3523
3934
|
const fingerprint = preResult.fingerprintMap?.get(i)
|
|
3524
|
-
??
|
|
3525
|
-
// CRITICAL: must
|
|
3935
|
+
?? this.ComputeRunViewCacheFingerprint(params[i], contextUser);
|
|
3936
|
+
// CRITICAL: must reflect the SAME widening predicate PreRunViews used to decide
|
|
3526
3937
|
// whether to widen Fields. Writing a non-widened (narrow or keyset-paged)
|
|
3527
|
-
// result here poisons the Fields-agnostic superset slot
|
|
3528
|
-
|
|
3529
|
-
|
|
3938
|
+
// result here poisons the Fields-agnostic superset slot.
|
|
3939
|
+
const itemEntity = this.resolveEntityForRunViewParams(params[i]);
|
|
3940
|
+
const shouldPopulateBatchSlot = preResult.populateCacheSet
|
|
3941
|
+
? preResult.populateCacheSet.has(i)
|
|
3942
|
+
: this.shouldWidenAndPopulateCache(params[i]);
|
|
3943
|
+
if (shouldPopulateBatchSlot && results[i].Success && LocalCacheManager.Instance.IsInitialized) {
|
|
3530
3944
|
const maxUpdatedAt = this.extractMaxUpdatedAt(results[i].Results);
|
|
3531
|
-
cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount, this));
|
|
3945
|
+
cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], this.PrepareRowsForCacheStorage(results[i].Results, itemEntity), maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount, this));
|
|
3532
3946
|
}
|
|
3533
3947
|
else if (this.shouldAutoCache(params[i], results[i], contextUser)) {
|
|
3534
3948
|
const maxUpdatedAt = this.extractMaxUpdatedAt(results[i].Results);
|
|
3535
|
-
cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount, this));
|
|
3949
|
+
cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], this.PrepareRowsForCacheStorage(results[i].Results, itemEntity), maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount, this));
|
|
3536
3950
|
LogStatusEx({ message: ` 📦 [Auto-Cache] RunViews "${params[i].EntityName || params[i].ViewName || 'unknown'}" — ${results[i].Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
|
|
3537
3951
|
}
|
|
3538
3952
|
// Register OnDataChanged callback if provided
|
|
@@ -3543,7 +3957,8 @@ export class ProviderBase {
|
|
|
3543
3957
|
await Promise.all(cachePromises);
|
|
3544
3958
|
// Project cache-miss DB results back down to each caller's originally
|
|
3545
3959
|
// requested fields. PreRunViews widened those params' Fields to ALL entity
|
|
3546
|
-
// fields
|
|
3960
|
+
// fields (less what each user's permission class may not read) so the cache entries
|
|
3961
|
+
// written above are supersets for that class — but
|
|
3547
3962
|
// callers must receive the same shape on a miss as on a hit (which projects
|
|
3548
3963
|
// from cache). Skip hits (already projected) and entity_object results
|
|
3549
3964
|
// (need all fields).
|
|
@@ -3602,6 +4017,18 @@ export class ProviderBase {
|
|
|
3602
4017
|
});
|
|
3603
4018
|
}
|
|
3604
4019
|
}
|
|
4020
|
+
/**
|
|
4021
|
+
* Works out a stored view's property values ({@link RunViewParams.Properties},
|
|
4022
|
+
* {@link RunViewParams.ServerProperties}, and each property's default) before the run's cache
|
|
4023
|
+
* lookup, so the result-cache fingerprint keys on the values the run will actually use. Called
|
|
4024
|
+
* on the run's own params copy, after the PreRunView hooks. Must never throw: a run whose
|
|
4025
|
+
* properties would be refused is left for the provider's run path to refuse with its message.
|
|
4026
|
+
*
|
|
4027
|
+
* Does nothing here. A database provider, which resolves token defaults on the server, overrides it.
|
|
4028
|
+
*/
|
|
4029
|
+
async PrepareRunViewProperties(_params, _contextUser) {
|
|
4030
|
+
// The client sends Properties as they are; the server resolves them.
|
|
4031
|
+
}
|
|
3605
4032
|
/**
|
|
3606
4033
|
* Runs all registered PreRunView hooks against a single RunViewParams,
|
|
3607
4034
|
* returning the (possibly mutated) params.
|
|
@@ -3790,6 +4217,8 @@ export class ProviderBase {
|
|
|
3790
4217
|
// main-path variants of those bugs.
|
|
3791
4218
|
if (!this.runViewCacheEligible(params))
|
|
3792
4219
|
return false;
|
|
4220
|
+
if (this.hasNarrowFieldsOverride(params))
|
|
4221
|
+
return false;
|
|
3793
4222
|
if (!LocalCacheManager.Instance.IsInitialized)
|
|
3794
4223
|
return false;
|
|
3795
4224
|
if (!result.Success)
|
|
@@ -3800,11 +4229,16 @@ export class ProviderBase {
|
|
|
3800
4229
|
return false;
|
|
3801
4230
|
// Only auto-cache unfiltered, unsorted queries — these are safe for
|
|
3802
4231
|
// in-place upsert because LocalCacheManager doesn't need to evaluate
|
|
3803
|
-
// SQL predicates or sort expressions.
|
|
4232
|
+
// SQL predicates or sort expressions. AlternateBaseView queries apply
|
|
4233
|
+
// a canonical SQL WHERE/JOIN and must not be auto-cached as unfiltered.
|
|
3804
4234
|
const filter = typeof params.ExtraFilter === 'string' ? params.ExtraFilter.trim() : '';
|
|
3805
4235
|
const orderBy = typeof params.OrderBy === 'string' ? params.OrderBy.trim() : '';
|
|
3806
4236
|
if (filter.length > 0 || orderBy.length > 0)
|
|
3807
4237
|
return false;
|
|
4238
|
+
if (GetEffectiveAlternateBaseViewName(params))
|
|
4239
|
+
return false;
|
|
4240
|
+
if (Boolean(params.ViewID || params.ViewName || params.ViewEntity))
|
|
4241
|
+
return false;
|
|
3808
4242
|
return true;
|
|
3809
4243
|
}
|
|
3810
4244
|
extractMaxUpdatedAt(results) {
|
|
@@ -4100,6 +4534,9 @@ export class ProviderBase {
|
|
|
4100
4534
|
* Resolves the {@link EntityInfo} normalization should read field types from, using only
|
|
4101
4535
|
* synchronously available information on the params.
|
|
4102
4536
|
*/
|
|
4537
|
+
resolveEntityForRunViewParams(param) {
|
|
4538
|
+
return this.resolveEntityForNormalization(param);
|
|
4539
|
+
}
|
|
4103
4540
|
resolveEntityForNormalization(param) {
|
|
4104
4541
|
if (param.EntityName) {
|
|
4105
4542
|
return this.EntityByName(param.EntityName);
|
|
@@ -4237,7 +4674,7 @@ export class ProviderBase {
|
|
|
4237
4674
|
if (!this.TrustLocalCacheCompletely && !hardRefresh && !this._localMetadata?.AllEntities?.length) {
|
|
4238
4675
|
await Promise.all([localCacheInitPromise, this.LoadLocalMetadataFromStorage()]);
|
|
4239
4676
|
if (this._localMetadata?.AllEntities?.length) {
|
|
4240
|
-
|
|
4677
|
+
LogStatus(`⚡ [Metadata Cache] Loaded ${this._localMetadata.AllEntities.length} entities from local cache — pre-validating before engine startup`);
|
|
4241
4678
|
// Do NOT kick off background validation here — it writes to IndexedDB
|
|
4242
4679
|
// which contends with engine RunView cache reads during StartupManager.
|
|
4243
4680
|
// Instead, the caller (setupGraphQLClient) should call
|
|
@@ -4325,7 +4762,7 @@ export class ProviderBase {
|
|
|
4325
4762
|
const start = new Date().getTime();
|
|
4326
4763
|
const res = await this.GetAllMetadata(providerToUse, effectiveHardRefresh);
|
|
4327
4764
|
const end = new Date().getTime();
|
|
4328
|
-
|
|
4765
|
+
LogStatus(`GetAllMetadata() took ${end - start} ms`);
|
|
4329
4766
|
if (res) {
|
|
4330
4767
|
await this.adoptServerMetadata(res);
|
|
4331
4768
|
this._configLoadedFromServer = true;
|
|
@@ -4353,7 +4790,8 @@ export class ProviderBase {
|
|
|
4353
4790
|
}
|
|
4354
4791
|
/**
|
|
4355
4792
|
* Adopts a metadata snapshot just loaded from the server: swaps it in, records that the
|
|
4356
|
-
* local copy now matches the server's timestamps
|
|
4793
|
+
* local copy now matches the server's timestamps (verified per item against the received
|
|
4794
|
+
* dataset row counts), and persists it to local storage.
|
|
4357
4795
|
*
|
|
4358
4796
|
* The swap goes through UpdateLocalMetadata — a single property assignment, atomic in
|
|
4359
4797
|
* JavaScript, so readers see the old snapshot until the new one is complete — which keeps
|
|
@@ -4361,9 +4799,116 @@ export class ProviderBase {
|
|
|
4361
4799
|
*/
|
|
4362
4800
|
async adoptServerMetadata(res) {
|
|
4363
4801
|
this.UpdateLocalMetadata(res);
|
|
4364
|
-
this._latestLocalMetadataTimestamps = this.
|
|
4802
|
+
this._latestLocalMetadataTimestamps = this.reconcileAdoptedMetadataTimestamps(res);
|
|
4365
4803
|
await this.SaveLocalMetadataToStorage();
|
|
4366
4804
|
}
|
|
4805
|
+
/**
|
|
4806
|
+
* Verifies that each item in `_latestRemoteMetadataTimestamps` matches the row count actually
|
|
4807
|
+
* received in the loaded `MJ_Metadata` dataset (or `AllMetadata` collections) before advancing
|
|
4808
|
+
* `_latestLocalMetadataTimestamps`. If any item has a mismatched row count (e.g., because a
|
|
4809
|
+
* server cache slot returned stale rows while the SQL status probe reported newer counts),
|
|
4810
|
+
* logs a warning and preserves the unadvanced local timestamp for that item so the next
|
|
4811
|
+
* staleness check (`LocalMetadataObsolete`) detects the discrepancy and self-heals.
|
|
4812
|
+
*/
|
|
4813
|
+
reconcileAdoptedMetadataTimestamps(res) {
|
|
4814
|
+
const remote = this._latestRemoteMetadataTimestamps;
|
|
4815
|
+
if (!remote || remote.length === 0) {
|
|
4816
|
+
return remote;
|
|
4817
|
+
}
|
|
4818
|
+
const norm = (v) => (v ?? '').trim().toLowerCase();
|
|
4819
|
+
const rollup = norm(ProviderBase._allMetadataTypeName);
|
|
4820
|
+
const prevLocal = this._latestLocalMetadataTimestamps ?? [];
|
|
4821
|
+
const datasetItems = this._metadataDatasetItems;
|
|
4822
|
+
const resRecord = res;
|
|
4823
|
+
let hasMismatch = false;
|
|
4824
|
+
const reconciled = [];
|
|
4825
|
+
const datasetItemsByEntityID = datasetItems && datasetItems.length > 0
|
|
4826
|
+
? IndexByUUID(datasetItems.filter(item => Boolean(item.EntityID)), item => item.EntityID)
|
|
4827
|
+
: null;
|
|
4828
|
+
const datasetItemsByNameOrCode = new Map();
|
|
4829
|
+
if (datasetItems && datasetItems.length > 0) {
|
|
4830
|
+
for (const item of datasetItems) {
|
|
4831
|
+
if (item.EntityName)
|
|
4832
|
+
datasetItemsByNameOrCode.set(norm(item.EntityName), item);
|
|
4833
|
+
if (item.Code)
|
|
4834
|
+
datasetItemsByNameOrCode.set(norm(item.Code), item);
|
|
4835
|
+
}
|
|
4836
|
+
}
|
|
4837
|
+
for (const remoteEntry of remote) {
|
|
4838
|
+
if (norm(remoteEntry.Type) === rollup) {
|
|
4839
|
+
continue;
|
|
4840
|
+
}
|
|
4841
|
+
let receivedRowCount;
|
|
4842
|
+
let receivedUpdateDate;
|
|
4843
|
+
if (datasetItems && datasetItems.length > 0) {
|
|
4844
|
+
const match = (remoteEntry.ID ? datasetItemsByEntityID?.Get(remoteEntry.ID) : undefined)
|
|
4845
|
+
?? datasetItemsByNameOrCode.get(norm(remoteEntry.Type));
|
|
4846
|
+
if (match && match.RowCount !== undefined) {
|
|
4847
|
+
receivedRowCount = match.RowCount;
|
|
4848
|
+
receivedUpdateDate = match.LatestUpdateDate;
|
|
4849
|
+
}
|
|
4850
|
+
}
|
|
4851
|
+
else if (resRecord) {
|
|
4852
|
+
// Fallback when adoptServerMetadata is invoked without a preceding dataset load
|
|
4853
|
+
const arrayMatch = AllMetadataArrays.find(m => {
|
|
4854
|
+
const code = m.key.substring(3);
|
|
4855
|
+
return norm(code) === norm(remoteEntry.Type) ||
|
|
4856
|
+
norm(m.key) === norm(remoteEntry.Type) ||
|
|
4857
|
+
norm(`MJ: ${code}`) === norm(remoteEntry.Type);
|
|
4858
|
+
});
|
|
4859
|
+
if (arrayMatch) {
|
|
4860
|
+
const arr = resRecord[arrayMatch.key];
|
|
4861
|
+
if (Array.isArray(arr)) {
|
|
4862
|
+
receivedRowCount = arr.length;
|
|
4863
|
+
}
|
|
4864
|
+
}
|
|
4865
|
+
}
|
|
4866
|
+
if (receivedRowCount !== undefined &&
|
|
4867
|
+
remoteEntry.RowCount !== undefined &&
|
|
4868
|
+
receivedRowCount !== remoteEntry.RowCount) {
|
|
4869
|
+
hasMismatch = true;
|
|
4870
|
+
LogWarning(`[Metadata Cache] Row count mismatch when adopting server metadata for "${remoteEntry.Type}": ` +
|
|
4871
|
+
`received ${receivedRowCount} rows but remote status reported ${remoteEntry.RowCount} rows. ` +
|
|
4872
|
+
`Not advancing local timestamp for "${remoteEntry.Type}" so the next check self-heals.`, 'Metadata');
|
|
4873
|
+
const priorEntry = prevLocal.find(m => norm(m.Type) === norm(remoteEntry.Type));
|
|
4874
|
+
if (priorEntry) {
|
|
4875
|
+
reconciled.push({
|
|
4876
|
+
...priorEntry,
|
|
4877
|
+
RowCount: receivedRowCount
|
|
4878
|
+
});
|
|
4879
|
+
}
|
|
4880
|
+
else {
|
|
4881
|
+
const fallbackDate = receivedUpdateDate &&
|
|
4882
|
+
remoteEntry.UpdatedAt &&
|
|
4883
|
+
new Date(receivedUpdateDate).getTime() !== new Date(remoteEntry.UpdatedAt).getTime()
|
|
4884
|
+
? receivedUpdateDate
|
|
4885
|
+
: new Date(0);
|
|
4886
|
+
reconciled.push({
|
|
4887
|
+
...remoteEntry,
|
|
4888
|
+
UpdatedAt: fallbackDate,
|
|
4889
|
+
RowCount: receivedRowCount
|
|
4890
|
+
});
|
|
4891
|
+
}
|
|
4892
|
+
}
|
|
4893
|
+
else {
|
|
4894
|
+
reconciled.push(remoteEntry);
|
|
4895
|
+
}
|
|
4896
|
+
}
|
|
4897
|
+
if (!hasMismatch) {
|
|
4898
|
+
return remote;
|
|
4899
|
+
}
|
|
4900
|
+
const remoteRollup = remote.find(r => norm(r.Type) === rollup);
|
|
4901
|
+
if (remoteRollup) {
|
|
4902
|
+
const priorRollup = prevLocal.find(m => norm(m.Type) === rollup);
|
|
4903
|
+
const totalReconciledRows = reconciled.reduce((sum, item) => sum + (item.RowCount ?? 0), 0);
|
|
4904
|
+
reconciled.push({
|
|
4905
|
+
...remoteRollup,
|
|
4906
|
+
UpdatedAt: priorRollup?.UpdatedAt ?? new Date(0),
|
|
4907
|
+
RowCount: totalReconciledRows
|
|
4908
|
+
});
|
|
4909
|
+
}
|
|
4910
|
+
return reconciled;
|
|
4911
|
+
}
|
|
4367
4912
|
/**
|
|
4368
4913
|
* Decides whether the current staleness can be served by a scoped reload of the saved-query
|
|
4369
4914
|
* family. Compares local and remote timestamps item by item (the same test as
|
|
@@ -4439,14 +4984,14 @@ export class ProviderBase {
|
|
|
4439
4984
|
const start = Date.now();
|
|
4440
4985
|
const d = await this.GetDatasetByName(ProviderBase._mjMetadataDatasetName, skipOthers, this.CurrentUser, providerToUse, true);
|
|
4441
4986
|
if (!d?.Success) {
|
|
4442
|
-
LogStatusEx({ message: `[Metadata] Scoped reload of ${codes.join(', ')} failed (${d?.Status ?? 'no result'}); running the full reload`, verboseOnly:
|
|
4987
|
+
LogStatusEx({ message: `[Metadata] Scoped reload of ${codes.join(', ')} failed (${d?.Status ?? 'no result'}); running the full reload`, verboseOnly: true });
|
|
4443
4988
|
return false;
|
|
4444
4989
|
}
|
|
4445
4990
|
const simple = {};
|
|
4446
4991
|
for (const code of codes) {
|
|
4447
4992
|
const item = d.Results?.find(r => r.Code === code);
|
|
4448
4993
|
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:
|
|
4994
|
+
LogStatusEx({ message: `[Metadata] Scoped reload: item ${code} ${item ? `failed (${item.Status ?? 'no rows'})` : 'missing'}; running the full reload`, verboseOnly: true });
|
|
4450
4995
|
return false;
|
|
4451
4996
|
}
|
|
4452
4997
|
simple[code] = item.Results;
|
|
@@ -4463,6 +5008,19 @@ export class ProviderBase {
|
|
|
4463
5008
|
for (const m of AllMetadataArrays) {
|
|
4464
5009
|
target[m.key] = keep.has(m.key.substring(3)) ? fresh[m.key] : kept[m.key];
|
|
4465
5010
|
}
|
|
5011
|
+
this._metadataDatasetItems = items.map(existing => {
|
|
5012
|
+
if (!keep.has(existing.Code)) {
|
|
5013
|
+
return existing;
|
|
5014
|
+
}
|
|
5015
|
+
const updated = d.Results?.find(r => r.Code === existing.Code);
|
|
5016
|
+
return updated && Array.isArray(updated.Results)
|
|
5017
|
+
? {
|
|
5018
|
+
...existing,
|
|
5019
|
+
RowCount: updated.Results.length,
|
|
5020
|
+
LatestUpdateDate: updated.LatestUpdateDate ?? existing.LatestUpdateDate
|
|
5021
|
+
}
|
|
5022
|
+
: existing;
|
|
5023
|
+
});
|
|
4466
5024
|
// Adopted like every other server snapshot. _configLoadedFromServer is NOT set: this read
|
|
4467
5025
|
// fetched no current user (the existing one is carried over), so the next pre-validation
|
|
4468
5026
|
// must still run its check.
|
|
@@ -4485,17 +5043,17 @@ export class ProviderBase {
|
|
|
4485
5043
|
try {
|
|
4486
5044
|
const needsRefresh = await this.CheckToSeeIfRefreshNeeded(providerToUse);
|
|
4487
5045
|
if (needsRefresh) {
|
|
4488
|
-
LogStatusEx({ message: `⚡ [Metadata Cache] Background check: metadata is stale — refreshing...`, verboseOnly:
|
|
5046
|
+
LogStatusEx({ message: `⚡ [Metadata Cache] Background check: metadata is stale — refreshing...`, verboseOnly: true });
|
|
4489
5047
|
const start = Date.now();
|
|
4490
5048
|
const res = await this.GetAllMetadata(providerToUse, false);
|
|
4491
5049
|
const elapsed = Date.now() - start;
|
|
4492
5050
|
if (res) {
|
|
4493
5051
|
await this.adoptServerMetadata(res);
|
|
4494
|
-
LogStatusEx({ message: `⚡ [Metadata Cache] Background refresh complete (${elapsed}ms) — metadata updated in place`, verboseOnly:
|
|
5052
|
+
LogStatusEx({ message: `⚡ [Metadata Cache] Background refresh complete (${elapsed}ms) — metadata updated in place`, verboseOnly: true });
|
|
4495
5053
|
}
|
|
4496
5054
|
}
|
|
4497
5055
|
else {
|
|
4498
|
-
LogStatusEx({ message: `⚡ [Metadata Cache] Background check: metadata is current — no refresh needed`, verboseOnly:
|
|
5056
|
+
LogStatusEx({ message: `⚡ [Metadata Cache] Background check: metadata is current — no refresh needed`, verboseOnly: true });
|
|
4499
5057
|
await this.RefreshCurrentUser();
|
|
4500
5058
|
}
|
|
4501
5059
|
}
|
|
@@ -4545,17 +5103,17 @@ export class ProviderBase {
|
|
|
4545
5103
|
this.RefreshCurrentUser(),
|
|
4546
5104
|
]);
|
|
4547
5105
|
if (needsRefresh) {
|
|
4548
|
-
|
|
5106
|
+
LogStatus(`⚡ [Metadata Cache] Pre-validation: metadata is stale — refreshing before engine startup`);
|
|
4549
5107
|
const start = Date.now();
|
|
4550
5108
|
const res = await this.GetAllMetadata(providerToUse, false);
|
|
4551
5109
|
const elapsed = Date.now() - start;
|
|
4552
5110
|
if (res) {
|
|
4553
5111
|
await this.adoptServerMetadata(res);
|
|
4554
|
-
|
|
5112
|
+
LogStatus(`⚡ [Metadata Cache] Pre-validation refresh complete (${elapsed}ms)`);
|
|
4555
5113
|
}
|
|
4556
5114
|
}
|
|
4557
5115
|
else {
|
|
4558
|
-
|
|
5116
|
+
LogStatus(`⚡ [Metadata Cache] Pre-validation: metadata is current`);
|
|
4559
5117
|
}
|
|
4560
5118
|
}
|
|
4561
5119
|
catch (e) {
|
|
@@ -4622,18 +5180,26 @@ export class ProviderBase {
|
|
|
4622
5180
|
* customized adoption through CloneAllMetadata. Remove the CloneAllMetadata
|
|
4623
5181
|
* override to activate a CreateSharedMetadataShell override.
|
|
4624
5182
|
*/
|
|
5183
|
+
/**
|
|
5184
|
+
* Builds this instance's AllMetadata as a zero-copy prototype-delegated shell over
|
|
5185
|
+
* another provider's already-loaded metadata (`Object.create(shared)`), keeping
|
|
5186
|
+
* `CurrentUser` as an own property on the shell instance.
|
|
5187
|
+
*
|
|
5188
|
+
* Why zero-copy prototype delegation is safe: the metadata graph (`AllMetadata` and its
|
|
5189
|
+
* arrays) is treated as immutable after `Config`. Refreshes swap the entire `AllMetadata`
|
|
5190
|
+
* reference via `UpdateLocalMetadata` rather than mutating the previous `AllMetadata`
|
|
5191
|
+
* instance in place, so in-flight shells continue reading a consistent snapshot while
|
|
5192
|
+
* avoiding 19 array shallow-copies on every per-request provider creation.
|
|
5193
|
+
*/
|
|
4625
5194
|
CreateSharedMetadataShell(shared) {
|
|
4626
|
-
const shell =
|
|
4627
|
-
for (const m of AllMetadataArrays) {
|
|
4628
|
-
shell[m.key] = [...(shared[m.key] ?? [])];
|
|
4629
|
-
}
|
|
5195
|
+
const shell = Object.create(shared);
|
|
4630
5196
|
shell.CurrentUser = this.CurrentUser; // same semantics the deep clone had — per-instance, not shared
|
|
4631
5197
|
return shell;
|
|
4632
5198
|
}
|
|
4633
5199
|
/**
|
|
4634
5200
|
* Adopts the global provider's metadata for this instance without reloading it
|
|
4635
5201
|
* from the server: shares the (immutable post-Config) metadata arrays by
|
|
4636
|
-
* reference via {@link CreateSharedMetadataShell} and builds this instance's
|
|
5202
|
+
* reference via {@link CreateSharedMetadataShell} and shares or builds this instance's
|
|
4637
5203
|
* entity lookup maps.
|
|
4638
5204
|
*/
|
|
4639
5205
|
CopyMetadataFromGlobalProvider() {
|
|
@@ -4642,7 +5208,8 @@ export class ProviderBase {
|
|
|
4642
5208
|
// registered-but-not-yet-configured global would otherwise donate an empty graph
|
|
4643
5209
|
// and this Config would "succeed" with zero entities. Falling through to the
|
|
4644
5210
|
// normal load path is the correct behavior in that case.
|
|
4645
|
-
const
|
|
5211
|
+
const globalProvider = Metadata.Provider !== this ? Metadata.Provider : undefined; // global-provider-ok: this method adopts metadata FROM the global provider on bootstrap
|
|
5212
|
+
const globalMetadata = globalProvider?.AllMetadata;
|
|
4646
5213
|
if ((globalMetadata?.AllEntities?.length ?? 0) > 0) {
|
|
4647
5214
|
// Back-compat: before #3083 this path called the overridable CloneAllMetadata,
|
|
4648
5215
|
// so external subclasses could customize adoption (e.g. tenant-filtered deep
|
|
@@ -4652,7 +5219,17 @@ export class ProviderBase {
|
|
|
4652
5219
|
const adopted = subclassOverridesClone
|
|
4653
5220
|
? this.CloneAllMetadata(globalMetadata)
|
|
4654
5221
|
: this.CreateSharedMetadataShell(globalMetadata);
|
|
4655
|
-
|
|
5222
|
+
if (!subclassOverridesClone &&
|
|
5223
|
+
globalProvider instanceof ProviderBase &&
|
|
5224
|
+
adopted.AllEntities === globalMetadata.AllEntities &&
|
|
5225
|
+
globalProvider._entityMapByName.size > 0) {
|
|
5226
|
+
this._localMetadata = adopted;
|
|
5227
|
+
this._entityMapByName = globalProvider._entityMapByName;
|
|
5228
|
+
this._entityMapByID = globalProvider._entityMapByID;
|
|
5229
|
+
}
|
|
5230
|
+
else {
|
|
5231
|
+
this.UpdateLocalMetadata(adopted);
|
|
5232
|
+
}
|
|
4656
5233
|
return true;
|
|
4657
5234
|
}
|
|
4658
5235
|
return false;
|
|
@@ -4662,6 +5239,13 @@ export class ProviderBase {
|
|
|
4662
5239
|
return false; // if we fail to copy the metadata, we will return false
|
|
4663
5240
|
}
|
|
4664
5241
|
}
|
|
5242
|
+
/**
|
|
5243
|
+
* Re-adopts the immutable read-only metadata catalog shell and lookup maps from
|
|
5244
|
+
* `Metadata.Provider` after the global provider completes a metadata refresh.
|
|
5245
|
+
*/
|
|
5246
|
+
SyncMetadataFromGlobalProvider() {
|
|
5247
|
+
return this.CopyMetadataFromGlobalProvider();
|
|
5248
|
+
}
|
|
4665
5249
|
/**
|
|
4666
5250
|
* Builds dataset filters based on the provider configuration.
|
|
4667
5251
|
* Ensures MJ Core schema is always included and never excluded.
|
|
@@ -4734,7 +5318,7 @@ export class ProviderBase {
|
|
|
4734
5318
|
simpleMetadata[r.Code] = r.Results;
|
|
4735
5319
|
}
|
|
4736
5320
|
// 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);
|
|
5321
|
+
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
5322
|
// Post Process the Applications, because we want to handle the sub-objects properly.
|
|
4739
5323
|
simpleMetadata.AllApplications = simpleMetadata.Applications.map((a) => {
|
|
4740
5324
|
a.ApplicationEntities = simpleMetadata.ApplicationEntities.filter((ae) => UUIDsEqual(ae.ApplicationID, a.ID));
|
|
@@ -4821,7 +5405,7 @@ export class ProviderBase {
|
|
|
4821
5405
|
}
|
|
4822
5406
|
return map;
|
|
4823
5407
|
}
|
|
4824
|
-
PostProcessEntityMetadata(entities, fields, fieldValues, permissions, relationships, settings, organicKeys, organicKeyRelatedEntities, fieldPermissions) {
|
|
5408
|
+
PostProcessEntityMetadata(entities, fields, fieldValues, permissions, relationships, settings, organicKeys, organicKeyRelatedEntities, fieldPermissions, alternateBaseViews) {
|
|
4825
5409
|
const result = [];
|
|
4826
5410
|
// Sort entities alphabetically by name to ensure deterministic ordering
|
|
4827
5411
|
// This prevents non-deterministic output in CodeGen and other metadata consumers
|
|
@@ -4859,6 +5443,9 @@ export class ProviderBase {
|
|
|
4859
5443
|
const activeOrganicKeysByEntityId = organicKeys
|
|
4860
5444
|
? this.groupByNormalizedUUID(organicKeys.filter(ok => ok.Status === 'Active'), ok => ok.EntityID)
|
|
4861
5445
|
: new Map();
|
|
5446
|
+
const alternateBaseViewsByEntityId = alternateBaseViews && alternateBaseViews.length > 0
|
|
5447
|
+
? this.groupByNormalizedUUID(alternateBaseViews, abv => abv.EntityID)
|
|
5448
|
+
: new Map();
|
|
4862
5449
|
for (const e of sortedEntities) {
|
|
4863
5450
|
const entityIdKey = NormalizeUUID(e.ID);
|
|
4864
5451
|
const entityFields = fieldsByEntityId.get(entityIdKey) || [];
|
|
@@ -4870,6 +5457,9 @@ export class ProviderBase {
|
|
|
4870
5457
|
if (organicKeys) {
|
|
4871
5458
|
e.EntityOrganicKeys = activeOrganicKeysByEntityId.get(entityIdKey) || [];
|
|
4872
5459
|
}
|
|
5460
|
+
if (alternateBaseViews) {
|
|
5461
|
+
e.EntityAlternateBaseViews = alternateBaseViewsByEntityId.get(entityIdKey) || [];
|
|
5462
|
+
}
|
|
4873
5463
|
result.push(new EntityInfo(e, this));
|
|
4874
5464
|
}
|
|
4875
5465
|
// Check for schema name collision: if both 'MJ' and 'MJCustom' schemas exist,
|
|
@@ -5085,6 +5675,27 @@ export class ProviderBase {
|
|
|
5085
5675
|
* RefreshIfNeeded load outside Config's reload single-flight). For the same reason it gets
|
|
5086
5676
|
* `false`, not the owner's rejection, when the owner's check throws.
|
|
5087
5677
|
* @returns True if refresh is needed, false otherwise
|
|
5678
|
+
*
|
|
5679
|
+
* @remarks
|
|
5680
|
+
* This is a **freshness question, not a load**, so it never throws on an unreachable
|
|
5681
|
+
* server. {@link ReadRemoteMetadataTimestamps} reports whether the server was reached at
|
|
5682
|
+
* all, and when it was NOT the honest answer here depends on whether we hold anything to
|
|
5683
|
+
* serve:
|
|
5684
|
+
*
|
|
5685
|
+
* - **We hold local metadata** → answer `false` ("do not tear this down"). The cached
|
|
5686
|
+
* snapshot may be a few seconds stale; a rejected status query is no reason to throw
|
|
5687
|
+
* it away, and every caller of this method treats `false` as "carry on with what you
|
|
5688
|
+
* have".
|
|
5689
|
+
* - **We hold nothing** → answer `true`, so the caller attempts a real load against the
|
|
5690
|
+
* actual metadata fetch. That load logs its own failure and leaves the graph empty;
|
|
5691
|
+
* `SetupGraphQLClient` turns an empty graph into an error carrying it, while a caller
|
|
5692
|
+
* that only checks {@link Config}'s return value sees `true`.
|
|
5693
|
+
*
|
|
5694
|
+
* Before this, an unreachable status query propagated an exception out of
|
|
5695
|
+
* {@link Config} and the Explorer's single boot catch turned it into a non-retryable
|
|
5696
|
+
* "System Error" — the whole app dead over a freshness check it did not need an answer
|
|
5697
|
+
* to. The two warm callers ({@link backgroundValidateAndRefresh},
|
|
5698
|
+
* {@link preValidateAndRefresh}) already caught it; {@link Config} did not.
|
|
5088
5699
|
*/
|
|
5089
5700
|
async CheckToSeeIfRefreshNeeded(providerToUse, bypassMinCheckInterval) {
|
|
5090
5701
|
if (!this.AllowRefresh)
|
|
@@ -5123,10 +5734,29 @@ export class ProviderBase {
|
|
|
5123
5734
|
*/
|
|
5124
5735
|
async runRefreshCheck(providerToUse) {
|
|
5125
5736
|
const gotTimestamps = await this.RefreshRemoteMetadataTimestamps(providerToUse);
|
|
5737
|
+
// Read the reachability flag in the SAME synchronous turn the await above resumed in —
|
|
5738
|
+
// before the next await can let a concurrent check overwrite it.
|
|
5739
|
+
const serverReached = this._lastRemoteTimestampReadReached;
|
|
5126
5740
|
if (gotTimestamps) {
|
|
5127
5741
|
this._lastRefreshCheckAt = Date.now();
|
|
5128
5742
|
}
|
|
5129
5743
|
await this.syncLocalMetadataFromStorage();
|
|
5744
|
+
if (!gotTimestamps && !serverReached) {
|
|
5745
|
+
// Could not ask the server. `LocalMetadataObsolete()` would answer `true` here
|
|
5746
|
+
// purely because the remote timestamp list is empty — a "yes, reload everything"
|
|
5747
|
+
// derived from the absence of an answer rather than from any comparison.
|
|
5748
|
+
const haveLocal = (this._localMetadata?.AllEntities?.length ?? 0) > 0;
|
|
5749
|
+
if (haveLocal) {
|
|
5750
|
+
LogError(`[RefreshCheck] Could not read metadata timestamps from the server — serving the ` +
|
|
5751
|
+
`cached metadata snapshot (${this._localMetadata.AllEntities.length} entities) and ` +
|
|
5752
|
+
`deferring the staleness check to the next call.`);
|
|
5753
|
+
return false;
|
|
5754
|
+
}
|
|
5755
|
+
LogError(`[RefreshCheck] Could not read metadata timestamps from the server and there is no ` +
|
|
5756
|
+
`cached snapshot to fall back on — reporting a refresh as needed so the caller loads ` +
|
|
5757
|
+
`metadata; that load reports its own failure.`);
|
|
5758
|
+
return true;
|
|
5759
|
+
}
|
|
5130
5760
|
return this.LocalMetadataObsolete();
|
|
5131
5761
|
}
|
|
5132
5762
|
/**
|
|
@@ -5318,8 +5948,30 @@ export class ProviderBase {
|
|
|
5318
5948
|
if (cachedDataset && cachedDateStr) {
|
|
5319
5949
|
// We have a candidate cache entry — confirm freshness with the server.
|
|
5320
5950
|
const localDate = new Date(cachedDateStr);
|
|
5321
|
-
|
|
5322
|
-
|
|
5951
|
+
let status;
|
|
5952
|
+
try {
|
|
5953
|
+
status = await this.GetDatasetStatusByName(datasetName, itemFilters);
|
|
5954
|
+
}
|
|
5955
|
+
catch (e) {
|
|
5956
|
+
// Only the TRANSPORT failing earns the fallback. A server that answered "no"
|
|
5957
|
+
// (an identity without the scope, a validation rejection) gave a definite
|
|
5958
|
+
// answer that waiting will not change, and the cache would hand the caller
|
|
5959
|
+
// exactly what the server just declined to — so that propagates as it always did.
|
|
5960
|
+
if (!IsTransportError(e)) {
|
|
5961
|
+
throw e;
|
|
5962
|
+
}
|
|
5963
|
+
// We are HOLDING a complete cached dataset and only asked whether it is still
|
|
5964
|
+
// current. A failed freshness question is not grounds for throwing the answer
|
|
5965
|
+
// away and propagating: the line below re-fetches the whole dataset from the
|
|
5966
|
+
// same unreachable server, so the alternative to serving this cache is an
|
|
5967
|
+
// exception, not fresher data. Serve it and say so.
|
|
5968
|
+
LogError(`GetAndCacheDatasetByName('${datasetName}'): the freshness check failed — ` +
|
|
5969
|
+
`${e instanceof Error ? e.message : String(e)}. Serving the cached dataset ` +
|
|
5970
|
+
`(cached ${cachedDateStr}), which may be stale.`);
|
|
5971
|
+
return cachedDataset;
|
|
5972
|
+
}
|
|
5973
|
+
const serverDate = this.validDatasetDate(status?.LatestUpdateDate);
|
|
5974
|
+
if (status?.Success && serverDate && localDate.getTime() >= serverDate.getTime()) {
|
|
5323
5975
|
// Timestamps suggest cache is fresh; verify per-entity row counts to
|
|
5324
5976
|
// catch deleted rows (timestamp comparison alone misses pure deletes).
|
|
5325
5977
|
// Shared with IsDatasetCacheUpToDate so the two cannot drift again.
|
|
@@ -5328,9 +5980,11 @@ export class ProviderBase {
|
|
|
5328
5980
|
}
|
|
5329
5981
|
}
|
|
5330
5982
|
}
|
|
5331
|
-
// Cold path or stale: fetch from server and cache.
|
|
5983
|
+
// Cold path or stale: fetch from server and cache only when the load succeeded.
|
|
5332
5984
|
const dataset = await this.GetDatasetByName(datasetName, itemFilters, contextUser, providerToUse);
|
|
5333
|
-
|
|
5985
|
+
if (dataset?.Success) {
|
|
5986
|
+
await this.CacheDataset(datasetName, itemFilters, dataset);
|
|
5987
|
+
}
|
|
5334
5988
|
return dataset;
|
|
5335
5989
|
}
|
|
5336
5990
|
/**
|
|
@@ -5361,8 +6015,9 @@ export class ProviderBase {
|
|
|
5361
6015
|
if (localDate) {
|
|
5362
6016
|
// we have a local cached timestamp, so compare it to the server timestamp
|
|
5363
6017
|
const status = await this.GetDatasetStatusByName(datasetName, itemFilters);
|
|
5364
|
-
|
|
5365
|
-
|
|
6018
|
+
const serverDate = this.validDatasetDate(status?.LatestUpdateDate);
|
|
6019
|
+
if (status?.Success && serverDate) {
|
|
6020
|
+
const serverTimestamp = serverDate.getTime();
|
|
5366
6021
|
if (localDate.getTime() >= serverTimestamp) {
|
|
5367
6022
|
// this situation means our local cache timestamp is >= the server timestamp, so we're most likely up to date
|
|
5368
6023
|
// in this situation, the last thing we check is for each entity, if the rowcount is the same as the server, if it is, we're good
|
|
@@ -5385,6 +6040,17 @@ export class ProviderBase {
|
|
|
5385
6040
|
return false;
|
|
5386
6041
|
}
|
|
5387
6042
|
}
|
|
6043
|
+
/**
|
|
6044
|
+
* Normalizes a dataset `LatestUpdateDate` to a valid `Date`, or `null` when absent or invalid
|
|
6045
|
+
* (for example, when a dataset or dataset-status call fails and returns `LatestUpdateDate: null`).
|
|
6046
|
+
*/
|
|
6047
|
+
validDatasetDate(value) {
|
|
6048
|
+
if (value == null) {
|
|
6049
|
+
return null;
|
|
6050
|
+
}
|
|
6051
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
6052
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
6053
|
+
}
|
|
5388
6054
|
/**
|
|
5389
6055
|
* Compares the per-entity row counts of a cached dataset against the server's status.
|
|
5390
6056
|
*
|
|
@@ -5443,6 +6109,12 @@ export class ProviderBase {
|
|
|
5443
6109
|
* @param dataset
|
|
5444
6110
|
*/
|
|
5445
6111
|
async CacheDataset(datasetName, itemFilters, dataset) {
|
|
6112
|
+
const latestUpdateDate = this.validDatasetDate(dataset?.LatestUpdateDate);
|
|
6113
|
+
if (!dataset?.Success || !latestUpdateDate) {
|
|
6114
|
+
LogError(`CacheDataset("${datasetName}"): refusing to cache failed or undated dataset result ` +
|
|
6115
|
+
`(Success=${dataset?.Success}, Status=${dataset?.Status ?? 'unknown'})`);
|
|
6116
|
+
return;
|
|
6117
|
+
}
|
|
5446
6118
|
const ls = this.LocalStorageProvider;
|
|
5447
6119
|
if (ls) {
|
|
5448
6120
|
const key = this.GetDatasetCacheKey(datasetName, itemFilters);
|
|
@@ -5450,7 +6122,7 @@ export class ProviderBase {
|
|
|
5450
6122
|
await ls.SetItem(key, dataset, ProviderBase.DatasetCacheCategory, { TTLSeconds: ProviderBase.DatasetCacheTTLSeconds });
|
|
5451
6123
|
// Date is stored as ISO string for forward-compatibility across providers
|
|
5452
6124
|
// (Redis can't natively round-trip Date; localStorage requires string).
|
|
5453
|
-
await ls.SetItem(key + '_date',
|
|
6125
|
+
await ls.SetItem(key + '_date', latestUpdateDate.toISOString(), ProviderBase.DatasetCacheCategory, { TTLSeconds: ProviderBase.DatasetDateCacheTTLSeconds });
|
|
5454
6126
|
}
|
|
5455
6127
|
}
|
|
5456
6128
|
/**
|
|
@@ -5557,11 +6229,70 @@ export class ProviderBase {
|
|
|
5557
6229
|
}
|
|
5558
6230
|
/**
|
|
5559
6231
|
* Retrieves the latest metadata update timestamps from the server.
|
|
5560
|
-
* @returns Array of metadata update information
|
|
6232
|
+
* @returns Array of metadata update information, or `undefined` when the server could not
|
|
6233
|
+
* be asked (transport failure, timeout, gateway error) or answered unsuccessfully.
|
|
6234
|
+
*
|
|
6235
|
+
* @remarks
|
|
6236
|
+
* This is the ONE network call behind every staleness check, and it is a question about
|
|
6237
|
+
* freshness — never the metadata itself. A rejected status query therefore returns
|
|
6238
|
+
* `undefined` (an absent answer) rather than propagating: its callers already treat a
|
|
6239
|
+
* falsy result as "could not tell", while an exception out of here travelled unguarded
|
|
6240
|
+
* through {@link RefreshRemoteMetadataTimestamps} → {@link CheckToSeeIfRefreshNeeded} →
|
|
6241
|
+
* {@link Config} and killed the caller's whole bootstrap.
|
|
6242
|
+
*
|
|
6243
|
+
* The failure is logged at error level, not swallowed quietly: "we are running on a
|
|
6244
|
+
* cached snapshot because the server did not answer" is something an operator needs to
|
|
6245
|
+
* see in the console.
|
|
5561
6246
|
*/
|
|
5562
6247
|
async GetLatestMetadataUpdates(providerToUse) {
|
|
5563
|
-
|
|
5564
|
-
|
|
6248
|
+
return (await this.ReadRemoteMetadataTimestamps(providerToUse)).Timestamps;
|
|
6249
|
+
}
|
|
6250
|
+
/**
|
|
6251
|
+
* The metadata-status read, with **why** it failed preserved.
|
|
6252
|
+
*
|
|
6253
|
+
* Two failures look the same to {@link GetLatestMetadataUpdates} — both give `undefined` —
|
|
6254
|
+
* but they call for opposite responses, so callers that can act on the difference read it
|
|
6255
|
+
* here instead:
|
|
6256
|
+
*
|
|
6257
|
+
* - `Reached: false` — the request never got an answer (transport failure, timeout, 504).
|
|
6258
|
+
* Transient. Nothing can be concluded about freshness, and a full metadata load would hit
|
|
6259
|
+
* the same wall, so the right move is to keep serving what we have.
|
|
6260
|
+
* - `Reached: true` with no `Timestamps` — the server answered `Success: false`. A
|
|
6261
|
+
* *permanent* condition (the dataset is missing or misconfigured), not a network blip.
|
|
6262
|
+
* Degrading here would serve a stale cache forever; the historical behaviour of treating
|
|
6263
|
+
* it as "obsolete" and attempting the real load is correct, because `GetDatasetByName`
|
|
6264
|
+
* may well still work.
|
|
6265
|
+
* - A rejection that is NOT a transport failure — an authorization denial ("this API key
|
|
6266
|
+
* requires the dataset:read scope"), a validation rejection, a programming error — is
|
|
6267
|
+
* the server (or our own code) answering, not the wire failing. It propagates exactly
|
|
6268
|
+
* as it did before this method existed. {@link IsTransportError} draws the line. The
|
|
6269
|
+
* integration tier's FLS fixture depends on this: it builds a reader whose scope rule
|
|
6270
|
+
* has not propagated yet and retries `Config()` until the denial stops — a denial
|
|
6271
|
+
* swallowed into `Reached: false` left that reader with an empty snapshot for good.
|
|
6272
|
+
*
|
|
6273
|
+
* Conflating these is how a broken status endpoint — or a refused identity — would become
|
|
6274
|
+
* permanently invisible.
|
|
6275
|
+
*/
|
|
6276
|
+
async ReadRemoteMetadataTimestamps(providerToUse) {
|
|
6277
|
+
let d;
|
|
6278
|
+
try {
|
|
6279
|
+
// No schema filters for metadata — see comment in GetAllMetadata
|
|
6280
|
+
d = await this.GetDatasetStatusByName(ProviderBase._mjMetadataDatasetName, null, this.CurrentUser, providerToUse);
|
|
6281
|
+
}
|
|
6282
|
+
catch (e) {
|
|
6283
|
+
if (!IsTransportError(e)) {
|
|
6284
|
+
// The server answered — with a refusal. Not ours to soften.
|
|
6285
|
+
throw e;
|
|
6286
|
+
}
|
|
6287
|
+
LogError(`ReadRemoteMetadataTimestamps: the metadata status query failed — ` +
|
|
6288
|
+
`${e instanceof Error ? e.message : String(e)}. Reporting the server as unreachable; ` +
|
|
6289
|
+
`callers fall back to the cached metadata snapshot.`);
|
|
6290
|
+
return { Reached: false, Timestamps: undefined };
|
|
6291
|
+
}
|
|
6292
|
+
return { Reached: true, Timestamps: this.mapDatasetStatusToTimestamps(d) };
|
|
6293
|
+
}
|
|
6294
|
+
/** Shape a dataset-status answer into the timestamp list, or `undefined` if it failed. */
|
|
6295
|
+
mapDatasetStatusToTimestamps(d) {
|
|
5565
6296
|
if (d && d.Success) {
|
|
5566
6297
|
const ret = d.EntityUpdateDates.map(e => {
|
|
5567
6298
|
return {
|
|
@@ -5580,6 +6311,7 @@ export class ProviderBase {
|
|
|
5580
6311
|
});
|
|
5581
6312
|
return ret;
|
|
5582
6313
|
}
|
|
6314
|
+
return undefined;
|
|
5583
6315
|
}
|
|
5584
6316
|
/**
|
|
5585
6317
|
* Refreshes the remote metadata timestamps from the server.
|
|
@@ -5587,9 +6319,14 @@ export class ProviderBase {
|
|
|
5587
6319
|
* @returns True if timestamps were successfully refreshed
|
|
5588
6320
|
*/
|
|
5589
6321
|
async RefreshRemoteMetadataTimestamps(providerToUse) {
|
|
5590
|
-
const
|
|
5591
|
-
|
|
5592
|
-
|
|
6322
|
+
const read = await this.ReadRemoteMetadataTimestamps(providerToUse);
|
|
6323
|
+
// Recorded rather than returned: this method's `boolean` contract is "did the timestamps
|
|
6324
|
+
// refresh", and widening it would change a public signature that callers outside this
|
|
6325
|
+
// class already consume. {@link CheckToSeeIfRefreshNeeded} reads the flag in the same
|
|
6326
|
+
// synchronous turn this call resolves in, so no interleaving call can overwrite it first.
|
|
6327
|
+
this._lastRemoteTimestampReadReached = read.Reached;
|
|
6328
|
+
if (read.Timestamps) {
|
|
6329
|
+
this._latestRemoteMetadataTimestamps = read.Timestamps;
|
|
5593
6330
|
return true;
|
|
5594
6331
|
}
|
|
5595
6332
|
else
|
|
@@ -5655,18 +6392,22 @@ export class ProviderBase {
|
|
|
5655
6392
|
}
|
|
5656
6393
|
/**
|
|
5657
6394
|
* Rebuilds the O(1) entity lookup Maps from the current AllEntities array.
|
|
6395
|
+
* Allocates fresh Map instances so any per-request provider sharing a previous
|
|
6396
|
+
* snapshot's maps is unaffected by a subsequent refresh.
|
|
5658
6397
|
* Called automatically from UpdateLocalMetadata().
|
|
5659
6398
|
*/
|
|
5660
6399
|
RebuildEntityMaps() {
|
|
5661
6400
|
const entities = this._localMetadata?.AllEntities;
|
|
5662
|
-
|
|
5663
|
-
|
|
6401
|
+
const byName = new Map();
|
|
6402
|
+
const byID = new Map();
|
|
5664
6403
|
if (entities) {
|
|
5665
6404
|
for (const e of entities) {
|
|
5666
|
-
|
|
5667
|
-
|
|
6405
|
+
byName.set(e.Name.trim().toLowerCase(), e);
|
|
6406
|
+
byID.set(NormalizeUUID(e.ID), e);
|
|
5668
6407
|
}
|
|
5669
6408
|
}
|
|
6409
|
+
this._entityMapByName = byName;
|
|
6410
|
+
this._entityMapByID = byID;
|
|
5670
6411
|
}
|
|
5671
6412
|
/**
|
|
5672
6413
|
* Returns the filesystem provider for the current environment.
|