@memberjunction/core 6.2.0-edge.6 → 6.2.0-edge.8
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/EntityFieldRules.d.ts.map +1 -1
- package/dist/generic/EntityFieldRules.js +1 -0
- package/dist/generic/EntityFieldRules.js.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +32 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
- package/dist/generic/baseEngine.d.ts +165 -12
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +622 -98
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEngineRegistry.d.ts +10 -2
- package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
- package/dist/generic/baseEngineRegistry.js +33 -5
- package/dist/generic/baseEngineRegistry.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +121 -6
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +1144 -64
- 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/databaseProviderBase.d.ts +37 -0
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +262 -46
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +25 -1
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +70 -12
- package/dist/generic/entityInfo.js.map +1 -1
- 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 +11 -2
- package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -1
- package/dist/generic/fieldSecuritySQLGate.js +6 -2
- package/dist/generic/fieldSecuritySQLGate.js.map +1 -1
- package/dist/generic/interfaces.d.ts +50 -1
- 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 +69 -5
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +253 -18
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/logging.d.ts +105 -38
- package/dist/generic/logging.d.ts.map +1 -1
- package/dist/generic/logging.js +196 -5
- package/dist/generic/logging.js.map +1 -1
- package/dist/generic/providerBase.d.ts +198 -3
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +721 -223
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/relatedRecordCollection.d.ts +5 -0
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
- package/dist/generic/relatedRecordCollection.js +11 -2
- 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/runQuerySQLFilterImplementations.d.ts +33 -6
- package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.js +81 -27
- package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
- package/dist/generic/sharedLoad.d.ts +42 -0
- package/dist/generic/sharedLoad.d.ts.map +1 -0
- package/dist/generic/sharedLoad.js +74 -0
- package/dist/generic/sharedLoad.js.map +1 -0
- package/dist/generic/telemetryManager.d.ts +31 -0
- package/dist/generic/telemetryManager.d.ts.map +1 -1
- package/dist/generic/telemetryManager.js +85 -9
- package/dist/generic/telemetryManager.js.map +1 -1
- package/dist/generic/transactionGroup.d.ts +19 -0
- package/dist/generic/transactionGroup.d.ts.map +1 -1
- package/dist/generic/transactionGroup.js +55 -7
- package/dist/generic/transactionGroup.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 +13 -1
- package/dist/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +53 -4
- package/dist/generic/util.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +23 -10
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +5 -1
- package/dist/views/runView.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,4 +1,5 @@
|
|
|
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
4
|
import { GetEffectiveAlternateBaseViewName, IsExplicitBaseViewOverride } from "./alternateBaseViews.js";
|
|
4
5
|
import { ProviderType, AllMetadata } from "./interfaces.js";
|
|
@@ -237,6 +238,21 @@ export class ProviderBase {
|
|
|
237
238
|
*/
|
|
238
239
|
this._inflightViews = new Map();
|
|
239
240
|
this._lingerInvalidationWired = false;
|
|
241
|
+
/**
|
|
242
|
+
* Per-entity monotonic mutation counter incremented whenever an entity receives a
|
|
243
|
+
* BaseEntity save/delete/remote-invalidate or an explicit {@link InvalidateRunViewDedupCache}
|
|
244
|
+
* call. Used by dataset reads (`GetDatasetByName`) to detect that an entity was mutated while
|
|
245
|
+
* SQL execution or post-processing was in flight and avoid writing stale pre-mutation rows
|
|
246
|
+
* back into `LocalCacheManager`.
|
|
247
|
+
*/
|
|
248
|
+
this._entityMutationVersions = new Map();
|
|
249
|
+
/**
|
|
250
|
+
* Monotonic counter incremented whenever any metadata-member entity is mutated against this
|
|
251
|
+
* provider's backend. Checked by `GetAllMetadata()` before caching `MJ_Metadata` into
|
|
252
|
+
* `DatasetCache` so an in-flight background reload does not overwrite `DatasetCache` with a
|
|
253
|
+
* pre-mutation snapshot after a save/delete has already landed.
|
|
254
|
+
*/
|
|
255
|
+
this._metadataMemberMutationSeq = 0;
|
|
240
256
|
/**
|
|
241
257
|
* Lowercased names of the entities whose rows COMPOSE this provider's metadata — the
|
|
242
258
|
* MJ_Metadata dataset's item entities, recorded each time the dataset is loaded (and restored
|
|
@@ -272,6 +288,14 @@ export class ProviderBase {
|
|
|
272
288
|
this._clientFingerprintMemo = new WeakMap();
|
|
273
289
|
this._requestDeniedReadFields = new WeakMap();
|
|
274
290
|
this._cachedVisibleExplorerNavigationItems = null;
|
|
291
|
+
/**
|
|
292
|
+
* Whether the most recent {@link RefreshRemoteMetadataTimestamps} actually REACHED the
|
|
293
|
+
* server, regardless of whether it came back with usable timestamps.
|
|
294
|
+
*
|
|
295
|
+
* Starts `true` so that nothing reads "unreachable" before a read has been attempted.
|
|
296
|
+
* See {@link ReadRemoteMetadataTimestamps} for why the distinction exists.
|
|
297
|
+
*/
|
|
298
|
+
this._lastRemoteTimestampReadReached = true;
|
|
275
299
|
/** Guard for {@link logMetadataSnapshotDisabledOnce}. */
|
|
276
300
|
this._metadataSnapshotSkipLogged = false;
|
|
277
301
|
}
|
|
@@ -418,6 +442,23 @@ export class ProviderBase {
|
|
|
418
442
|
if (!entityName) {
|
|
419
443
|
return;
|
|
420
444
|
}
|
|
445
|
+
const cascadeRelatedEntities = [];
|
|
446
|
+
const isaRelatedEntities = [];
|
|
447
|
+
const entityInfo = entityEvent.baseEntity?.EntityInfo;
|
|
448
|
+
if (entityEvent.type === 'delete' && entityInfo?.CascadeDeletes && Array.isArray(entityInfo.RelatedEntities)) {
|
|
449
|
+
for (const rel of entityInfo.RelatedEntities) {
|
|
450
|
+
const relName = rel?.RelatedEntity?.trim().toLowerCase();
|
|
451
|
+
const relType = (rel?.Type ?? 'One To Many').trim().toUpperCase();
|
|
452
|
+
if (relName && relName !== entityName && relType === 'ONE TO MANY' && !cascadeRelatedEntities.includes(relName)) {
|
|
453
|
+
cascadeRelatedEntities.push(relName);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if ((entityEvent.type === 'save' || entityEvent.type === 'delete') && entityInfo) {
|
|
458
|
+
for (const isaLower of ProviderBase.collectISAHierarchyEntityNamesLower(entityInfo, entityName)) {
|
|
459
|
+
isaRelatedEntities.push(isaLower);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
421
462
|
for (const ref of ProviderBase._invalidationTargets) {
|
|
422
463
|
const instance = ref.deref();
|
|
423
464
|
if (!instance) {
|
|
@@ -428,9 +469,50 @@ export class ProviderBase {
|
|
|
428
469
|
}
|
|
429
470
|
instance.invalidateInflightViewsForEntity(entityName);
|
|
430
471
|
instance.handleMetadataMemberEntityEvent(entityName, entityEvent);
|
|
472
|
+
for (const relLower of cascadeRelatedEntities) {
|
|
473
|
+
instance.invalidateInflightViewsForEntity(relLower);
|
|
474
|
+
instance.handleMetadataMemberEntityEvent(relLower, entityEvent);
|
|
475
|
+
}
|
|
476
|
+
for (const isaLower of isaRelatedEntities) {
|
|
477
|
+
instance.invalidateInflightViewsForEntity(isaLower);
|
|
478
|
+
instance.handleMetadataMemberEntityEvent(isaLower, entityEvent);
|
|
479
|
+
}
|
|
431
480
|
}
|
|
432
481
|
});
|
|
433
482
|
}
|
|
483
|
+
/**
|
|
484
|
+
* Collects all lowercased entity names in the same IS-A hierarchy (`ParentEntityInfo` ancestors
|
|
485
|
+
* and `ChildEntities` descendants) as `entityInfo`, excluding `excludeLower`.
|
|
486
|
+
*/
|
|
487
|
+
static collectISAHierarchyEntityNamesLower(entityInfo, excludeLower) {
|
|
488
|
+
if (!entityInfo.IsChildType && !entityInfo.IsParentType) {
|
|
489
|
+
return [];
|
|
490
|
+
}
|
|
491
|
+
let root = entityInfo;
|
|
492
|
+
const seenAncestors = new Set();
|
|
493
|
+
while (root.ParentEntityInfo && !seenAncestors.has(root.ParentEntityInfo)) {
|
|
494
|
+
seenAncestors.add(root);
|
|
495
|
+
root = root.ParentEntityInfo;
|
|
496
|
+
}
|
|
497
|
+
const results = [];
|
|
498
|
+
const visited = new Set();
|
|
499
|
+
const queue = [root];
|
|
500
|
+
while (queue.length > 0) {
|
|
501
|
+
const current = queue.shift();
|
|
502
|
+
const lower = current?.Name?.trim().toLowerCase();
|
|
503
|
+
if (!lower || visited.has(lower)) {
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
visited.add(lower);
|
|
507
|
+
if (lower !== excludeLower) {
|
|
508
|
+
results.push(lower);
|
|
509
|
+
}
|
|
510
|
+
if (Array.isArray(current.ChildEntities) && current.ChildEntities.length > 0) {
|
|
511
|
+
queue.push(...current.ChildEntities);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return results;
|
|
515
|
+
}
|
|
434
516
|
/**
|
|
435
517
|
* Lazily registers this instance (once per provider instance) for write-invalidation
|
|
436
518
|
* of in-flight/lingered RunView entries touching a written entity. Deleting an IN-FLIGHT
|
|
@@ -451,6 +533,17 @@ export class ProviderBase {
|
|
|
451
533
|
this._lingerInvalidationWired = true;
|
|
452
534
|
ProviderBase._invalidationTargets.add(new WeakRef(this));
|
|
453
535
|
}
|
|
536
|
+
/**
|
|
537
|
+
* Returns the current monotonic mutation version for `entityName` (case-insensitive).
|
|
538
|
+
*/
|
|
539
|
+
GetEntityMutationVersion(entityName) {
|
|
540
|
+
this.ensureInflightViewInvalidation();
|
|
541
|
+
const lower = entityName?.trim().toLowerCase();
|
|
542
|
+
if (!lower) {
|
|
543
|
+
return 0;
|
|
544
|
+
}
|
|
545
|
+
return this._entityMutationVersions.get(lower) ?? 0;
|
|
546
|
+
}
|
|
454
547
|
/**
|
|
455
548
|
* Drops this provider's in-flight/lingered RunView dedup entries so the next identical
|
|
456
549
|
* RunView goes to the server instead of replaying a result from the last
|
|
@@ -473,6 +566,9 @@ export class ProviderBase {
|
|
|
473
566
|
const names = (entityNames ?? []).map(n => n?.trim().toLowerCase()).filter((n) => !!n);
|
|
474
567
|
if (names.length === 0) {
|
|
475
568
|
this._inflightViews.clear();
|
|
569
|
+
for (const [k, v] of this._entityMutationVersions) {
|
|
570
|
+
this._entityMutationVersions.set(k, v + 1);
|
|
571
|
+
}
|
|
476
572
|
return;
|
|
477
573
|
}
|
|
478
574
|
for (const n of names) {
|
|
@@ -484,6 +580,7 @@ export class ProviderBase {
|
|
|
484
580
|
* entity (lowercased name). Called on BaseEntity save/delete/remote-invalidate.
|
|
485
581
|
*/
|
|
486
582
|
invalidateInflightViewsForEntity(lowerEntityName) {
|
|
583
|
+
this._entityMutationVersions.set(lowerEntityName, (this._entityMutationVersions.get(lowerEntityName) ?? 0) + 1);
|
|
487
584
|
for (const [key, entry] of this._inflightViews) {
|
|
488
585
|
if (entry.entityNames?.has(lowerEntityName)) {
|
|
489
586
|
this._inflightViews.delete(key);
|
|
@@ -663,6 +760,7 @@ export class ProviderBase {
|
|
|
663
760
|
if (!this.eventTargetsThisProviderBackend(entityEvent)) {
|
|
664
761
|
return;
|
|
665
762
|
}
|
|
763
|
+
this._metadataMemberMutationSeq++;
|
|
666
764
|
this.scheduleMetadataMemberRefresh();
|
|
667
765
|
}
|
|
668
766
|
/**
|
|
@@ -881,6 +979,14 @@ export class ProviderBase {
|
|
|
881
979
|
return true;
|
|
882
980
|
}
|
|
883
981
|
this._metadataMemberRefreshWaits = 0;
|
|
982
|
+
if (this._metadataReloadInFlight) {
|
|
983
|
+
// A metadata reload is already in flight. Do not call RefreshRemoteMetadataTimestamps()
|
|
984
|
+
// concurrently here, because that mutates _latestRemoteMetadataTimestamps out from under
|
|
985
|
+
// the in-flight pass before it runs adoptServerMetadata(). Instead, delegate directly to
|
|
986
|
+
// ReloadMetadata, which sets _metadataReloadQueued = true and re-reads authoritative
|
|
987
|
+
// remote timestamps at the start of the queued follow-up pass.
|
|
988
|
+
return this.ReloadMetadata(true, undefined, true);
|
|
989
|
+
}
|
|
884
990
|
// The writer holds positive evidence, so the timestamps are re-read from the authoritative
|
|
885
991
|
// status query. When they attribute the change entirely to the saved-query family (see
|
|
886
992
|
// PartialRefreshMetadataItemCodes) only that family is reloaded, cache layers bypassed as a
|
|
@@ -1087,6 +1193,7 @@ export class ProviderBase {
|
|
|
1087
1193
|
if (params.OnDataChanged && preResult.fingerprint) {
|
|
1088
1194
|
preResult.cachedResult.Unsubscribe = LocalCacheManager.Instance.RegisterChangeCallback(preResult.fingerprint, params.OnDataChanged);
|
|
1089
1195
|
}
|
|
1196
|
+
ProviderBase.clearSkippedTotalRowCount(params, preResult.cachedResult);
|
|
1090
1197
|
return preResult.cachedResult;
|
|
1091
1198
|
}
|
|
1092
1199
|
// Cache miss — execute query, then post-process (stores in cache)
|
|
@@ -1095,6 +1202,7 @@ export class ProviderBase {
|
|
|
1095
1202
|
// PostRunView copies any hook-supplied replacement onto `result` in place, so this
|
|
1096
1203
|
// reference reflects the hook chain's output.
|
|
1097
1204
|
await this.PostRunView(result, params, preResult, contextUser);
|
|
1205
|
+
ProviderBase.clearSkippedTotalRowCount(params, result);
|
|
1098
1206
|
return result;
|
|
1099
1207
|
}
|
|
1100
1208
|
// Delegate to RunViews, which uses the smart cache check (lightweight maxUpdatedAt +
|
|
@@ -1167,27 +1275,7 @@ export class ProviderBase {
|
|
|
1167
1275
|
// ── Fresh execution ──
|
|
1168
1276
|
const promise = this.executeRunViewsPipeline(params, contextUser)
|
|
1169
1277
|
.then(results => {
|
|
1170
|
-
|
|
1171
|
-
// extreme churn (hundreds of distinct keys resolving within one linger
|
|
1172
|
-
// window) skip lingering rather than hold more result arrays in memory —
|
|
1173
|
-
// the linger is a latency optimization, never a correctness requirement.
|
|
1174
|
-
const entry = this._inflightViews.get(key);
|
|
1175
|
-
if (entry && entry.promise === promise) {
|
|
1176
|
-
if (ProviderBase.DedupLingerMs > 0 && this._inflightViews.size <= ProviderBase.MaxLingerEntries) {
|
|
1177
|
-
entry.resolvedResults = results;
|
|
1178
|
-
entry.resolvedAt = Date.now();
|
|
1179
|
-
// Schedule cleanup after linger expires
|
|
1180
|
-
setTimeout(() => {
|
|
1181
|
-
const current = this._inflightViews.get(key);
|
|
1182
|
-
if (current && current.promise === promise) {
|
|
1183
|
-
this._inflightViews.delete(key);
|
|
1184
|
-
}
|
|
1185
|
-
}, ProviderBase.DedupLingerMs);
|
|
1186
|
-
}
|
|
1187
|
-
else {
|
|
1188
|
-
this._inflightViews.delete(key);
|
|
1189
|
-
}
|
|
1190
|
-
}
|
|
1278
|
+
this.stashInflightLingerResult(key, promise, results, true);
|
|
1191
1279
|
return results;
|
|
1192
1280
|
})
|
|
1193
1281
|
.catch(err => {
|
|
@@ -1204,6 +1292,36 @@ export class ProviderBase {
|
|
|
1204
1292
|
return results.map(r => this.shallowCopyResult(r));
|
|
1205
1293
|
}
|
|
1206
1294
|
// ── Dedup helpers ──────────────────────────────────────────────────
|
|
1295
|
+
/**
|
|
1296
|
+
* Stashes resolved RunViews results on `_inflightViews` for `DedupLingerMs` and schedules
|
|
1297
|
+
* cleanup via an isolated static `WeakRef` timer helper so the timer closure's V8 lexical
|
|
1298
|
+
* context never retains `this`, `promise`, or `results` on short-lived per-request providers.
|
|
1299
|
+
*/
|
|
1300
|
+
stashInflightLingerResult(key, promise, results, enforceMaxEntries) {
|
|
1301
|
+
const entry = this._inflightViews.get(key);
|
|
1302
|
+
if (!entry || entry.promise !== promise) {
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
if (ProviderBase.DedupLingerMs > 0 && (!enforceMaxEntries || this._inflightViews.size <= ProviderBase.MaxLingerEntries)) {
|
|
1306
|
+
const resolvedAt = Date.now();
|
|
1307
|
+
entry.resolvedResults = results;
|
|
1308
|
+
entry.resolvedAt = resolvedAt;
|
|
1309
|
+
ProviderBase.scheduleInflightLingerExpiry(new WeakRef(this), key, resolvedAt);
|
|
1310
|
+
}
|
|
1311
|
+
else {
|
|
1312
|
+
this._inflightViews.delete(key);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
static scheduleInflightLingerExpiry(selfRef, key, resolvedAt) {
|
|
1316
|
+
const timer = setTimeout(() => {
|
|
1317
|
+
const self = selfRef.deref();
|
|
1318
|
+
const current = self?._inflightViews.get(key);
|
|
1319
|
+
if (current && current.resolvedAt === resolvedAt) {
|
|
1320
|
+
self?._inflightViews.delete(key);
|
|
1321
|
+
}
|
|
1322
|
+
}, ProviderBase.DedupLingerMs);
|
|
1323
|
+
timer?.unref?.();
|
|
1324
|
+
}
|
|
1207
1325
|
/**
|
|
1208
1326
|
* The original RunViews execution pipeline (pre-processing, cache,
|
|
1209
1327
|
* internal execution, post-processing).
|
|
@@ -1234,12 +1352,32 @@ export class ProviderBase {
|
|
|
1234
1352
|
}
|
|
1235
1353
|
return merged;
|
|
1236
1354
|
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Keeps the `TotalRowCount` contract on every way out of the pipeline: a caller that passed
|
|
1357
|
+
* `SkipTotalRowCount` gets `undefined`, never a number. Cache hits, the smart-cache check and
|
|
1358
|
+
* the GraphQL wire each rebuild a result and each have a reason to fill in a row count, so the
|
|
1359
|
+
* contract is applied once here rather than at each of those sites.
|
|
1360
|
+
*/
|
|
1361
|
+
static clearSkippedTotalRowCount(params, result) {
|
|
1362
|
+
if (result && params.SkipTotalRowCount === true && params.ResultType !== 'count_only') {
|
|
1363
|
+
result.TotalRowCount = undefined;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
/**
|
|
1367
|
+
* Runs a RunViews batch of the views the field-security predicate gate admitted, and applies
|
|
1368
|
+
* the `TotalRowCount` contract to whichever stage produced each result.
|
|
1369
|
+
*/
|
|
1370
|
+
async runAdmittedViews(params, preResult, contextUser) {
|
|
1371
|
+
const results = await this.runAdmittedViewStages(params, preResult, contextUser);
|
|
1372
|
+
params.forEach((param, i) => ProviderBase.clearSkippedTotalRowCount(param, results[i]));
|
|
1373
|
+
return results;
|
|
1374
|
+
}
|
|
1237
1375
|
/**
|
|
1238
1376
|
* The cache, database and post-processing stages of a RunViews batch, for the views the
|
|
1239
1377
|
* field-security predicate gate admitted. `params` and every index map in `preResult`
|
|
1240
1378
|
* describe the same admitted views.
|
|
1241
1379
|
*/
|
|
1242
|
-
async
|
|
1380
|
+
async runAdmittedViewStages(params, preResult, contextUser) {
|
|
1243
1381
|
// Check for smart cache check mode
|
|
1244
1382
|
if (preResult.useSmartCacheCheck && preResult.smartCacheCheckParams) {
|
|
1245
1383
|
return this.executeSmartCacheCheck(params, preResult, contextUser);
|
|
@@ -1258,6 +1396,7 @@ export class ProviderBase {
|
|
|
1258
1396
|
// allCached ⇒ every param produced a hit and was pushed in order (PreRunViews only
|
|
1259
1397
|
// pushes a null placeholder on the path that clears allCached), so index i of
|
|
1260
1398
|
// cachedResults corresponds to params[i].
|
|
1399
|
+
await this.TransformBatchRunViewResults(params, preResult.cachedResults, undefined, preResult.callerFieldsMap, contextUser);
|
|
1261
1400
|
for (let i = 0; i < preResult.cachedResults.length; i++) {
|
|
1262
1401
|
await this.ApplyPostRunViewHooksToCacheHit(params[i], preResult.cachedResults[i], contextUser);
|
|
1263
1402
|
}
|
|
@@ -1509,22 +1648,7 @@ export class ProviderBase {
|
|
|
1509
1648
|
// ── Fresh execution ──
|
|
1510
1649
|
const promise = this.executeRunViewsPipeline(params, contextUser)
|
|
1511
1650
|
.then(results => {
|
|
1512
|
-
|
|
1513
|
-
if (entry && entry.promise === promise) {
|
|
1514
|
-
entry.resolvedResults = results;
|
|
1515
|
-
entry.resolvedAt = Date.now();
|
|
1516
|
-
if (ProviderBase.DedupLingerMs > 0) {
|
|
1517
|
-
setTimeout(() => {
|
|
1518
|
-
const current = this._inflightViews.get(key);
|
|
1519
|
-
if (current && current.promise === promise) {
|
|
1520
|
-
this._inflightViews.delete(key);
|
|
1521
|
-
}
|
|
1522
|
-
}, ProviderBase.DedupLingerMs);
|
|
1523
|
-
}
|
|
1524
|
-
else {
|
|
1525
|
-
this._inflightViews.delete(key);
|
|
1526
|
-
}
|
|
1527
|
-
}
|
|
1651
|
+
this.stashInflightLingerResult(key, promise, results, false);
|
|
1528
1652
|
return results;
|
|
1529
1653
|
})
|
|
1530
1654
|
.catch(err => {
|
|
@@ -1584,6 +1708,7 @@ export class ProviderBase {
|
|
|
1584
1708
|
return false;
|
|
1585
1709
|
}
|
|
1586
1710
|
return !param.BypassCache &&
|
|
1711
|
+
param.CacheLocal !== false &&
|
|
1587
1712
|
!param.AfterKey &&
|
|
1588
1713
|
!IsMaterializedDataSource(param.DataSource) &&
|
|
1589
1714
|
!this.isAlternateBaseViewCacheBypassed(param) &&
|
|
@@ -1593,6 +1718,27 @@ export class ProviderBase {
|
|
|
1593
1718
|
// Deliberately not user-dependent: field security changes which slot a request uses
|
|
1594
1719
|
// (the fls: segment), never whether it is cacheable.
|
|
1595
1720
|
}
|
|
1721
|
+
/**
|
|
1722
|
+
* True when the caller explicitly requested a non-empty `Fields` projection on a plain-object
|
|
1723
|
+
* (`ResultType !== 'entity_object'`) read.
|
|
1724
|
+
*/
|
|
1725
|
+
hasNarrowFieldsOverride(param) {
|
|
1726
|
+
return Boolean(param.Fields && param.Fields.length > 0 && param.ResultType !== 'entity_object');
|
|
1727
|
+
}
|
|
1728
|
+
/**
|
|
1729
|
+
* True when a cache-miss read should widen `Fields` to the full entity superset AND populate
|
|
1730
|
+
* `LocalCacheManager` with that superset.
|
|
1731
|
+
*
|
|
1732
|
+
* On the server (`TrustLocalCacheCompletely === true`), cache-allowed entities (`AllowCaching === true`)
|
|
1733
|
+
* widen on miss by default so the Fields-agnostic superset slot serves any future `Fields` subset
|
|
1734
|
+
* (`server-cache` S1 -> S2 -> S3), UNLESS the caller explicitly opts out via `CacheLocal: false` or
|
|
1735
|
+
* `BypassCache: true` (in which case narrow SQL `SELECT` pushdown is preserved end-to-end without
|
|
1736
|
+
* polluting the cache). On the client (`TrustLocalCacheCompletely === false`), only `CacheLocal: true`
|
|
1737
|
+
* widens and caches.
|
|
1738
|
+
*/
|
|
1739
|
+
shouldWidenAndPopulateCache(param) {
|
|
1740
|
+
return this.runViewCacheEligible(param);
|
|
1741
|
+
}
|
|
1596
1742
|
/**
|
|
1597
1743
|
* True when the request targets an `AlternateBaseView` (explicitly on `param.AlternateBaseView`
|
|
1598
1744
|
* or pinned on `param.ViewEntity`) whose `CachePolicy` (or an ancestor's `CachePolicy` in its
|
|
@@ -1984,6 +2130,7 @@ export class ProviderBase {
|
|
|
1984
2130
|
ExtraFilter: filter,
|
|
1985
2131
|
ResultType: 'simple',
|
|
1986
2132
|
MaxRows: 1,
|
|
2133
|
+
SkipTotalRowCount: true,
|
|
1987
2134
|
}, contextUser);
|
|
1988
2135
|
if (!r.Success || (r.Results?.length ?? 0) === 0)
|
|
1989
2136
|
return null;
|
|
@@ -2092,6 +2239,7 @@ export class ProviderBase {
|
|
|
2092
2239
|
Fields: entity.PrimaryKeys.map(pk => pk.Name),
|
|
2093
2240
|
ResultType: 'simple',
|
|
2094
2241
|
MaxRows: ids.length,
|
|
2242
|
+
SkipTotalRowCount: true,
|
|
2095
2243
|
}, contextUser);
|
|
2096
2244
|
if (!r.Success)
|
|
2097
2245
|
return new Set();
|
|
@@ -2112,7 +2260,10 @@ export class ProviderBase {
|
|
|
2112
2260
|
// revoked grants stay visible through the linger window.
|
|
2113
2261
|
// SaveViewResults bypasses for the original reason: it creates DB
|
|
2114
2262
|
// records and must not be deduplicated.
|
|
2115
|
-
|
|
2263
|
+
// CacheLocal:false is the server-side opt-out from the RunView cache (read AND write). A
|
|
2264
|
+
// linger hit would hand back rows that were themselves served from that cache, so the
|
|
2265
|
+
// opt-out has to cover the dedup window as well.
|
|
2266
|
+
return params.some(p => p.SaveViewResults === true || p.BypassCache === true || p.CacheLocal === false);
|
|
2116
2267
|
}
|
|
2117
2268
|
/**
|
|
2118
2269
|
* Returns a shallow copy of a RunViewResult: the Results array is a
|
|
@@ -2263,7 +2414,8 @@ export class ProviderBase {
|
|
|
2263
2414
|
...(params.Parameters ?? {}),
|
|
2264
2415
|
__maxRows: params.MaxRows ?? -1,
|
|
2265
2416
|
__startRow: params.StartRow ?? 0,
|
|
2266
|
-
...serverSegment
|
|
2417
|
+
...serverSegment,
|
|
2418
|
+
...(queryAuth.fieldSecurityKey ? { __fieldSecurity: queryAuth.fieldSecurityKey } : {})
|
|
2267
2419
|
};
|
|
2268
2420
|
if (serverSegment !== null) {
|
|
2269
2421
|
queryFingerprint = LocalCacheManager.Instance.GenerateRunQueryFingerprint(
|
|
@@ -2280,22 +2432,24 @@ export class ProviderBase {
|
|
|
2280
2432
|
queryDefinition = queryAuth.definition;
|
|
2281
2433
|
// TTL-enforced, and a miss when the slot was written under another query definition.
|
|
2282
2434
|
const cached = queryFingerprint ? await LocalCacheManager.Instance.GetRunQueryResult(queryFingerprint, queryDefinition) : null;
|
|
2283
|
-
if (
|
|
2284
|
-
const serveFromSlot =
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2435
|
+
if (queryFingerprint) {
|
|
2436
|
+
const serveFromSlot = cached
|
|
2437
|
+
? () => ({
|
|
2438
|
+
QueryID: cached.queryId ?? params.QueryID ?? '',
|
|
2439
|
+
QueryName: params.QueryName ?? '',
|
|
2440
|
+
Success: true,
|
|
2441
|
+
// Transport boundary: `cached.results` is readonly (shared, deep-frozen cache
|
|
2442
|
+
// rows) while the outbound Results is mutable — the runtime freeze is the
|
|
2443
|
+
// enforcement. Same cast as the RunView hit paths above.
|
|
2444
|
+
Results: cached.results,
|
|
2445
|
+
RowCount: cached.results.length,
|
|
2446
|
+
TotalRowCount: cached.rowCount ?? cached.results.length,
|
|
2447
|
+
ExecutionTime: 0,
|
|
2448
|
+
ErrorMessage: '',
|
|
2449
|
+
CacheHit: true,
|
|
2450
|
+
CacheKey: queryFingerprint
|
|
2451
|
+
})
|
|
2452
|
+
: undefined;
|
|
2299
2453
|
const checker = this.RunQueriesWithCacheCheck?.bind(this);
|
|
2300
2454
|
if (!checker || this.TrustLocalCacheCompletely) {
|
|
2301
2455
|
// TTL mode (server providers / no validation transport).
|
|
@@ -2312,69 +2466,71 @@ export class ProviderBase {
|
|
|
2312
2466
|
// authorizes with the proper error message. Query metadata missing from the
|
|
2313
2467
|
// provider cache ⇒ ALSO fall through (never serve rows we cannot authorize);
|
|
2314
2468
|
// that costs one query run, not correctness.
|
|
2315
|
-
if (
|
|
2316
|
-
if (
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2469
|
+
if (cached && serveFromSlot) {
|
|
2470
|
+
if (contextUser) {
|
|
2471
|
+
if (queryAuth.resolvable) {
|
|
2472
|
+
// Metadata can answer — enforce it. `authorized` comes from the
|
|
2473
|
+
// provider's STRONGEST available check (B45): the base enforces
|
|
2474
|
+
// roles-only QueryInfo.UserCanRun; GenericDatabaseProvider's
|
|
2475
|
+
// override enforces the full MJQueryEntityExtended.UserCanRun
|
|
2476
|
+
// (roles + entity CanRead + recursive composition) — the identical
|
|
2477
|
+
// check ValidateQueryForExecution applies on the miss path, so a
|
|
2478
|
+
// hit is never easier to read than a miss.
|
|
2479
|
+
if (queryAuth.authorized) {
|
|
2480
|
+
return serveFromSlot();
|
|
2481
|
+
}
|
|
2482
|
+
LogStatusEx({ message: `RunQuery cache: user '${contextUser.Email}' lacks run permission on '${queryAuth.queryName ?? params.QueryName ?? params.QueryID}' — falling through to authorized execution.`, verboseOnly: true });
|
|
2483
|
+
}
|
|
2484
|
+
else if (cached.warmedForUserID && UUIDsEqual(cached.warmedForUserID, contextUser.ID)) {
|
|
2485
|
+
// Metadata CANNOT answer — runtime-created queries never appear in the
|
|
2486
|
+
// provider's Queries cache (it does not refresh in-process; verified:
|
|
2487
|
+
// a saved Query stays invisible, 21 -> 21). But a slot only exists
|
|
2488
|
+
// because its WARMER executed the fully-authorized miss path — so the
|
|
2489
|
+
// warmer's own permission is already proven. Serve the warmer;
|
|
2490
|
+
// anyone ELSE falls through and pays one authorized execution.
|
|
2491
|
+
//
|
|
2492
|
+
// The first version of this gate failed closed on unresolvable
|
|
2493
|
+
// metadata, which silently disabled TTL caching for every
|
|
2494
|
+
// runtime-created query — caught by Q2/Q5/Q10 going red.
|
|
2325
2495
|
return serveFromSlot();
|
|
2326
2496
|
}
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
// Metadata CANNOT answer — runtime-created queries never appear in the
|
|
2331
|
-
// provider's Queries cache (it does not refresh in-process; verified:
|
|
2332
|
-
// a saved Query stays invisible, 21 -> 21). But a slot only exists
|
|
2333
|
-
// because its WARMER executed the fully-authorized miss path — so the
|
|
2334
|
-
// warmer's own permission is already proven. Serve the warmer;
|
|
2335
|
-
// anyone ELSE falls through and pays one authorized execution.
|
|
2336
|
-
//
|
|
2337
|
-
// The first version of this gate failed closed on unresolvable
|
|
2338
|
-
// metadata, which silently disabled TTL caching for every
|
|
2339
|
-
// runtime-created query — caught by Q2/Q5/Q10 going red.
|
|
2340
|
-
return serveFromSlot();
|
|
2497
|
+
else {
|
|
2498
|
+
LogStatusEx({ message: `RunQuery cache: '${params.QueryID ?? params.QueryName}' not in cached metadata and requester is not the slot's warmer — falling through to authorized execution.`, verboseOnly: true });
|
|
2499
|
+
}
|
|
2341
2500
|
}
|
|
2342
2501
|
else {
|
|
2343
|
-
|
|
2502
|
+
return serveFromSlot();
|
|
2344
2503
|
}
|
|
2345
2504
|
}
|
|
2346
|
-
else {
|
|
2347
|
-
return serveFromSlot();
|
|
2348
|
-
}
|
|
2349
|
-
}
|
|
2350
|
-
// Client smart validation round trip.
|
|
2351
|
-
//
|
|
2352
|
-
// Guarded on `checker` (R4-2): the block above is entered when `!checker ||
|
|
2353
|
-
// TrustLocalCacheCompletely`. If we got here via `!checker` AND the B43 permission
|
|
2354
|
-
// gate DECLINED to serve (denied user / non-warmer), execution used to fall
|
|
2355
|
-
// straight into `await checker(...)` = `await undefined(...)` — a TypeError instead
|
|
2356
|
-
// of the promised "fall through to authorized execution". Every in-tree provider
|
|
2357
|
-
// implements the optional `RunQueriesWithCacheCheck`, so this was latent, but the
|
|
2358
|
-
// pre-B43 code handled `!checker` and this must too: skip the round trip and let
|
|
2359
|
-
// the normal PreRunQuery/InternalRunQuery path below authorize and execute.
|
|
2360
|
-
if (!checker) {
|
|
2361
|
-
// fall through to normal execution below
|
|
2362
2505
|
}
|
|
2363
2506
|
else {
|
|
2507
|
+
// Client smart validation round trip (both cold first-fetch and warm revalidation).
|
|
2508
|
+
// Routing cold CacheLocal requests through RunQueriesWithCacheCheck with no cacheStatus
|
|
2509
|
+
// engages the server's firstFetchWithValidation path so the initial client slot is
|
|
2510
|
+
// stamped with maxUpdatedAt, rowCount, and definitionHash — allowing the very first
|
|
2511
|
+
// subsequent refresh (F5) to validate as 'current' instead of missing.
|
|
2364
2512
|
const response = await checker([{
|
|
2365
2513
|
params,
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2514
|
+
...(cached
|
|
2515
|
+
? {
|
|
2516
|
+
// '' for a slot written without a hash: the server answers `stale` and returns
|
|
2517
|
+
// its hash, so the rewrite below stamps the slot.
|
|
2518
|
+
cacheStatus: {
|
|
2519
|
+
maxUpdatedAt: cached.maxUpdatedAt,
|
|
2520
|
+
rowCount: cached.rowCount,
|
|
2521
|
+
definitionHash: cached.definitionHash ?? ''
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
: {})
|
|
2369
2525
|
}], contextUser);
|
|
2370
2526
|
const check = response.results?.[0];
|
|
2371
2527
|
if (response.success && check) {
|
|
2372
|
-
if (check.status === 'current') {
|
|
2528
|
+
if (check.status === 'current' && serveFromSlot) {
|
|
2373
2529
|
return serveFromSlot();
|
|
2374
2530
|
}
|
|
2375
2531
|
if ((check.status === 'stale' || check.status === 'no_validation') && check.results) {
|
|
2376
2532
|
const freshRows = check.results;
|
|
2377
|
-
// Fire-and-forget slot rewrite — same pattern as the RunView client path
|
|
2533
|
+
// Fire-and-forget slot write/rewrite — same pattern as the RunView client path
|
|
2378
2534
|
LocalCacheManager.Instance.SetRunQueryResult(queryFingerprint, params.QueryName ?? '', freshRows, check.maxUpdatedAt ?? '', check.rowCount, check.queryId, params.CacheLocalTTL, contextUser?.ID, check.definitionHash ? { QueryID: check.queryId, Hash: check.definitionHash } : undefined).catch(e => LogError(`RunQuery cache rewrite failed: ${e}`));
|
|
2379
2535
|
return {
|
|
2380
2536
|
QueryID: check.queryId ?? params.QueryID ?? '',
|
|
@@ -2384,12 +2540,25 @@ export class ProviderBase {
|
|
|
2384
2540
|
RowCount: freshRows.length,
|
|
2385
2541
|
TotalRowCount: check.rowCount ?? freshRows.length,
|
|
2386
2542
|
ExecutionTime: 0,
|
|
2387
|
-
ErrorMessage: ''
|
|
2543
|
+
ErrorMessage: '',
|
|
2544
|
+
CacheKey: queryFingerprint
|
|
2545
|
+
};
|
|
2546
|
+
}
|
|
2547
|
+
if (check.status === 'error') {
|
|
2548
|
+
return {
|
|
2549
|
+
QueryID: check.queryId ?? params.QueryID ?? '',
|
|
2550
|
+
QueryName: params.QueryName ?? '',
|
|
2551
|
+
Success: false,
|
|
2552
|
+
Results: [],
|
|
2553
|
+
RowCount: 0,
|
|
2554
|
+
TotalRowCount: 0,
|
|
2555
|
+
ExecutionTime: 0,
|
|
2556
|
+
ErrorMessage: check.errorMessage ?? response.errorMessage ?? 'Query execution failed'
|
|
2388
2557
|
};
|
|
2389
2558
|
}
|
|
2390
2559
|
}
|
|
2560
|
+
// validation transport failed — fall through to a normal execution
|
|
2391
2561
|
}
|
|
2392
|
-
// validation transport failed — fall through to a normal execution
|
|
2393
2562
|
}
|
|
2394
2563
|
}
|
|
2395
2564
|
// Pre-processing: telemetry, cache check
|
|
@@ -3056,41 +3225,29 @@ export class ProviderBase {
|
|
|
3056
3225
|
await this.EntityStatusCheck(params, 'PreRunView', contextUser);
|
|
3057
3226
|
const entityCheckTime = performance.now() - entityCheckStart;
|
|
3058
3227
|
// Save the caller's original Fields request for post-cache filtering.
|
|
3059
|
-
//
|
|
3060
|
-
//
|
|
3061
|
-
//
|
|
3062
|
-
//
|
|
3228
|
+
// When widening happens (full-row read, a cache-eligible read on the server, explicit
|
|
3229
|
+
// CacheLocal: true, or entity_object), we fetch every field this user's permission class
|
|
3230
|
+
// may read so the cache entry is a superset for that class, and project the result back to
|
|
3231
|
+
// the caller's Fields. A narrow Fields read first checks any already-warm superset slot
|
|
3232
|
+
// (0-SQL hit). Only CacheLocal: false or BypassCache: true keep the caller's narrow SQL
|
|
3233
|
+
// projection on a miss, and then nothing is written to the cache.
|
|
3063
3234
|
const entityLookupStart = performance.now();
|
|
3064
|
-
|
|
3235
|
+
const rawCallerRequestedFields = params.Fields && params.Fields.length > 0
|
|
3065
3236
|
? params.Fields.map(f => f.trim().toLowerCase())
|
|
3066
3237
|
: null; // null = caller wants all fields
|
|
3067
|
-
// Only override Fields to the widened fetch set when caching will actually happen
|
|
3068
|
-
// for this call. For non-cached calls we respect the caller's narrow Fields
|
|
3069
|
-
// end-to-end — there's no cache-coherence concern to preserve. The widened set is
|
|
3070
|
-
// ALL entity fields; a database provider then leaves out the columns this user may
|
|
3071
|
-
// not read, and the slot is keyed by the user's permission class (fls: segment).
|
|
3072
3238
|
const entity = params.EntityName ? this.EntityByName(params.EntityName) : null;
|
|
3073
|
-
const willCache = this.runViewCacheEligible(params);
|
|
3074
|
-
// entity_object ALWAYS widens, cache-eligible or not: a BaseEntity must hydrate from
|
|
3075
|
-
// every column it is entitled to, or it round-trips partial state into a save. This
|
|
3076
|
-
// must happen here rather than only in the server's PreProcessRunView, which is not on
|
|
3077
|
-
// the client's path — a client entity_object request without CacheLocal would otherwise
|
|
3078
|
-
// ship the caller's narrow Fields over the wire and materialize partial entities. What a
|
|
3079
|
-
// field-restricted user RECEIVES is still their allowed set: the server strips denied
|
|
3080
|
-
// columns on the wire, and the missing keys mark those fields not-loaded.
|
|
3081
|
-
const widenForEntityObject = params.ResultType === 'entity_object';
|
|
3082
3239
|
if (entity)
|
|
3083
3240
|
this.ResolveIncludeBinaryFields(params, entity);
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3241
|
+
const projectedCallerFields = (rawCallerRequestedFields && entity)
|
|
3242
|
+
? ProviderBase.UnionFieldsWithPrimaryKeys(rawCallerRequestedFields, entity)
|
|
3243
|
+
: rawCallerRequestedFields;
|
|
3244
|
+
const canReadFromCache = this.runViewCacheEligible(params);
|
|
3245
|
+
const willPopulateCache = this.shouldWidenAndPopulateCache(params) && (!this.hasNarrowFieldsOverride(params) || Boolean(entity));
|
|
3246
|
+
// entity_object ALWAYS widens, cache-eligible or not: a BaseEntity must hydrate from
|
|
3247
|
+
// every column it is entitled to, or it round-trips partial state into a save.
|
|
3248
|
+
const widenForEntityObject = params.ResultType === 'entity_object';
|
|
3092
3249
|
const entityLookupTime = performance.now() - entityLookupStart;
|
|
3093
|
-
// Check local cache if enabled
|
|
3250
|
+
// Check local cache FIRST if enabled (before any cache-miss Fields widening)
|
|
3094
3251
|
const cacheCheckStart = performance.now();
|
|
3095
3252
|
let cacheStatus = 'disabled';
|
|
3096
3253
|
let cachedResult;
|
|
@@ -3106,16 +3263,17 @@ export class ProviderBase {
|
|
|
3106
3263
|
// fails closed (or resolves ViewEntity synchronously) to plug that hole.
|
|
3107
3264
|
const cacheReadDenied = (!!entity && !!contextUser && !(entity.GetUserPermisions(contextUser)?.CanRead ?? false)) ||
|
|
3108
3265
|
this.cacheDeniedForViewOnlyRequest(params, contextUser);
|
|
3109
|
-
if (
|
|
3266
|
+
if (canReadFromCache && !cacheReadDenied && LocalCacheManager.Instance.IsInitialized) {
|
|
3110
3267
|
fingerprint = this.ComputeRunViewCacheFingerprint(params, contextUser);
|
|
3111
3268
|
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
3112
3269
|
if (cached) {
|
|
3113
3270
|
// These rows are the cache's shared, deep-frozen objects — the runtime freeze is
|
|
3114
3271
|
// what stops a consumer from corrupting the cache. Anything that needs to
|
|
3115
3272
|
// transform them must map onto copies.
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3273
|
+
// Materialize any encrypted fields stored as ciphertext at rest before projection.
|
|
3274
|
+
let results = await this.MaterializeCachedRowsForCaller(cached.results, entity, contextUser, Boolean(params.PreserveRestrictedApiCiphertext));
|
|
3275
|
+
if (projectedCallerFields && params.ResultType !== 'entity_object') {
|
|
3276
|
+
results = ProjectRowsToFields(results, projectedCallerFields);
|
|
3119
3277
|
}
|
|
3120
3278
|
// Field-level security MUST be applied here, not only in PostRunView: this path
|
|
3121
3279
|
// RETURNS (see RunView) without ever calling PostRunView, and it is the exact
|
|
@@ -3149,6 +3307,15 @@ export class ProviderBase {
|
|
|
3149
3307
|
cacheStatus = 'miss';
|
|
3150
3308
|
}
|
|
3151
3309
|
}
|
|
3310
|
+
// On cache MISS (or uninitialized cache), widen Fields ONLY when populating a superset
|
|
3311
|
+
// cache slot or materializing full BaseEntity objects.
|
|
3312
|
+
let callerRequestedFieldsForPost = null;
|
|
3313
|
+
if (cacheStatus !== 'hit' && entity && (willPopulateCache || widenForEntityObject)) {
|
|
3314
|
+
params.Fields = this.ComputeRunViewFetchFields(entity, params);
|
|
3315
|
+
if (willPopulateCache && projectedCallerFields) {
|
|
3316
|
+
callerRequestedFieldsForPost = projectedCallerFields;
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3152
3319
|
const cacheCheckTime = performance.now() - cacheCheckStart;
|
|
3153
3320
|
const totalPreTime = performance.now() - preViewStart;
|
|
3154
3321
|
if (totalPreTime > 50) {
|
|
@@ -3159,9 +3326,10 @@ export class ProviderBase {
|
|
|
3159
3326
|
cacheStatus,
|
|
3160
3327
|
cachedResult,
|
|
3161
3328
|
fingerprint,
|
|
3162
|
-
// Only non-null when params.Fields was actually widened
|
|
3329
|
+
// Only non-null when params.Fields was actually widened for cache population — tells
|
|
3163
3330
|
// PostRunView to project cache-miss DB results back to the caller's shape
|
|
3164
|
-
callerRequestedFields:
|
|
3331
|
+
callerRequestedFields: callerRequestedFieldsForPost,
|
|
3332
|
+
populateCache: willPopulateCache,
|
|
3165
3333
|
};
|
|
3166
3334
|
}
|
|
3167
3335
|
/**
|
|
@@ -3283,6 +3451,7 @@ export class ProviderBase {
|
|
|
3283
3451
|
const cacheStatusMap = new Map();
|
|
3284
3452
|
const callerFieldsMap = new Map();
|
|
3285
3453
|
const fingerprintMap = new Map();
|
|
3454
|
+
const populateCacheSet = new Set();
|
|
3286
3455
|
const uncachedParams = [];
|
|
3287
3456
|
const cachedResults = [];
|
|
3288
3457
|
let allCached = true;
|
|
@@ -3290,31 +3459,22 @@ export class ProviderBase {
|
|
|
3290
3459
|
const param = params[i];
|
|
3291
3460
|
// Entity status check
|
|
3292
3461
|
await this.EntityStatusCheck(param, 'PreRunViews', contextUser);
|
|
3293
|
-
// Save caller's original Fields
|
|
3294
|
-
//
|
|
3295
|
-
|
|
3462
|
+
// Save caller's original Fields. Consult warm superset cache slots first (0-SQL hit),
|
|
3463
|
+
// and on cache miss widen Fields ONLY when populating a superset cache slot or
|
|
3464
|
+
// materializing BaseEntity instances.
|
|
3465
|
+
const rawCallerFields = param.Fields && param.Fields.length > 0
|
|
3296
3466
|
? param.Fields.map(f => f.trim().toLowerCase())
|
|
3297
3467
|
: null;
|
|
3298
|
-
// Only override Fields to the widened fetch set when caching will actually happen
|
|
3299
|
-
// for this call. For non-cached calls we respect the caller's narrow Fields
|
|
3300
|
-
// end-to-end — there's no cache-coherence concern to preserve. Widened = ALL
|
|
3301
|
-
// fields (see PreRunView).
|
|
3302
3468
|
const batchEntity = param.EntityName ? this.EntityByName(param.EntityName) : null;
|
|
3303
|
-
const batchWillCache = this.runViewCacheEligible(param);
|
|
3304
|
-
// Same entity_object-always-widens rule as the single-view path above.
|
|
3305
|
-
const batchWidenForEntityObject = param.ResultType === 'entity_object';
|
|
3306
3469
|
if (batchEntity)
|
|
3307
3470
|
this.ResolveIncludeBinaryFields(param, batchEntity);
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
callerFieldsMap.set(i, callerFields);
|
|
3316
|
-
}
|
|
3317
|
-
}
|
|
3471
|
+
const projectedCallerFields = (rawCallerFields && batchEntity)
|
|
3472
|
+
? ProviderBase.UnionFieldsWithPrimaryKeys(rawCallerFields, batchEntity)
|
|
3473
|
+
: rawCallerFields;
|
|
3474
|
+
const batchCanReadCache = this.runViewCacheEligible(param);
|
|
3475
|
+
const batchWillPopulateCache = this.shouldWidenAndPopulateCache(param) && (!this.hasNarrowFieldsOverride(param) || Boolean(batchEntity));
|
|
3476
|
+
// Same entity_object-always-widens rule as the single-view path above.
|
|
3477
|
+
const batchWidenForEntityObject = param.ResultType === 'entity_object';
|
|
3318
3478
|
// Check local cache if enabled or if server trusts its cache completely
|
|
3319
3479
|
// BypassCache skips cache entirely — used by maintenance actions querying for
|
|
3320
3480
|
// records that were inserted via direct SQL (bypassing BaseEntity.Save())
|
|
@@ -3324,15 +3484,16 @@ export class ProviderBase {
|
|
|
3324
3484
|
// view-only fail-closed gate for ViewID/ViewName-only requests (no resolvable entity).
|
|
3325
3485
|
const batchCacheReadDenied = (!!batchEntity && !!contextUser && !(batchEntity.GetUserPermisions(contextUser)?.CanRead ?? false)) ||
|
|
3326
3486
|
this.cacheDeniedForViewOnlyRequest(param, contextUser);
|
|
3327
|
-
if (
|
|
3487
|
+
if (batchCanReadCache && !batchCacheReadDenied && LocalCacheManager.Instance.IsInitialized) {
|
|
3328
3488
|
const fingerprint = this.ComputeRunViewCacheFingerprint(param, contextUser);
|
|
3329
3489
|
fingerprintMap.set(i, fingerprint);
|
|
3330
3490
|
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
3331
3491
|
if (cached) {
|
|
3332
3492
|
// Shared, deep-frozen cache rows — same contract as the single-view hit path.
|
|
3333
|
-
let results = cached.results;
|
|
3334
|
-
if (
|
|
3335
|
-
results = ProjectRowsToFields(results,
|
|
3493
|
+
let results = await this.MaterializeCachedRowsForCaller(cached.results, batchEntity, contextUser, Boolean(param.PreserveRestrictedApiCiphertext));
|
|
3494
|
+
if (projectedCallerFields && param.ResultType !== 'entity_object') {
|
|
3495
|
+
results = ProjectRowsToFields(results, projectedCallerFields);
|
|
3496
|
+
callerFieldsMap.set(i, projectedCallerFields);
|
|
3336
3497
|
}
|
|
3337
3498
|
// Field-level security on the batch cache-hit path — same reasoning as the
|
|
3338
3499
|
// single-view path in PreRunView: this result is returned without
|
|
@@ -3351,8 +3512,9 @@ export class ProviderBase {
|
|
|
3351
3512
|
// different Aggregates[] order — remap to THIS caller's requested order.
|
|
3352
3513
|
AggregateResults: LocalCacheManager.Instance.ReorderAggregateResultsToRequest(cached.aggregateResults, param.Aggregates)
|
|
3353
3514
|
};
|
|
3354
|
-
//
|
|
3355
|
-
|
|
3515
|
+
// Entity transformation (`ResultType: 'entity_object'`) is deferred to
|
|
3516
|
+
// TransformBatchRunViewResults in runAdmittedViewStages / PostRunViews so
|
|
3517
|
+
// cache hits and DB misses participate together in two-phase sibling IS-A hydration.
|
|
3356
3518
|
this.logDeprecatedAlternateBaseViewIfAny(param, 'PreRunViews');
|
|
3357
3519
|
if (!param.CacheLocal && this.TrustLocalCacheCompletely) {
|
|
3358
3520
|
LogStatusEx({ message: ` ✅ [Server Cache HIT] RunViews "${param.EntityName || param.ViewName || 'unknown'}" — ${cached.results.length} rows served from server cache (no DB query)`, verboseOnly: true });
|
|
@@ -3368,6 +3530,15 @@ export class ProviderBase {
|
|
|
3368
3530
|
else {
|
|
3369
3531
|
cacheStatusMap.set(i, { status: 'disabled' });
|
|
3370
3532
|
}
|
|
3533
|
+
if (batchWillPopulateCache) {
|
|
3534
|
+
populateCacheSet.add(i);
|
|
3535
|
+
}
|
|
3536
|
+
if (batchEntity && (batchWillPopulateCache || batchWidenForEntityObject)) {
|
|
3537
|
+
param.Fields = this.ComputeRunViewFetchFields(batchEntity, param);
|
|
3538
|
+
if (batchWillPopulateCache && projectedCallerFields) {
|
|
3539
|
+
callerFieldsMap.set(i, projectedCallerFields);
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3371
3542
|
allCached = false;
|
|
3372
3543
|
uncachedParams.push(param);
|
|
3373
3544
|
cachedResults.push(null); // Placeholder for uncached
|
|
@@ -3382,7 +3553,8 @@ export class ProviderBase {
|
|
|
3382
3553
|
uncachedParams: allCached ? undefined : uncachedParams,
|
|
3383
3554
|
cacheStatusMap,
|
|
3384
3555
|
callerFieldsMap: callerFieldsMap.size > 0 ? callerFieldsMap : undefined,
|
|
3385
|
-
fingerprintMap: fingerprintMap.size > 0 ? fingerprintMap : undefined
|
|
3556
|
+
fingerprintMap: fingerprintMap.size > 0 ? fingerprintMap : undefined,
|
|
3557
|
+
populateCacheSet,
|
|
3386
3558
|
};
|
|
3387
3559
|
}
|
|
3388
3560
|
/**
|
|
@@ -3518,6 +3690,14 @@ export class ProviderBase {
|
|
|
3518
3690
|
// pre-resolved map instead of issuing their own GetRunViewResult calls.
|
|
3519
3691
|
const processingPromises = params.map((param, i) => this.processSingleSmartCacheResult(param, i, serverResultsByViewIndex.get(i), preResolvedCache, contextUser));
|
|
3520
3692
|
const processedResults = await Promise.all(processingPromises);
|
|
3693
|
+
const batchResults = processedResults.map(r => r.result);
|
|
3694
|
+
const alreadyTransformedIndices = new Set();
|
|
3695
|
+
for (let i = 0; i < processedResults.length; i++) {
|
|
3696
|
+
if (processedResults[i].alreadyTransformed) {
|
|
3697
|
+
alreadyTransformedIndices.add(i);
|
|
3698
|
+
}
|
|
3699
|
+
}
|
|
3700
|
+
await this.TransformBatchRunViewResults(params, batchResults, alreadyTransformedIndices, undefined, contextUser);
|
|
3521
3701
|
// Aggregate telemetry stats
|
|
3522
3702
|
let cacheHits = 0;
|
|
3523
3703
|
let cacheMisses = 0;
|
|
@@ -3535,7 +3715,7 @@ export class ProviderBase {
|
|
|
3535
3715
|
cacheMisses,
|
|
3536
3716
|
batchSize: params.length
|
|
3537
3717
|
});
|
|
3538
|
-
return
|
|
3718
|
+
return batchResults;
|
|
3539
3719
|
}
|
|
3540
3720
|
/**
|
|
3541
3721
|
* Processes a single smart cache check result.
|
|
@@ -3581,8 +3761,6 @@ export class ProviderBase {
|
|
|
3581
3761
|
// different Aggregates[] order — remap to THIS caller's requested order.
|
|
3582
3762
|
AggregateResults: LocalCacheManager.Instance.ReorderAggregateResultsToRequest(cached.aggregateResults, param.Aggregates)
|
|
3583
3763
|
};
|
|
3584
|
-
// Transform to entity objects if needed
|
|
3585
|
-
await this.TransformSimpleObjectToEntityObject(param, cachedResult, contextUser);
|
|
3586
3764
|
return { result: cachedResult, cacheHit: true, cacheMiss: false };
|
|
3587
3765
|
}
|
|
3588
3766
|
else {
|
|
@@ -3633,8 +3811,6 @@ export class ProviderBase {
|
|
|
3633
3811
|
// Include aggregate results - either fresh from server or from merged cache
|
|
3634
3812
|
AggregateResults: checkResult.aggregateResults || merged.aggregateResults
|
|
3635
3813
|
};
|
|
3636
|
-
// Transform to entity objects if needed
|
|
3637
|
-
await this.TransformSimpleObjectToEntityObject(param, mergedResult, contextUser);
|
|
3638
3814
|
return { result: mergedResult, cacheHit: true, cacheMiss: false };
|
|
3639
3815
|
}
|
|
3640
3816
|
}
|
|
@@ -3649,14 +3825,23 @@ export class ProviderBase {
|
|
|
3649
3825
|
// `r.result` straight to the caller, so null became the caller's RESULT.
|
|
3650
3826
|
//
|
|
3651
3827
|
// The only correct remedy is what a cache miss does anyway: fetch in full. The
|
|
3652
|
-
// declined slot was already invalidated by ApplyDifferentialUpdate,
|
|
3653
|
-
//
|
|
3654
|
-
//
|
|
3655
|
-
//
|
|
3828
|
+
// declined slot was already invalidated by ApplyDifferentialUpdate, and because
|
|
3829
|
+
// fallbackParam sets CacheLocal:false + BypassCache:true (so the refetch takes the
|
|
3830
|
+
// plain path and cannot re-enter the smart-cache transport), PostRunViews does not
|
|
3831
|
+
// write to LocalCacheManager — so we repopulate the slot explicitly here.
|
|
3656
3832
|
LogStatusEx({ message: `Differential merge declined for '${param.EntityName}' — refetching in full.`, verboseOnly: true });
|
|
3657
3833
|
const fallbackParam = { ...param, CacheLocal: false, BypassCache: true };
|
|
3658
3834
|
const freshFetch = await this.RunView(fallbackParam, contextUser);
|
|
3659
|
-
|
|
3835
|
+
if (this.runViewCacheEligibleForWrite(param) && freshFetch.Success && LocalCacheManager.Instance.IsInitialized) {
|
|
3836
|
+
const plainRows = (freshFetch.Results || []).map(row => row && typeof row.GetAll === 'function'
|
|
3837
|
+
? row.GetAll()
|
|
3838
|
+
: row);
|
|
3839
|
+
const maxUpdatedAt = checkResult.maxUpdatedAt || this.extractMaxUpdatedAt(plainRows) || '';
|
|
3840
|
+
if (maxUpdatedAt) {
|
|
3841
|
+
LocalCacheManager.Instance.SetRunViewResult(fingerprint, param, plainRows, maxUpdatedAt, freshFetch.AggregateResults ?? checkResult.aggregateResults, freshFetch.TotalRowCount ?? checkResult.rowCount, this).catch(e => LogError(`Failed to update cache after differential fallback: ${e}`));
|
|
3842
|
+
}
|
|
3843
|
+
}
|
|
3844
|
+
return { result: freshFetch, cacheHit: false, cacheMiss: true, alreadyTransformed: true };
|
|
3660
3845
|
}
|
|
3661
3846
|
else if (checkResult.status === 'stale') {
|
|
3662
3847
|
// Cache is stale - use fresh data and update cache (entity doesn't support differential)
|
|
@@ -3681,8 +3866,6 @@ export class ProviderBase {
|
|
|
3681
3866
|
LocalCacheManager.Instance.SetRunViewResult(fingerprint, param, checkResult.results || [], checkResult.maxUpdatedAt, checkResult.aggregateResults, // Include aggregate results in cache
|
|
3682
3867
|
checkResult.rowCount, this).catch(e => LogError(`Failed to update cache: ${e}`));
|
|
3683
3868
|
}
|
|
3684
|
-
// Transform to entity objects if needed
|
|
3685
|
-
await this.TransformSimpleObjectToEntityObject(param, freshResult, contextUser);
|
|
3686
3869
|
return { result: freshResult, cacheHit: false, cacheMiss: true };
|
|
3687
3870
|
}
|
|
3688
3871
|
else {
|
|
@@ -3762,18 +3945,39 @@ export class ProviderBase {
|
|
|
3762
3945
|
* @param preResult - The pre-processing result
|
|
3763
3946
|
* @param contextUser - Optional user context
|
|
3764
3947
|
*/
|
|
3948
|
+
/**
|
|
3949
|
+
* Hook allowing server database providers to substitute pre-decryption (ciphertext-preserving)
|
|
3950
|
+
* rows before storing view results in {@link LocalCacheManager} / Redis, ensuring decrypted
|
|
3951
|
+
* plaintext secrets are never persisted in local or shared caches.
|
|
3952
|
+
*
|
|
3953
|
+
* Client-side providers inherit this default identity implementation.
|
|
3954
|
+
*/
|
|
3955
|
+
PrepareRowsForCacheStorage(rows, _entityInfo) {
|
|
3956
|
+
return rows;
|
|
3957
|
+
}
|
|
3958
|
+
/**
|
|
3959
|
+
* Hook allowing server database providers to decrypt ciphertext-at-rest rows retrieved from
|
|
3960
|
+
* {@link LocalCacheManager} for in-process callers (or selectively preserve restricted
|
|
3961
|
+
* ciphertext when `preserveRestrictedApiCiphertext` is `true`).
|
|
3962
|
+
*
|
|
3963
|
+
* Client-side providers inherit this default identity implementation.
|
|
3964
|
+
*/
|
|
3965
|
+
async MaterializeCachedRowsForCaller(rows, _entityInfo, _contextUser, _preserveRestrictedApiCiphertext) {
|
|
3966
|
+
return rows;
|
|
3967
|
+
}
|
|
3765
3968
|
async PostRunView(result, params, preResult, contextUser) {
|
|
3766
3969
|
// Store in local cache BEFORE entity transformation — the cache needs
|
|
3767
3970
|
// plain JSON-serializable objects. BaseEntity objects contain RxJS Subjects
|
|
3768
3971
|
// with circular subscriber references that break JSON.stringify.
|
|
3769
3972
|
// On cache read, TransformSimpleObjectToEntityObject is called to restore
|
|
3770
3973
|
// entity objects when ResultType === 'entity_object'.
|
|
3771
|
-
//
|
|
3772
|
-
//
|
|
3773
|
-
|
|
3774
|
-
|
|
3974
|
+
// preResult.populateCache records PreRunView's widening decision so narrow Fields
|
|
3975
|
+
// queries (without CacheLocal: true) never poison the Fields-agnostic superset slot.
|
|
3976
|
+
const resolvedEntity = this.resolveEntityForRunViewParams(params);
|
|
3977
|
+
const shouldPopulateSlot = preResult.populateCache ?? this.shouldWidenAndPopulateCache(params);
|
|
3978
|
+
if (shouldPopulateSlot && result.Success && preResult.fingerprint && LocalCacheManager.Instance.IsInitialized) {
|
|
3775
3979
|
const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
|
|
3776
|
-
await LocalCacheManager.Instance.SetRunViewResult(preResult.fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
|
|
3980
|
+
await LocalCacheManager.Instance.SetRunViewResult(preResult.fingerprint, params, this.PrepareRowsForCacheStorage(result.Results, resolvedEntity), maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
|
|
3777
3981
|
}
|
|
3778
3982
|
else if (this.shouldAutoCache(params, result, contextUser)) {
|
|
3779
3983
|
// Server-side auto-cache: small, unfiltered, unsorted results are
|
|
@@ -3781,7 +3985,7 @@ export class ProviderBase {
|
|
|
3781
3985
|
// safe for in-place upsert on entity changes (no filter to evaluate).
|
|
3782
3986
|
const fingerprint = preResult.fingerprint || this.ComputeRunViewCacheFingerprint(params, contextUser);
|
|
3783
3987
|
const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
|
|
3784
|
-
await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
|
|
3988
|
+
await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, this.PrepareRowsForCacheStorage(result.Results, resolvedEntity), maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
|
|
3785
3989
|
LogStatusEx({ message: ` 📦 [Auto-Cache] RunView "${params.EntityName || params.ViewName || 'unknown'}" — ${result.Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
|
|
3786
3990
|
}
|
|
3787
3991
|
// Project cache-miss DB results back down to the caller's originally requested
|
|
@@ -3857,17 +4061,20 @@ export class ProviderBase {
|
|
|
3857
4061
|
// was disabled for them but auto-cache/OnDataChanged may still need it).
|
|
3858
4062
|
const fingerprint = preResult.fingerprintMap?.get(i)
|
|
3859
4063
|
?? this.ComputeRunViewCacheFingerprint(params[i], contextUser);
|
|
3860
|
-
// CRITICAL: must
|
|
4064
|
+
// CRITICAL: must reflect the SAME widening predicate PreRunViews used to decide
|
|
3861
4065
|
// whether to widen Fields. Writing a non-widened (narrow or keyset-paged)
|
|
3862
|
-
// result here poisons the Fields-agnostic superset slot
|
|
3863
|
-
|
|
3864
|
-
|
|
4066
|
+
// result here poisons the Fields-agnostic superset slot.
|
|
4067
|
+
const itemEntity = this.resolveEntityForRunViewParams(params[i]);
|
|
4068
|
+
const shouldPopulateBatchSlot = preResult.populateCacheSet
|
|
4069
|
+
? preResult.populateCacheSet.has(i)
|
|
4070
|
+
: this.shouldWidenAndPopulateCache(params[i]);
|
|
4071
|
+
if (shouldPopulateBatchSlot && results[i].Success && LocalCacheManager.Instance.IsInitialized) {
|
|
3865
4072
|
const maxUpdatedAt = this.extractMaxUpdatedAt(results[i].Results);
|
|
3866
|
-
cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount, this));
|
|
4073
|
+
cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], this.PrepareRowsForCacheStorage(results[i].Results, itemEntity), maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount, this));
|
|
3867
4074
|
}
|
|
3868
4075
|
else if (this.shouldAutoCache(params[i], results[i], contextUser)) {
|
|
3869
4076
|
const maxUpdatedAt = this.extractMaxUpdatedAt(results[i].Results);
|
|
3870
|
-
cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount, this));
|
|
4077
|
+
cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], this.PrepareRowsForCacheStorage(results[i].Results, itemEntity), maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount, this));
|
|
3871
4078
|
LogStatusEx({ message: ` 📦 [Auto-Cache] RunViews "${params[i].EntityName || params[i].ViewName || 'unknown'}" — ${results[i].Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
|
|
3872
4079
|
}
|
|
3873
4080
|
// Register OnDataChanged callback if provided
|
|
@@ -3907,17 +4114,8 @@ export class ProviderBase {
|
|
|
3907
4114
|
results[i].Results = this.ApplyFieldSecurityProjection(results[i].Results, params[i], contextUser);
|
|
3908
4115
|
results[i].Results = this.ApplyRecordChangeFieldSecurityProjection(results[i].Results, params[i], contextUser);
|
|
3909
4116
|
}
|
|
3910
|
-
// Transform results to entity objects AFTER caching plain objects.
|
|
3911
|
-
|
|
3912
|
-
const transformPromises = [];
|
|
3913
|
-
for (let i = 0; i < results.length; i++) {
|
|
3914
|
-
const cacheInfo = preResult.cacheStatusMap?.get(i);
|
|
3915
|
-
if (cacheInfo?.status === 'hit') {
|
|
3916
|
-
continue;
|
|
3917
|
-
}
|
|
3918
|
-
transformPromises.push(this.TransformSimpleObjectToEntityObject(params[i], results[i], contextUser));
|
|
3919
|
-
}
|
|
3920
|
-
await Promise.all(transformPromises);
|
|
4117
|
+
// Transform results (both cache hits and DB misses) to entity objects AFTER caching plain objects.
|
|
4118
|
+
await this.TransformBatchRunViewResults(params, results, undefined, preResult.callerFieldsMap, contextUser);
|
|
3921
4119
|
// Run registered PostRunView hooks on each result in the batch
|
|
3922
4120
|
for (let i = 0; i < results.length; i++) {
|
|
3923
4121
|
results[i] = await this.RunPostRunViewHooks(params[i], results[i], contextUser);
|
|
@@ -4138,6 +4336,8 @@ export class ProviderBase {
|
|
|
4138
4336
|
// main-path variants of those bugs.
|
|
4139
4337
|
if (!this.runViewCacheEligible(params))
|
|
4140
4338
|
return false;
|
|
4339
|
+
if (this.hasNarrowFieldsOverride(params))
|
|
4340
|
+
return false;
|
|
4141
4341
|
if (!LocalCacheManager.Instance.IsInitialized)
|
|
4142
4342
|
return false;
|
|
4143
4343
|
if (!result.Success)
|
|
@@ -4336,12 +4536,7 @@ export class ProviderBase {
|
|
|
4336
4536
|
*/
|
|
4337
4537
|
async PostProcessRunViews(results, params, contextUser) {
|
|
4338
4538
|
if (params && params.length > 0) {
|
|
4339
|
-
|
|
4340
|
-
for (let i = 0; i < results.length; i++) {
|
|
4341
|
-
promises.push(this.TransformSimpleObjectToEntityObject(params[i], results[i], contextUser));
|
|
4342
|
-
}
|
|
4343
|
-
// await the promises for all transformations
|
|
4344
|
-
await Promise.all(promises);
|
|
4539
|
+
await this.TransformBatchRunViewResults(params, results, undefined, undefined, contextUser);
|
|
4345
4540
|
// End telemetry tracking for batch operation
|
|
4346
4541
|
const eventId = params[0]._telemetryBatchEventId;
|
|
4347
4542
|
if (eventId) {
|
|
@@ -4350,18 +4545,113 @@ export class ProviderBase {
|
|
|
4350
4545
|
}
|
|
4351
4546
|
}
|
|
4352
4547
|
}
|
|
4548
|
+
/**
|
|
4549
|
+
* Two-phase batch entity transformation for `RunViews`:
|
|
4550
|
+
* 1. Transforms all non-IS-A-parent views in parallel first so lookup tables (`SubtypeSelector`)
|
|
4551
|
+
* and IS-A child tables in the same batch are hydrated before parent views run.
|
|
4552
|
+
* 2. Registers all unfiltered full-entity views from this batch on `BaseEntity` for the duration
|
|
4553
|
+
* of Phase 2, then transforms IS-A parent views so they can resolve child records and
|
|
4554
|
+
* `SubtypeSelector` targets directly from memory with 0 extra queries.
|
|
4555
|
+
*/
|
|
4556
|
+
async TransformBatchRunViewResults(params, results, skipTransformIndices, callerFieldsMap, contextUser) {
|
|
4557
|
+
if (!params || params.length === 0) {
|
|
4558
|
+
return;
|
|
4559
|
+
}
|
|
4560
|
+
const nonParentIndices = [];
|
|
4561
|
+
const isaParentIndices = [];
|
|
4562
|
+
for (let i = 0; i < results.length; i++) {
|
|
4563
|
+
if (skipTransformIndices?.has(i)) {
|
|
4564
|
+
continue;
|
|
4565
|
+
}
|
|
4566
|
+
const firstRow = results[i]?.Results?.[0];
|
|
4567
|
+
if (firstRow instanceof BaseEntity) {
|
|
4568
|
+
continue;
|
|
4569
|
+
}
|
|
4570
|
+
const param = params[i];
|
|
4571
|
+
if (!param) {
|
|
4572
|
+
continue;
|
|
4573
|
+
}
|
|
4574
|
+
const entity = param.EntityName ? this.EntityByName(param.EntityName) : undefined;
|
|
4575
|
+
if (param.ResultType === 'entity_object' && entity?.IsParentType && entity.ChildEntities.length > 0) {
|
|
4576
|
+
isaParentIndices.push(i);
|
|
4577
|
+
}
|
|
4578
|
+
else {
|
|
4579
|
+
nonParentIndices.push(i);
|
|
4580
|
+
}
|
|
4581
|
+
}
|
|
4582
|
+
if (nonParentIndices.length > 0) {
|
|
4583
|
+
await Promise.all(nonParentIndices.map(i => this.TransformSimpleObjectToEntityObject(params[i], results[i], contextUser)));
|
|
4584
|
+
}
|
|
4585
|
+
if (isaParentIndices.length === 0) {
|
|
4586
|
+
return;
|
|
4587
|
+
}
|
|
4588
|
+
const batchUnfilteredCache = new Map();
|
|
4589
|
+
for (let i = 0; i < results.length; i++) {
|
|
4590
|
+
const param = params[i];
|
|
4591
|
+
if (param && this.isUnfilteredFullEntityBatchView(param, results[i], callerFieldsMap, i)) {
|
|
4592
|
+
const key = param.EntityName.trim().toLowerCase();
|
|
4593
|
+
if (!batchUnfilteredCache.has(key)) {
|
|
4594
|
+
batchUnfilteredCache.set(key, results[i].Results);
|
|
4595
|
+
}
|
|
4596
|
+
}
|
|
4597
|
+
}
|
|
4598
|
+
const batchCache = batchUnfilteredCache.size > 0 ? batchUnfilteredCache : undefined;
|
|
4599
|
+
await Promise.all(isaParentIndices.map(i => this.TransformSimpleObjectToEntityObject(params[i], results[i], contextUser, batchCache)));
|
|
4600
|
+
}
|
|
4601
|
+
isUnfilteredFullEntityBatchView(param, result, callerFieldsMap, viewIndex) {
|
|
4602
|
+
if (!param.EntityName || !result?.Success || !Array.isArray(result.Results)) {
|
|
4603
|
+
return false;
|
|
4604
|
+
}
|
|
4605
|
+
if (param.ResultType === 'count_only') {
|
|
4606
|
+
return false;
|
|
4607
|
+
}
|
|
4608
|
+
const filter = typeof param.ExtraFilter === 'string' ? param.ExtraFilter.trim() : '';
|
|
4609
|
+
const search = typeof param.UserSearchString === 'string' ? param.UserSearchString.trim() : '';
|
|
4610
|
+
if (filter.length > 0 || search.length > 0) {
|
|
4611
|
+
return false;
|
|
4612
|
+
}
|
|
4613
|
+
if (param.ViewID || param.ViewName || param.ViewEntity) {
|
|
4614
|
+
return false;
|
|
4615
|
+
}
|
|
4616
|
+
const alternateBaseView = param.AlternateBaseView;
|
|
4617
|
+
if (alternateBaseView && alternateBaseView.trim().length > 0) {
|
|
4618
|
+
return false;
|
|
4619
|
+
}
|
|
4620
|
+
if (param.MaxRows || param.StartRow || param.AfterKey) {
|
|
4621
|
+
return false;
|
|
4622
|
+
}
|
|
4623
|
+
if (typeof result.TotalRowCount === 'number' &&
|
|
4624
|
+
result.TotalRowCount > result.Results.length) {
|
|
4625
|
+
return false;
|
|
4626
|
+
}
|
|
4627
|
+
if (param.IgnoreMaxRows !== true) {
|
|
4628
|
+
const entity = this.EntityByName(param.EntityName);
|
|
4629
|
+
const maxRowsCap = entity?.UserViewMaxRows ?? 0;
|
|
4630
|
+
if (maxRowsCap > 0 && result.Results.length >= maxRowsCap) {
|
|
4631
|
+
return false;
|
|
4632
|
+
}
|
|
4633
|
+
}
|
|
4634
|
+
if (viewIndex !== undefined && callerFieldsMap?.has(viewIndex)) {
|
|
4635
|
+
return false;
|
|
4636
|
+
}
|
|
4637
|
+
if (param.Fields && param.Fields.length > 0 && !this.isFullCoverageFieldList(param)) {
|
|
4638
|
+
return false;
|
|
4639
|
+
}
|
|
4640
|
+
return true;
|
|
4641
|
+
}
|
|
4353
4642
|
/**
|
|
4354
4643
|
* Transforms the result set from simple objects to entity objects if needed.
|
|
4355
4644
|
* @param param - The RunViewParams used for the request
|
|
4356
4645
|
* @param result - The RunViewResult returned from the request
|
|
4357
4646
|
* @param contextUser - The user context for permissions
|
|
4647
|
+
* @param batchUnfilteredCache - Optional map of unfiltered full-entity sibling views from the same RunViews call
|
|
4358
4648
|
*/
|
|
4359
|
-
async TransformSimpleObjectToEntityObject(param, result, contextUser) {
|
|
4649
|
+
async TransformSimpleObjectToEntityObject(param, result, contextUser, batchUnfilteredCache) {
|
|
4360
4650
|
// Mutually exclusive with the entity branch below: entity objects get real types from
|
|
4361
4651
|
// BaseEntity's Get/Set conversion, so normalization applies only to non-entity results.
|
|
4362
4652
|
this.NormalizeSimpleRowTypes(param, result);
|
|
4363
4653
|
if (param.ResultType === 'entity_object' && result && result.Success && result.Results?.length > 0) {
|
|
4364
|
-
result.Results = await TransformSimpleObjectToEntityObject(this, param.EntityName, result.Results, contextUser);
|
|
4654
|
+
result.Results = await TransformSimpleObjectToEntityObject(this, param.EntityName, result.Results, contextUser, batchUnfilteredCache);
|
|
4365
4655
|
// Opt-in batched child loading: ONE query per named collection across the whole result
|
|
4366
4656
|
// set, not one per row. Companion eager loading is deliberately kept out of
|
|
4367
4657
|
// LoadFromData() (which is the per-row path above) precisely so that populating children
|
|
@@ -4453,6 +4743,9 @@ export class ProviderBase {
|
|
|
4453
4743
|
* Resolves the {@link EntityInfo} normalization should read field types from, using only
|
|
4454
4744
|
* synchronously available information on the params.
|
|
4455
4745
|
*/
|
|
4746
|
+
resolveEntityForRunViewParams(param) {
|
|
4747
|
+
return this.resolveEntityForNormalization(param);
|
|
4748
|
+
}
|
|
4456
4749
|
resolveEntityForNormalization(param) {
|
|
4457
4750
|
if (param.EntityName) {
|
|
4458
4751
|
return this.EntityByName(param.EntityName);
|
|
@@ -4786,6 +5079,9 @@ export class ProviderBase {
|
|
|
4786
5079
|
LogWarning(`[Metadata Cache] Row count mismatch when adopting server metadata for "${remoteEntry.Type}": ` +
|
|
4787
5080
|
`received ${receivedRowCount} rows but remote status reported ${remoteEntry.RowCount} rows. ` +
|
|
4788
5081
|
`Not advancing local timestamp for "${remoteEntry.Type}" so the next check self-heals.`, 'Metadata');
|
|
5082
|
+
if (LocalCacheManager.Instance.IsInitialized && remoteEntry.Type) {
|
|
5083
|
+
LocalCacheManager.Instance.InvalidateEntityCaches(remoteEntry.Type).catch(() => { });
|
|
5084
|
+
}
|
|
4789
5085
|
const priorEntry = prevLocal.find(m => norm(m.Type) === norm(remoteEntry.Type));
|
|
4790
5086
|
if (priorEntry) {
|
|
4791
5087
|
reconciled.push({
|
|
@@ -4813,6 +5109,7 @@ export class ProviderBase {
|
|
|
4813
5109
|
if (!hasMismatch) {
|
|
4814
5110
|
return remote;
|
|
4815
5111
|
}
|
|
5112
|
+
this.ClearDatasetCache(ProviderBase._mjMetadataDatasetName).catch(() => { });
|
|
4816
5113
|
const remoteRollup = remote.find(r => norm(r.Type) === rollup);
|
|
4817
5114
|
if (remoteRollup) {
|
|
4818
5115
|
const priorRollup = prevLocal.find(m => norm(m.Type) === rollup);
|
|
@@ -5155,6 +5452,13 @@ export class ProviderBase {
|
|
|
5155
5452
|
return false; // if we fail to copy the metadata, we will return false
|
|
5156
5453
|
}
|
|
5157
5454
|
}
|
|
5455
|
+
/**
|
|
5456
|
+
* Re-adopts the immutable read-only metadata catalog shell and lookup maps from
|
|
5457
|
+
* `Metadata.Provider` after the global provider completes a metadata refresh.
|
|
5458
|
+
*/
|
|
5459
|
+
SyncMetadataFromGlobalProvider() {
|
|
5460
|
+
return this.CopyMetadataFromGlobalProvider();
|
|
5461
|
+
}
|
|
5158
5462
|
/**
|
|
5159
5463
|
* Builds dataset filters based on the provider configuration.
|
|
5160
5464
|
* Ensures MJ Core schema is always included and never excluded.
|
|
@@ -5191,6 +5495,40 @@ export class ProviderBase {
|
|
|
5191
5495
|
return f;
|
|
5192
5496
|
}
|
|
5193
5497
|
static { this._mjMetadataDatasetName = 'MJ_Metadata'; }
|
|
5498
|
+
/**
|
|
5499
|
+
* Checks whether any item in a freshly fetched `MJ_Metadata` dataset has a row count that
|
|
5500
|
+
* disagrees with `_latestRemoteMetadataTimestamps` (when remote timestamps are populated).
|
|
5501
|
+
* Used to prevent `GetAllMetadata()` from caching a dataset snapshot into `DatasetCache`
|
|
5502
|
+
* whose row counts are already stale relative to remote status.
|
|
5503
|
+
*/
|
|
5504
|
+
hasDatasetRemoteRowCountMismatch(dataset) {
|
|
5505
|
+
const remote = this._latestRemoteMetadataTimestamps;
|
|
5506
|
+
if (!remote || remote.length === 0 || !Array.isArray(dataset?.Results)) {
|
|
5507
|
+
return false;
|
|
5508
|
+
}
|
|
5509
|
+
const norm = (v) => (v ?? '').trim().toLowerCase();
|
|
5510
|
+
const rollup = norm(ProviderBase._allMetadataTypeName);
|
|
5511
|
+
const nonRollupRemote = remote.filter(r => norm(r.Type) !== rollup);
|
|
5512
|
+
const remoteByID = IndexByUUID(nonRollupRemote.filter(r => Boolean(r.ID)), r => r.ID);
|
|
5513
|
+
const remoteByType = new Map();
|
|
5514
|
+
for (const r of nonRollupRemote) {
|
|
5515
|
+
if (r.Type) {
|
|
5516
|
+
remoteByType.set(norm(r.Type), r);
|
|
5517
|
+
}
|
|
5518
|
+
}
|
|
5519
|
+
for (const item of dataset.Results) {
|
|
5520
|
+
if (!Array.isArray(item.Results)) {
|
|
5521
|
+
continue;
|
|
5522
|
+
}
|
|
5523
|
+
const remoteEntry = (item.EntityID ? remoteByID.Get(item.EntityID) : undefined)
|
|
5524
|
+
?? remoteByType.get(norm(item.EntityName))
|
|
5525
|
+
?? remoteByType.get(norm(item.Code));
|
|
5526
|
+
if (remoteEntry && remoteEntry.RowCount !== undefined && item.Results.length !== remoteEntry.RowCount) {
|
|
5527
|
+
return true;
|
|
5528
|
+
}
|
|
5529
|
+
}
|
|
5530
|
+
return false;
|
|
5531
|
+
}
|
|
5194
5532
|
/**
|
|
5195
5533
|
* Retrieves all metadata from the server and constructs typed instances.
|
|
5196
5534
|
* Uses the MJ_Metadata dataset for efficient bulk loading.
|
|
@@ -5201,6 +5539,7 @@ export class ProviderBase {
|
|
|
5201
5539
|
// we are now using datasets instead of the custom metadata to GraphQL to simplify GraphQL's work as it was very slow preivously
|
|
5202
5540
|
// NOTE: Schema filters (IncludeSchemas/ExcludeSchemas) are for CodeGen only, not for runtime
|
|
5203
5541
|
// metadata loading. We always load all schemas — there are no sys/staging entities in metadata anyway.
|
|
5542
|
+
const mutationSeqAtStart = this._metadataMemberMutationSeq;
|
|
5204
5543
|
// Get the dataset and cache it for anyone else who wants to use it
|
|
5205
5544
|
// When forceRefresh is true (from a hard Refresh() call), bypass LocalCacheManager
|
|
5206
5545
|
const d = await this.GetDatasetByName(ProviderBase._mjMetadataDatasetName, null, this.CurrentUser, providerToUse, forceRefresh);
|
|
@@ -5215,12 +5554,24 @@ export class ProviderBase {
|
|
|
5215
5554
|
this.recordMetadataLoadFailure(new Error(`GetAllMetadata() - the ${ProviderBase._mjMetadataDatasetName} dataset returned no entities; keeping the metadata already loaded`));
|
|
5216
5555
|
}
|
|
5217
5556
|
else if (d && d.Success) {
|
|
5218
|
-
// cache the dataset for anyone who wants to use it
|
|
5219
|
-
await this.CacheDataset(ProviderBase._mjMetadataDatasetName, null, d);
|
|
5220
5557
|
// Record which entities this metadata is built from, so a write to any of them
|
|
5221
5558
|
// schedules a debounced refresh of this provider's metadata (see
|
|
5222
5559
|
// registerMetadataDatasetMembership — membership is the dataset definition, not a list).
|
|
5223
5560
|
this.registerMetadataDatasetMembership(d);
|
|
5561
|
+
// Only cache the dataset in DatasetCache when no metadata-member mutation occurred
|
|
5562
|
+
// while GetDatasetByName was in flight, no follow-up reload is already queued, and
|
|
5563
|
+
// item row counts match _latestRemoteMetadataTimestamps. Otherwise a slow background
|
|
5564
|
+
// GetAllMetadata() that started before a delete can overwrite DatasetCache with
|
|
5565
|
+
// pre-delete rows right after the delete has already completed.
|
|
5566
|
+
const mutatedDuringLoad = this._metadataReloadQueued ||
|
|
5567
|
+
this._metadataMemberMutationSeq !== mutationSeqAtStart ||
|
|
5568
|
+
this.hasDatasetRemoteRowCountMismatch(d);
|
|
5569
|
+
if (!mutatedDuringLoad) {
|
|
5570
|
+
await this.CacheDataset(ProviderBase._mjMetadataDatasetName, null, d);
|
|
5571
|
+
}
|
|
5572
|
+
else {
|
|
5573
|
+
await this.ClearDatasetCache(ProviderBase._mjMetadataDatasetName);
|
|
5574
|
+
}
|
|
5224
5575
|
// got the results, let's build our response in the format we need
|
|
5225
5576
|
const simpleMetadata = {};
|
|
5226
5577
|
for (let r of d.Results) {
|
|
@@ -5584,6 +5935,27 @@ export class ProviderBase {
|
|
|
5584
5935
|
* RefreshIfNeeded load outside Config's reload single-flight). For the same reason it gets
|
|
5585
5936
|
* `false`, not the owner's rejection, when the owner's check throws.
|
|
5586
5937
|
* @returns True if refresh is needed, false otherwise
|
|
5938
|
+
*
|
|
5939
|
+
* @remarks
|
|
5940
|
+
* This is a **freshness question, not a load**, so it never throws on an unreachable
|
|
5941
|
+
* server. {@link ReadRemoteMetadataTimestamps} reports whether the server was reached at
|
|
5942
|
+
* all, and when it was NOT the honest answer here depends on whether we hold anything to
|
|
5943
|
+
* serve:
|
|
5944
|
+
*
|
|
5945
|
+
* - **We hold local metadata** → answer `false` ("do not tear this down"). The cached
|
|
5946
|
+
* snapshot may be a few seconds stale; a rejected status query is no reason to throw
|
|
5947
|
+
* it away, and every caller of this method treats `false` as "carry on with what you
|
|
5948
|
+
* have".
|
|
5949
|
+
* - **We hold nothing** → answer `true`, so the caller attempts a real load against the
|
|
5950
|
+
* actual metadata fetch. That load logs its own failure and leaves the graph empty;
|
|
5951
|
+
* `SetupGraphQLClient` turns an empty graph into an error carrying it, while a caller
|
|
5952
|
+
* that only checks {@link Config}'s return value sees `true`.
|
|
5953
|
+
*
|
|
5954
|
+
* Before this, an unreachable status query propagated an exception out of
|
|
5955
|
+
* {@link Config} and the Explorer's single boot catch turned it into a non-retryable
|
|
5956
|
+
* "System Error" — the whole app dead over a freshness check it did not need an answer
|
|
5957
|
+
* to. The two warm callers ({@link backgroundValidateAndRefresh},
|
|
5958
|
+
* {@link preValidateAndRefresh}) already caught it; {@link Config} did not.
|
|
5587
5959
|
*/
|
|
5588
5960
|
async CheckToSeeIfRefreshNeeded(providerToUse, bypassMinCheckInterval) {
|
|
5589
5961
|
if (!this.AllowRefresh)
|
|
@@ -5622,10 +5994,29 @@ export class ProviderBase {
|
|
|
5622
5994
|
*/
|
|
5623
5995
|
async runRefreshCheck(providerToUse) {
|
|
5624
5996
|
const gotTimestamps = await this.RefreshRemoteMetadataTimestamps(providerToUse);
|
|
5997
|
+
// Read the reachability flag in the SAME synchronous turn the await above resumed in —
|
|
5998
|
+
// before the next await can let a concurrent check overwrite it.
|
|
5999
|
+
const serverReached = this._lastRemoteTimestampReadReached;
|
|
5625
6000
|
if (gotTimestamps) {
|
|
5626
6001
|
this._lastRefreshCheckAt = Date.now();
|
|
5627
6002
|
}
|
|
5628
6003
|
await this.syncLocalMetadataFromStorage();
|
|
6004
|
+
if (!gotTimestamps && !serverReached) {
|
|
6005
|
+
// Could not ask the server. `LocalMetadataObsolete()` would answer `true` here
|
|
6006
|
+
// purely because the remote timestamp list is empty — a "yes, reload everything"
|
|
6007
|
+
// derived from the absence of an answer rather than from any comparison.
|
|
6008
|
+
const haveLocal = (this._localMetadata?.AllEntities?.length ?? 0) > 0;
|
|
6009
|
+
if (haveLocal) {
|
|
6010
|
+
LogError(`[RefreshCheck] Could not read metadata timestamps from the server — serving the ` +
|
|
6011
|
+
`cached metadata snapshot (${this._localMetadata.AllEntities.length} entities) and ` +
|
|
6012
|
+
`deferring the staleness check to the next call.`);
|
|
6013
|
+
return false;
|
|
6014
|
+
}
|
|
6015
|
+
LogError(`[RefreshCheck] Could not read metadata timestamps from the server and there is no ` +
|
|
6016
|
+
`cached snapshot to fall back on — reporting a refresh as needed so the caller loads ` +
|
|
6017
|
+
`metadata; that load reports its own failure.`);
|
|
6018
|
+
return true;
|
|
6019
|
+
}
|
|
5629
6020
|
return this.LocalMetadataObsolete();
|
|
5630
6021
|
}
|
|
5631
6022
|
/**
|
|
@@ -5817,8 +6208,30 @@ export class ProviderBase {
|
|
|
5817
6208
|
if (cachedDataset && cachedDateStr) {
|
|
5818
6209
|
// We have a candidate cache entry — confirm freshness with the server.
|
|
5819
6210
|
const localDate = new Date(cachedDateStr);
|
|
5820
|
-
|
|
5821
|
-
|
|
6211
|
+
let status;
|
|
6212
|
+
try {
|
|
6213
|
+
status = await this.GetDatasetStatusByName(datasetName, itemFilters);
|
|
6214
|
+
}
|
|
6215
|
+
catch (e) {
|
|
6216
|
+
// Only the TRANSPORT failing earns the fallback. A server that answered "no"
|
|
6217
|
+
// (an identity without the scope, a validation rejection) gave a definite
|
|
6218
|
+
// answer that waiting will not change, and the cache would hand the caller
|
|
6219
|
+
// exactly what the server just declined to — so that propagates as it always did.
|
|
6220
|
+
if (!IsTransportError(e)) {
|
|
6221
|
+
throw e;
|
|
6222
|
+
}
|
|
6223
|
+
// We are HOLDING a complete cached dataset and only asked whether it is still
|
|
6224
|
+
// current. A failed freshness question is not grounds for throwing the answer
|
|
6225
|
+
// away and propagating: the line below re-fetches the whole dataset from the
|
|
6226
|
+
// same unreachable server, so the alternative to serving this cache is an
|
|
6227
|
+
// exception, not fresher data. Serve it and say so.
|
|
6228
|
+
LogError(`GetAndCacheDatasetByName('${datasetName}'): the freshness check failed — ` +
|
|
6229
|
+
`${e instanceof Error ? e.message : String(e)}. Serving the cached dataset ` +
|
|
6230
|
+
`(cached ${cachedDateStr}), which may be stale.`);
|
|
6231
|
+
return cachedDataset;
|
|
6232
|
+
}
|
|
6233
|
+
const serverDate = this.validDatasetDate(status?.LatestUpdateDate);
|
|
6234
|
+
if (status?.Success && serverDate && localDate.getTime() >= serverDate.getTime()) {
|
|
5822
6235
|
// Timestamps suggest cache is fresh; verify per-entity row counts to
|
|
5823
6236
|
// catch deleted rows (timestamp comparison alone misses pure deletes).
|
|
5824
6237
|
// Shared with IsDatasetCacheUpToDate so the two cannot drift again.
|
|
@@ -5827,9 +6240,11 @@ export class ProviderBase {
|
|
|
5827
6240
|
}
|
|
5828
6241
|
}
|
|
5829
6242
|
}
|
|
5830
|
-
// Cold path or stale: fetch from server and cache.
|
|
6243
|
+
// Cold path or stale: fetch from server and cache only when the load succeeded.
|
|
5831
6244
|
const dataset = await this.GetDatasetByName(datasetName, itemFilters, contextUser, providerToUse);
|
|
5832
|
-
|
|
6245
|
+
if (dataset?.Success) {
|
|
6246
|
+
await this.CacheDataset(datasetName, itemFilters, dataset);
|
|
6247
|
+
}
|
|
5833
6248
|
return dataset;
|
|
5834
6249
|
}
|
|
5835
6250
|
/**
|
|
@@ -5860,8 +6275,9 @@ export class ProviderBase {
|
|
|
5860
6275
|
if (localDate) {
|
|
5861
6276
|
// we have a local cached timestamp, so compare it to the server timestamp
|
|
5862
6277
|
const status = await this.GetDatasetStatusByName(datasetName, itemFilters);
|
|
5863
|
-
|
|
5864
|
-
|
|
6278
|
+
const serverDate = this.validDatasetDate(status?.LatestUpdateDate);
|
|
6279
|
+
if (status?.Success && serverDate) {
|
|
6280
|
+
const serverTimestamp = serverDate.getTime();
|
|
5865
6281
|
if (localDate.getTime() >= serverTimestamp) {
|
|
5866
6282
|
// this situation means our local cache timestamp is >= the server timestamp, so we're most likely up to date
|
|
5867
6283
|
// 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
|
|
@@ -5884,6 +6300,17 @@ export class ProviderBase {
|
|
|
5884
6300
|
return false;
|
|
5885
6301
|
}
|
|
5886
6302
|
}
|
|
6303
|
+
/**
|
|
6304
|
+
* Normalizes a dataset `LatestUpdateDate` to a valid `Date`, or `null` when absent or invalid
|
|
6305
|
+
* (for example, when a dataset or dataset-status call fails and returns `LatestUpdateDate: null`).
|
|
6306
|
+
*/
|
|
6307
|
+
validDatasetDate(value) {
|
|
6308
|
+
if (value == null) {
|
|
6309
|
+
return null;
|
|
6310
|
+
}
|
|
6311
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
6312
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
6313
|
+
}
|
|
5887
6314
|
/**
|
|
5888
6315
|
* Compares the per-entity row counts of a cached dataset against the server's status.
|
|
5889
6316
|
*
|
|
@@ -5942,6 +6369,12 @@ export class ProviderBase {
|
|
|
5942
6369
|
* @param dataset
|
|
5943
6370
|
*/
|
|
5944
6371
|
async CacheDataset(datasetName, itemFilters, dataset) {
|
|
6372
|
+
const latestUpdateDate = this.validDatasetDate(dataset?.LatestUpdateDate);
|
|
6373
|
+
if (!dataset?.Success || !latestUpdateDate) {
|
|
6374
|
+
LogError(`CacheDataset("${datasetName}"): refusing to cache failed or undated dataset result ` +
|
|
6375
|
+
`(Success=${dataset?.Success}, Status=${dataset?.Status ?? 'unknown'})`);
|
|
6376
|
+
return;
|
|
6377
|
+
}
|
|
5945
6378
|
const ls = this.LocalStorageProvider;
|
|
5946
6379
|
if (ls) {
|
|
5947
6380
|
const key = this.GetDatasetCacheKey(datasetName, itemFilters);
|
|
@@ -5949,7 +6382,7 @@ export class ProviderBase {
|
|
|
5949
6382
|
await ls.SetItem(key, dataset, ProviderBase.DatasetCacheCategory, { TTLSeconds: ProviderBase.DatasetCacheTTLSeconds });
|
|
5950
6383
|
// Date is stored as ISO string for forward-compatibility across providers
|
|
5951
6384
|
// (Redis can't natively round-trip Date; localStorage requires string).
|
|
5952
|
-
await ls.SetItem(key + '_date',
|
|
6385
|
+
await ls.SetItem(key + '_date', latestUpdateDate.toISOString(), ProviderBase.DatasetCacheCategory, { TTLSeconds: ProviderBase.DatasetDateCacheTTLSeconds });
|
|
5953
6386
|
}
|
|
5954
6387
|
}
|
|
5955
6388
|
/**
|
|
@@ -6056,11 +6489,70 @@ export class ProviderBase {
|
|
|
6056
6489
|
}
|
|
6057
6490
|
/**
|
|
6058
6491
|
* Retrieves the latest metadata update timestamps from the server.
|
|
6059
|
-
* @returns Array of metadata update information
|
|
6492
|
+
* @returns Array of metadata update information, or `undefined` when the server could not
|
|
6493
|
+
* be asked (transport failure, timeout, gateway error) or answered unsuccessfully.
|
|
6494
|
+
*
|
|
6495
|
+
* @remarks
|
|
6496
|
+
* This is the ONE network call behind every staleness check, and it is a question about
|
|
6497
|
+
* freshness — never the metadata itself. A rejected status query therefore returns
|
|
6498
|
+
* `undefined` (an absent answer) rather than propagating: its callers already treat a
|
|
6499
|
+
* falsy result as "could not tell", while an exception out of here travelled unguarded
|
|
6500
|
+
* through {@link RefreshRemoteMetadataTimestamps} → {@link CheckToSeeIfRefreshNeeded} →
|
|
6501
|
+
* {@link Config} and killed the caller's whole bootstrap.
|
|
6502
|
+
*
|
|
6503
|
+
* The failure is logged at error level, not swallowed quietly: "we are running on a
|
|
6504
|
+
* cached snapshot because the server did not answer" is something an operator needs to
|
|
6505
|
+
* see in the console.
|
|
6060
6506
|
*/
|
|
6061
6507
|
async GetLatestMetadataUpdates(providerToUse) {
|
|
6062
|
-
|
|
6063
|
-
|
|
6508
|
+
return (await this.ReadRemoteMetadataTimestamps(providerToUse)).Timestamps;
|
|
6509
|
+
}
|
|
6510
|
+
/**
|
|
6511
|
+
* The metadata-status read, with **why** it failed preserved.
|
|
6512
|
+
*
|
|
6513
|
+
* Two failures look the same to {@link GetLatestMetadataUpdates} — both give `undefined` —
|
|
6514
|
+
* but they call for opposite responses, so callers that can act on the difference read it
|
|
6515
|
+
* here instead:
|
|
6516
|
+
*
|
|
6517
|
+
* - `Reached: false` — the request never got an answer (transport failure, timeout, 504).
|
|
6518
|
+
* Transient. Nothing can be concluded about freshness, and a full metadata load would hit
|
|
6519
|
+
* the same wall, so the right move is to keep serving what we have.
|
|
6520
|
+
* - `Reached: true` with no `Timestamps` — the server answered `Success: false`. A
|
|
6521
|
+
* *permanent* condition (the dataset is missing or misconfigured), not a network blip.
|
|
6522
|
+
* Degrading here would serve a stale cache forever; the historical behaviour of treating
|
|
6523
|
+
* it as "obsolete" and attempting the real load is correct, because `GetDatasetByName`
|
|
6524
|
+
* may well still work.
|
|
6525
|
+
* - A rejection that is NOT a transport failure — an authorization denial ("this API key
|
|
6526
|
+
* requires the dataset:read scope"), a validation rejection, a programming error — is
|
|
6527
|
+
* the server (or our own code) answering, not the wire failing. It propagates exactly
|
|
6528
|
+
* as it did before this method existed. {@link IsTransportError} draws the line. The
|
|
6529
|
+
* integration tier's FLS fixture depends on this: it builds a reader whose scope rule
|
|
6530
|
+
* has not propagated yet and retries `Config()` until the denial stops — a denial
|
|
6531
|
+
* swallowed into `Reached: false` left that reader with an empty snapshot for good.
|
|
6532
|
+
*
|
|
6533
|
+
* Conflating these is how a broken status endpoint — or a refused identity — would become
|
|
6534
|
+
* permanently invisible.
|
|
6535
|
+
*/
|
|
6536
|
+
async ReadRemoteMetadataTimestamps(providerToUse) {
|
|
6537
|
+
let d;
|
|
6538
|
+
try {
|
|
6539
|
+
// No schema filters for metadata — see comment in GetAllMetadata
|
|
6540
|
+
d = await this.GetDatasetStatusByName(ProviderBase._mjMetadataDatasetName, null, this.CurrentUser, providerToUse);
|
|
6541
|
+
}
|
|
6542
|
+
catch (e) {
|
|
6543
|
+
if (!IsTransportError(e)) {
|
|
6544
|
+
// The server answered — with a refusal. Not ours to soften.
|
|
6545
|
+
throw e;
|
|
6546
|
+
}
|
|
6547
|
+
LogError(`ReadRemoteMetadataTimestamps: the metadata status query failed — ` +
|
|
6548
|
+
`${e instanceof Error ? e.message : String(e)}. Reporting the server as unreachable; ` +
|
|
6549
|
+
`callers fall back to the cached metadata snapshot.`);
|
|
6550
|
+
return { Reached: false, Timestamps: undefined };
|
|
6551
|
+
}
|
|
6552
|
+
return { Reached: true, Timestamps: this.mapDatasetStatusToTimestamps(d) };
|
|
6553
|
+
}
|
|
6554
|
+
/** Shape a dataset-status answer into the timestamp list, or `undefined` if it failed. */
|
|
6555
|
+
mapDatasetStatusToTimestamps(d) {
|
|
6064
6556
|
if (d && d.Success) {
|
|
6065
6557
|
const ret = d.EntityUpdateDates.map(e => {
|
|
6066
6558
|
return {
|
|
@@ -6079,6 +6571,7 @@ export class ProviderBase {
|
|
|
6079
6571
|
});
|
|
6080
6572
|
return ret;
|
|
6081
6573
|
}
|
|
6574
|
+
return undefined;
|
|
6082
6575
|
}
|
|
6083
6576
|
/**
|
|
6084
6577
|
* Refreshes the remote metadata timestamps from the server.
|
|
@@ -6086,9 +6579,14 @@ export class ProviderBase {
|
|
|
6086
6579
|
* @returns True if timestamps were successfully refreshed
|
|
6087
6580
|
*/
|
|
6088
6581
|
async RefreshRemoteMetadataTimestamps(providerToUse) {
|
|
6089
|
-
const
|
|
6090
|
-
|
|
6091
|
-
|
|
6582
|
+
const read = await this.ReadRemoteMetadataTimestamps(providerToUse);
|
|
6583
|
+
// Recorded rather than returned: this method's `boolean` contract is "did the timestamps
|
|
6584
|
+
// refresh", and widening it would change a public signature that callers outside this
|
|
6585
|
+
// class already consume. {@link CheckToSeeIfRefreshNeeded} reads the flag in the same
|
|
6586
|
+
// synchronous turn this call resolves in, so no interleaving call can overwrite it first.
|
|
6587
|
+
this._lastRemoteTimestampReadReached = read.Reached;
|
|
6588
|
+
if (read.Timestamps) {
|
|
6589
|
+
this._latestRemoteMetadataTimestamps = read.Timestamps;
|
|
6092
6590
|
return true;
|
|
6093
6591
|
}
|
|
6094
6592
|
else
|