@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.
Files changed (96) hide show
  1. package/dist/generic/EntityFieldRules.d.ts.map +1 -1
  2. package/dist/generic/EntityFieldRules.js +1 -0
  3. package/dist/generic/EntityFieldRules.js.map +1 -1
  4. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +32 -0
  5. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
  6. package/dist/generic/baseEngine.d.ts +165 -12
  7. package/dist/generic/baseEngine.d.ts.map +1 -1
  8. package/dist/generic/baseEngine.js +622 -98
  9. package/dist/generic/baseEngine.js.map +1 -1
  10. package/dist/generic/baseEngineRegistry.d.ts +10 -2
  11. package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
  12. package/dist/generic/baseEngineRegistry.js +33 -5
  13. package/dist/generic/baseEngineRegistry.js.map +1 -1
  14. package/dist/generic/baseEntity.d.ts +121 -6
  15. package/dist/generic/baseEntity.d.ts.map +1 -1
  16. package/dist/generic/baseEntity.js +1144 -64
  17. package/dist/generic/baseEntity.js.map +1 -1
  18. package/dist/generic/baseRemotableOperation.d.ts +95 -6
  19. package/dist/generic/baseRemotableOperation.d.ts.map +1 -1
  20. package/dist/generic/baseRemotableOperation.js +112 -8
  21. package/dist/generic/baseRemotableOperation.js.map +1 -1
  22. package/dist/generic/databaseProviderBase.d.ts +37 -0
  23. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  24. package/dist/generic/databaseProviderBase.js +262 -46
  25. package/dist/generic/databaseProviderBase.js.map +1 -1
  26. package/dist/generic/entityInfo.d.ts +25 -1
  27. package/dist/generic/entityInfo.d.ts.map +1 -1
  28. package/dist/generic/entityInfo.js +70 -12
  29. package/dist/generic/entityInfo.js.map +1 -1
  30. package/dist/generic/externalDataSourceReadRouter.d.ts +10 -0
  31. package/dist/generic/externalDataSourceReadRouter.d.ts.map +1 -1
  32. package/dist/generic/externalDataSourceReadRouter.js +12 -0
  33. package/dist/generic/externalDataSourceReadRouter.js.map +1 -1
  34. package/dist/generic/fieldSecuritySQLGate.d.ts +11 -2
  35. package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -1
  36. package/dist/generic/fieldSecuritySQLGate.js +6 -2
  37. package/dist/generic/fieldSecuritySQLGate.js.map +1 -1
  38. package/dist/generic/interfaces.d.ts +50 -1
  39. package/dist/generic/interfaces.d.ts.map +1 -1
  40. package/dist/generic/interfaces.js.map +1 -1
  41. package/dist/generic/listMembershipFilter.d.ts +5 -0
  42. package/dist/generic/listMembershipFilter.d.ts.map +1 -0
  43. package/dist/generic/listMembershipFilter.js +23 -0
  44. package/dist/generic/listMembershipFilter.js.map +1 -0
  45. package/dist/generic/localCacheManager.d.ts +69 -5
  46. package/dist/generic/localCacheManager.d.ts.map +1 -1
  47. package/dist/generic/localCacheManager.js +253 -18
  48. package/dist/generic/localCacheManager.js.map +1 -1
  49. package/dist/generic/logging.d.ts +105 -38
  50. package/dist/generic/logging.d.ts.map +1 -1
  51. package/dist/generic/logging.js +196 -5
  52. package/dist/generic/logging.js.map +1 -1
  53. package/dist/generic/providerBase.d.ts +198 -3
  54. package/dist/generic/providerBase.d.ts.map +1 -1
  55. package/dist/generic/providerBase.js +721 -223
  56. package/dist/generic/providerBase.js.map +1 -1
  57. package/dist/generic/relatedRecordCollection.d.ts +5 -0
  58. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  59. package/dist/generic/relatedRecordCollection.js +11 -2
  60. package/dist/generic/relatedRecordCollection.js.map +1 -1
  61. package/dist/generic/remoteOperationDispatch.d.ts.map +1 -1
  62. package/dist/generic/remoteOperationDispatch.js +14 -2
  63. package/dist/generic/remoteOperationDispatch.js.map +1 -1
  64. package/dist/generic/runQuerySQLFilterImplementations.d.ts +33 -6
  65. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  66. package/dist/generic/runQuerySQLFilterImplementations.js +81 -27
  67. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  68. package/dist/generic/sharedLoad.d.ts +42 -0
  69. package/dist/generic/sharedLoad.d.ts.map +1 -0
  70. package/dist/generic/sharedLoad.js +74 -0
  71. package/dist/generic/sharedLoad.js.map +1 -0
  72. package/dist/generic/telemetryManager.d.ts +31 -0
  73. package/dist/generic/telemetryManager.d.ts.map +1 -1
  74. package/dist/generic/telemetryManager.js +85 -9
  75. package/dist/generic/telemetryManager.js.map +1 -1
  76. package/dist/generic/transactionGroup.d.ts +19 -0
  77. package/dist/generic/transactionGroup.d.ts.map +1 -1
  78. package/dist/generic/transactionGroup.js +55 -7
  79. package/dist/generic/transactionGroup.js.map +1 -1
  80. package/dist/generic/transportError.d.ts +41 -0
  81. package/dist/generic/transportError.d.ts.map +1 -0
  82. package/dist/generic/transportError.js +167 -0
  83. package/dist/generic/transportError.js.map +1 -0
  84. package/dist/generic/util.d.ts +13 -1
  85. package/dist/generic/util.d.ts.map +1 -1
  86. package/dist/generic/util.js +53 -4
  87. package/dist/generic/util.js.map +1 -1
  88. package/dist/index.d.ts +3 -0
  89. package/dist/index.d.ts.map +1 -1
  90. package/dist/index.js +3 -0
  91. package/dist/index.js.map +1 -1
  92. package/dist/views/runView.d.ts +23 -10
  93. package/dist/views/runView.d.ts.map +1 -1
  94. package/dist/views/runView.js +5 -1
  95. package/dist/views/runView.js.map +1 -1
  96. 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
- // Stash resolved results for the linger window. Safety cap: under
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 runAdmittedViews(params, preResult, contextUser) {
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
- const entry = this._inflightViews.get(key);
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
- return params.some(p => p.SaveViewResults === true || p.BypassCache === true);
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 (cached && queryFingerprint) {
2284
- const serveFromSlot = () => ({
2285
- QueryID: cached.queryId ?? params.QueryID ?? '',
2286
- QueryName: params.QueryName ?? '',
2287
- Success: true,
2288
- // Transport boundary: `cached.results` is readonly (shared, deep-frozen cache
2289
- // rows) while the outbound Results is mutable — the runtime freeze is the
2290
- // enforcement. Same cast as the RunView hit paths above.
2291
- Results: cached.results,
2292
- RowCount: cached.results.length,
2293
- TotalRowCount: cached.rowCount ?? cached.results.length,
2294
- ExecutionTime: 0,
2295
- ErrorMessage: '',
2296
- CacheHit: true,
2297
- CacheKey: queryFingerprint
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 (contextUser) {
2316
- if (queryAuth.resolvable) {
2317
- // Metadata can answer — enforce it. `authorized` comes from the
2318
- // provider's STRONGEST available check (B45): the base enforces
2319
- // roles-only QueryInfo.UserCanRun; GenericDatabaseProvider's
2320
- // override enforces the full MJQueryEntityExtended.UserCanRun
2321
- // (roles + entity CanRead + recursive composition) — the identical
2322
- // check ValidateQueryForExecution applies on the miss path, so a
2323
- // hit is never easier to read than a miss.
2324
- if (queryAuth.authorized) {
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
- LogStatusEx({ message: `RunQuery cache: user '${contextUser.Email}' lacks run permission on '${queryAuth.queryName ?? params.QueryName ?? params.QueryID}' — falling through to authorized execution.`, verboseOnly: true });
2328
- }
2329
- else if (cached.warmedForUserID && UUIDsEqual(cached.warmedForUserID, contextUser.ID)) {
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
- 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 });
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
- // '' for a slot written without a hash: the server answers `stale` and returns
2367
- // its hash, so the rewrite below stamps the slot.
2368
- cacheStatus: { maxUpdatedAt: cached.maxUpdatedAt, rowCount: cached.rowCount, definitionHash: cached.definitionHash ?? '' }
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
- // We fetch every field this user's permission class may read, so the cache entry is a
3060
- // superset for that class: it satisfies any later query for the same entity+filter, by
3061
- // any user of the same class, regardless of field subset. Slots are keyed per class (the
3062
- // fls: segment), so no slot is shared between classes at different widths.
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
- let callerRequestedFields = params.Fields && params.Fields.length > 0
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
- if (entity && (willCache || widenForEntityObject)) {
3085
- params.Fields = this.ComputeRunViewFetchFields(entity, params);
3086
- // Platform contract: explicit Fields always include the primary key(s) —
3087
- // project back down to requested ∪ PK, matching the direct SQL path.
3088
- if (callerRequestedFields) {
3089
- callerRequestedFields = ProviderBase.UnionFieldsWithPrimaryKeys(callerRequestedFields, entity);
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 (willCache && !cacheReadDenied && LocalCacheManager.Instance.IsInitialized) {
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
- let results = cached.results;
3117
- if (callerRequestedFields && params.ResultType !== 'entity_object') {
3118
- results = ProjectRowsToFields(results, callerRequestedFields);
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 above — tells
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: (entity && willCache) ? callerRequestedFields : null
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, then always fetch all fields from DB.
3294
- // One cache entry per entity+filter satisfies all field subsets.
3295
- let callerFields = param.Fields && param.Fields.length > 0
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
- if (batchEntity && (batchWillCache || batchWidenForEntityObject)) {
3309
- param.Fields = this.ComputeRunViewFetchFields(batchEntity, param);
3310
- // Platform contract: explicit Fields always include the primary key(s)
3311
- if (callerFields) {
3312
- callerFields = ProviderBase.UnionFieldsWithPrimaryKeys(callerFields, batchEntity);
3313
- // Remember the caller's original shape so PostRunViews can project
3314
- // cache-miss DB results back down to it
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 (batchWillCache && !batchCacheReadDenied && LocalCacheManager.Instance.IsInitialized) {
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 (callerFields && param.ResultType !== 'entity_object') {
3335
- results = ProjectRowsToFields(results, callerFields);
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
- // if needed this will transform each result into an entity object
3355
- await this.TransformSimpleObjectToEntityObject(param, cachedViewResult, contextUser);
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 processedResults.map(r => r.result);
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, so the refetch
3653
- // repopulates it. CacheLocal is stripped and BypassCache set so this single call
3654
- // takes the plain path — it cannot re-enter the smart-cache transport, which is what
3655
- // makes the recursion impossible rather than merely unlikely.
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
- return { result: freshFetch, cacheHit: false, cacheMiss: true };
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
- // runViewCacheEligible is the same predicate PreRunView used to decide whether to
3772
- // widen Fields — only widened (superset) results may be written to the cache.
3773
- // preResult.fingerprint doubles as a guard (only computed when eligible).
3774
- if (this.runViewCacheEligible(params) && result.Success && preResult.fingerprint && LocalCacheManager.Instance.IsInitialized) {
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 be the SAME eligibility predicate PreRunViews used to decide
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 — this exact gate
3863
- // previously omitted BypassCache/AfterKey and cached narrow BypassCache rows.
3864
- if (this.runViewCacheEligible(params[i]) && results[i].Success && LocalCacheManager.Instance.IsInitialized) {
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
- // Skip results that came from cache hits — they're already entity objects.
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
- const promises = [];
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
- const status = await this.GetDatasetStatusByName(datasetName, itemFilters);
5821
- if (status && localDate.getTime() >= status.LatestUpdateDate.getTime()) {
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
- await this.CacheDataset(datasetName, itemFilters, dataset);
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
- if (status) {
5864
- const serverTimestamp = status.LatestUpdateDate.getTime();
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', dataset.LatestUpdateDate.toISOString(), ProviderBase.DatasetCacheCategory, { TTLSeconds: ProviderBase.DatasetDateCacheTTLSeconds });
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
- // No schema filters for metadata — see comment in GetAllMetadata
6063
- const d = await this.GetDatasetStatusByName(ProviderBase._mjMetadataDatasetName, null, this.CurrentUser, providerToUse);
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 mdTimeStamps = await this.GetLatestMetadataUpdates(providerToUse);
6090
- if (mdTimeStamps) {
6091
- this._latestRemoteMetadataTimestamps = mdTimeStamps;
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