@memberjunction/core 6.2.0-edge.6 → 6.2.0-edge.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) 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/baseEngine.d.ts +118 -8
  5. package/dist/generic/baseEngine.d.ts.map +1 -1
  6. package/dist/generic/baseEngine.js +528 -82
  7. package/dist/generic/baseEngine.js.map +1 -1
  8. package/dist/generic/baseEntity.d.ts +63 -4
  9. package/dist/generic/baseEntity.d.ts.map +1 -1
  10. package/dist/generic/baseEntity.js +625 -55
  11. package/dist/generic/baseEntity.js.map +1 -1
  12. package/dist/generic/baseRemotableOperation.d.ts +95 -6
  13. package/dist/generic/baseRemotableOperation.d.ts.map +1 -1
  14. package/dist/generic/baseRemotableOperation.js +112 -8
  15. package/dist/generic/baseRemotableOperation.js.map +1 -1
  16. package/dist/generic/databaseProviderBase.d.ts +37 -0
  17. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  18. package/dist/generic/databaseProviderBase.js +222 -40
  19. package/dist/generic/databaseProviderBase.js.map +1 -1
  20. package/dist/generic/entityInfo.d.ts +6 -0
  21. package/dist/generic/entityInfo.d.ts.map +1 -1
  22. package/dist/generic/entityInfo.js +18 -12
  23. package/dist/generic/entityInfo.js.map +1 -1
  24. package/dist/generic/externalDataSourceReadRouter.d.ts +10 -0
  25. package/dist/generic/externalDataSourceReadRouter.d.ts.map +1 -1
  26. package/dist/generic/externalDataSourceReadRouter.js +12 -0
  27. package/dist/generic/externalDataSourceReadRouter.js.map +1 -1
  28. package/dist/generic/fieldSecuritySQLGate.d.ts +11 -2
  29. package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -1
  30. package/dist/generic/fieldSecuritySQLGate.js +6 -2
  31. package/dist/generic/fieldSecuritySQLGate.js.map +1 -1
  32. package/dist/generic/interfaces.d.ts +50 -1
  33. package/dist/generic/interfaces.d.ts.map +1 -1
  34. package/dist/generic/interfaces.js.map +1 -1
  35. package/dist/generic/listMembershipFilter.d.ts +5 -0
  36. package/dist/generic/listMembershipFilter.d.ts.map +1 -0
  37. package/dist/generic/listMembershipFilter.js +23 -0
  38. package/dist/generic/listMembershipFilter.js.map +1 -0
  39. package/dist/generic/localCacheManager.d.ts +48 -5
  40. package/dist/generic/localCacheManager.d.ts.map +1 -1
  41. package/dist/generic/localCacheManager.js +146 -17
  42. package/dist/generic/localCacheManager.js.map +1 -1
  43. package/dist/generic/providerBase.d.ts +155 -2
  44. package/dist/generic/providerBase.d.ts.map +1 -1
  45. package/dist/generic/providerBase.js +362 -124
  46. package/dist/generic/providerBase.js.map +1 -1
  47. package/dist/generic/relatedRecordCollection.d.ts +5 -0
  48. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  49. package/dist/generic/relatedRecordCollection.js +11 -2
  50. package/dist/generic/relatedRecordCollection.js.map +1 -1
  51. package/dist/generic/remoteOperationDispatch.d.ts.map +1 -1
  52. package/dist/generic/remoteOperationDispatch.js +14 -2
  53. package/dist/generic/remoteOperationDispatch.js.map +1 -1
  54. package/dist/generic/runQuerySQLFilterImplementations.d.ts +33 -6
  55. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  56. package/dist/generic/runQuerySQLFilterImplementations.js +81 -27
  57. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  58. package/dist/generic/transportError.d.ts +41 -0
  59. package/dist/generic/transportError.d.ts.map +1 -0
  60. package/dist/generic/transportError.js +167 -0
  61. package/dist/generic/transportError.js.map +1 -0
  62. package/dist/generic/util.d.ts +12 -0
  63. package/dist/generic/util.d.ts.map +1 -1
  64. package/dist/generic/util.js +52 -3
  65. package/dist/generic/util.js.map +1 -1
  66. package/dist/index.d.ts +2 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +2 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/views/runView.d.ts +23 -10
  71. package/dist/views/runView.d.ts.map +1 -1
  72. package/dist/views/runView.js +5 -1
  73. package/dist/views/runView.js.map +1 -1
  74. 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";
@@ -272,6 +273,14 @@ export class ProviderBase {
272
273
  this._clientFingerprintMemo = new WeakMap();
273
274
  this._requestDeniedReadFields = new WeakMap();
274
275
  this._cachedVisibleExplorerNavigationItems = null;
276
+ /**
277
+ * Whether the most recent {@link RefreshRemoteMetadataTimestamps} actually REACHED the
278
+ * server, regardless of whether it came back with usable timestamps.
279
+ *
280
+ * Starts `true` so that nothing reads "unreachable" before a read has been attempted.
281
+ * See {@link ReadRemoteMetadataTimestamps} for why the distinction exists.
282
+ */
283
+ this._lastRemoteTimestampReadReached = true;
275
284
  /** Guard for {@link logMetadataSnapshotDisabledOnce}. */
276
285
  this._metadataSnapshotSkipLogged = false;
277
286
  }
@@ -1087,6 +1096,7 @@ export class ProviderBase {
1087
1096
  if (params.OnDataChanged && preResult.fingerprint) {
1088
1097
  preResult.cachedResult.Unsubscribe = LocalCacheManager.Instance.RegisterChangeCallback(preResult.fingerprint, params.OnDataChanged);
1089
1098
  }
1099
+ ProviderBase.clearSkippedTotalRowCount(params, preResult.cachedResult);
1090
1100
  return preResult.cachedResult;
1091
1101
  }
1092
1102
  // Cache miss — execute query, then post-process (stores in cache)
@@ -1095,6 +1105,7 @@ export class ProviderBase {
1095
1105
  // PostRunView copies any hook-supplied replacement onto `result` in place, so this
1096
1106
  // reference reflects the hook chain's output.
1097
1107
  await this.PostRunView(result, params, preResult, contextUser);
1108
+ ProviderBase.clearSkippedTotalRowCount(params, result);
1098
1109
  return result;
1099
1110
  }
1100
1111
  // Delegate to RunViews, which uses the smart cache check (lightweight maxUpdatedAt +
@@ -1167,27 +1178,7 @@ export class ProviderBase {
1167
1178
  // ── Fresh execution ──
1168
1179
  const promise = this.executeRunViewsPipeline(params, contextUser)
1169
1180
  .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
- }
1181
+ this.stashInflightLingerResult(key, promise, results, true);
1191
1182
  return results;
1192
1183
  })
1193
1184
  .catch(err => {
@@ -1204,6 +1195,36 @@ export class ProviderBase {
1204
1195
  return results.map(r => this.shallowCopyResult(r));
1205
1196
  }
1206
1197
  // ── Dedup helpers ──────────────────────────────────────────────────
1198
+ /**
1199
+ * Stashes resolved RunViews results on `_inflightViews` for `DedupLingerMs` and schedules
1200
+ * cleanup via an isolated static `WeakRef` timer helper so the timer closure's V8 lexical
1201
+ * context never retains `this`, `promise`, or `results` on short-lived per-request providers.
1202
+ */
1203
+ stashInflightLingerResult(key, promise, results, enforceMaxEntries) {
1204
+ const entry = this._inflightViews.get(key);
1205
+ if (!entry || entry.promise !== promise) {
1206
+ return;
1207
+ }
1208
+ if (ProviderBase.DedupLingerMs > 0 && (!enforceMaxEntries || this._inflightViews.size <= ProviderBase.MaxLingerEntries)) {
1209
+ const resolvedAt = Date.now();
1210
+ entry.resolvedResults = results;
1211
+ entry.resolvedAt = resolvedAt;
1212
+ ProviderBase.scheduleInflightLingerExpiry(new WeakRef(this), key, resolvedAt);
1213
+ }
1214
+ else {
1215
+ this._inflightViews.delete(key);
1216
+ }
1217
+ }
1218
+ static scheduleInflightLingerExpiry(selfRef, key, resolvedAt) {
1219
+ const timer = setTimeout(() => {
1220
+ const self = selfRef.deref();
1221
+ const current = self?._inflightViews.get(key);
1222
+ if (current && current.resolvedAt === resolvedAt) {
1223
+ self?._inflightViews.delete(key);
1224
+ }
1225
+ }, ProviderBase.DedupLingerMs);
1226
+ timer?.unref?.();
1227
+ }
1207
1228
  /**
1208
1229
  * The original RunViews execution pipeline (pre-processing, cache,
1209
1230
  * internal execution, post-processing).
@@ -1234,12 +1255,32 @@ export class ProviderBase {
1234
1255
  }
1235
1256
  return merged;
1236
1257
  }
1258
+ /**
1259
+ * Keeps the `TotalRowCount` contract on every way out of the pipeline: a caller that passed
1260
+ * `SkipTotalRowCount` gets `undefined`, never a number. Cache hits, the smart-cache check and
1261
+ * the GraphQL wire each rebuild a result and each have a reason to fill in a row count, so the
1262
+ * contract is applied once here rather than at each of those sites.
1263
+ */
1264
+ static clearSkippedTotalRowCount(params, result) {
1265
+ if (result && params.SkipTotalRowCount === true && params.ResultType !== 'count_only') {
1266
+ result.TotalRowCount = undefined;
1267
+ }
1268
+ }
1269
+ /**
1270
+ * Runs a RunViews batch of the views the field-security predicate gate admitted, and applies
1271
+ * the `TotalRowCount` contract to whichever stage produced each result.
1272
+ */
1273
+ async runAdmittedViews(params, preResult, contextUser) {
1274
+ const results = await this.runAdmittedViewStages(params, preResult, contextUser);
1275
+ params.forEach((param, i) => ProviderBase.clearSkippedTotalRowCount(param, results[i]));
1276
+ return results;
1277
+ }
1237
1278
  /**
1238
1279
  * The cache, database and post-processing stages of a RunViews batch, for the views the
1239
1280
  * field-security predicate gate admitted. `params` and every index map in `preResult`
1240
1281
  * describe the same admitted views.
1241
1282
  */
1242
- async runAdmittedViews(params, preResult, contextUser) {
1283
+ async runAdmittedViewStages(params, preResult, contextUser) {
1243
1284
  // Check for smart cache check mode
1244
1285
  if (preResult.useSmartCacheCheck && preResult.smartCacheCheckParams) {
1245
1286
  return this.executeSmartCacheCheck(params, preResult, contextUser);
@@ -1509,22 +1550,7 @@ export class ProviderBase {
1509
1550
  // ── Fresh execution ──
1510
1551
  const promise = this.executeRunViewsPipeline(params, contextUser)
1511
1552
  .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
- }
1553
+ this.stashInflightLingerResult(key, promise, results, false);
1528
1554
  return results;
1529
1555
  })
1530
1556
  .catch(err => {
@@ -1584,6 +1610,7 @@ export class ProviderBase {
1584
1610
  return false;
1585
1611
  }
1586
1612
  return !param.BypassCache &&
1613
+ param.CacheLocal !== false &&
1587
1614
  !param.AfterKey &&
1588
1615
  !IsMaterializedDataSource(param.DataSource) &&
1589
1616
  !this.isAlternateBaseViewCacheBypassed(param) &&
@@ -1593,6 +1620,27 @@ export class ProviderBase {
1593
1620
  // Deliberately not user-dependent: field security changes which slot a request uses
1594
1621
  // (the fls: segment), never whether it is cacheable.
1595
1622
  }
1623
+ /**
1624
+ * True when the caller explicitly requested a non-empty `Fields` projection on a plain-object
1625
+ * (`ResultType !== 'entity_object'`) read.
1626
+ */
1627
+ hasNarrowFieldsOverride(param) {
1628
+ return Boolean(param.Fields && param.Fields.length > 0 && param.ResultType !== 'entity_object');
1629
+ }
1630
+ /**
1631
+ * True when a cache-miss read should widen `Fields` to the full entity superset AND populate
1632
+ * `LocalCacheManager` with that superset.
1633
+ *
1634
+ * On the server (`TrustLocalCacheCompletely === true`), cache-allowed entities (`AllowCaching === true`)
1635
+ * widen on miss by default so the Fields-agnostic superset slot serves any future `Fields` subset
1636
+ * (`server-cache` S1 -> S2 -> S3), UNLESS the caller explicitly opts out via `CacheLocal: false` or
1637
+ * `BypassCache: true` (in which case narrow SQL `SELECT` pushdown is preserved end-to-end without
1638
+ * polluting the cache). On the client (`TrustLocalCacheCompletely === false`), only `CacheLocal: true`
1639
+ * widens and caches.
1640
+ */
1641
+ shouldWidenAndPopulateCache(param) {
1642
+ return this.runViewCacheEligible(param);
1643
+ }
1596
1644
  /**
1597
1645
  * True when the request targets an `AlternateBaseView` (explicitly on `param.AlternateBaseView`
1598
1646
  * or pinned on `param.ViewEntity`) whose `CachePolicy` (or an ancestor's `CachePolicy` in its
@@ -1984,6 +2032,7 @@ export class ProviderBase {
1984
2032
  ExtraFilter: filter,
1985
2033
  ResultType: 'simple',
1986
2034
  MaxRows: 1,
2035
+ SkipTotalRowCount: true,
1987
2036
  }, contextUser);
1988
2037
  if (!r.Success || (r.Results?.length ?? 0) === 0)
1989
2038
  return null;
@@ -2092,6 +2141,7 @@ export class ProviderBase {
2092
2141
  Fields: entity.PrimaryKeys.map(pk => pk.Name),
2093
2142
  ResultType: 'simple',
2094
2143
  MaxRows: ids.length,
2144
+ SkipTotalRowCount: true,
2095
2145
  }, contextUser);
2096
2146
  if (!r.Success)
2097
2147
  return new Set();
@@ -2112,7 +2162,10 @@ export class ProviderBase {
2112
2162
  // revoked grants stay visible through the linger window.
2113
2163
  // SaveViewResults bypasses for the original reason: it creates DB
2114
2164
  // records and must not be deduplicated.
2115
- return params.some(p => p.SaveViewResults === true || p.BypassCache === true);
2165
+ // CacheLocal:false is the server-side opt-out from the RunView cache (read AND write). A
2166
+ // linger hit would hand back rows that were themselves served from that cache, so the
2167
+ // opt-out has to cover the dedup window as well.
2168
+ return params.some(p => p.SaveViewResults === true || p.BypassCache === true || p.CacheLocal === false);
2116
2169
  }
2117
2170
  /**
2118
2171
  * Returns a shallow copy of a RunViewResult: the Results array is a
@@ -2263,7 +2316,8 @@ export class ProviderBase {
2263
2316
  ...(params.Parameters ?? {}),
2264
2317
  __maxRows: params.MaxRows ?? -1,
2265
2318
  __startRow: params.StartRow ?? 0,
2266
- ...serverSegment
2319
+ ...serverSegment,
2320
+ ...(queryAuth.fieldSecurityKey ? { __fieldSecurity: queryAuth.fieldSecurityKey } : {})
2267
2321
  };
2268
2322
  if (serverSegment !== null) {
2269
2323
  queryFingerprint = LocalCacheManager.Instance.GenerateRunQueryFingerprint(
@@ -3056,41 +3110,29 @@ export class ProviderBase {
3056
3110
  await this.EntityStatusCheck(params, 'PreRunView', contextUser);
3057
3111
  const entityCheckTime = performance.now() - entityCheckStart;
3058
3112
  // 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.
3113
+ // When widening happens (full-row read, a cache-eligible read on the server, explicit
3114
+ // CacheLocal: true, or entity_object), we fetch every field this user's permission class
3115
+ // may read so the cache entry is a superset for that class, and project the result back to
3116
+ // the caller's Fields. A narrow Fields read first checks any already-warm superset slot
3117
+ // (0-SQL hit). Only CacheLocal: false or BypassCache: true keep the caller's narrow SQL
3118
+ // projection on a miss, and then nothing is written to the cache.
3063
3119
  const entityLookupStart = performance.now();
3064
- let callerRequestedFields = params.Fields && params.Fields.length > 0
3120
+ const rawCallerRequestedFields = params.Fields && params.Fields.length > 0
3065
3121
  ? params.Fields.map(f => f.trim().toLowerCase())
3066
3122
  : 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
3123
  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
3124
  if (entity)
3083
3125
  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
- }
3126
+ const projectedCallerFields = (rawCallerRequestedFields && entity)
3127
+ ? ProviderBase.UnionFieldsWithPrimaryKeys(rawCallerRequestedFields, entity)
3128
+ : rawCallerRequestedFields;
3129
+ const canReadFromCache = this.runViewCacheEligible(params);
3130
+ const willPopulateCache = this.shouldWidenAndPopulateCache(params) && (!this.hasNarrowFieldsOverride(params) || Boolean(entity));
3131
+ // entity_object ALWAYS widens, cache-eligible or not: a BaseEntity must hydrate from
3132
+ // every column it is entitled to, or it round-trips partial state into a save.
3133
+ const widenForEntityObject = params.ResultType === 'entity_object';
3092
3134
  const entityLookupTime = performance.now() - entityLookupStart;
3093
- // Check local cache if enabled
3135
+ // Check local cache FIRST if enabled (before any cache-miss Fields widening)
3094
3136
  const cacheCheckStart = performance.now();
3095
3137
  let cacheStatus = 'disabled';
3096
3138
  let cachedResult;
@@ -3106,16 +3148,17 @@ export class ProviderBase {
3106
3148
  // fails closed (or resolves ViewEntity synchronously) to plug that hole.
3107
3149
  const cacheReadDenied = (!!entity && !!contextUser && !(entity.GetUserPermisions(contextUser)?.CanRead ?? false)) ||
3108
3150
  this.cacheDeniedForViewOnlyRequest(params, contextUser);
3109
- if (willCache && !cacheReadDenied && LocalCacheManager.Instance.IsInitialized) {
3151
+ if (canReadFromCache && !cacheReadDenied && LocalCacheManager.Instance.IsInitialized) {
3110
3152
  fingerprint = this.ComputeRunViewCacheFingerprint(params, contextUser);
3111
3153
  const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
3112
3154
  if (cached) {
3113
3155
  // These rows are the cache's shared, deep-frozen objects — the runtime freeze is
3114
3156
  // what stops a consumer from corrupting the cache. Anything that needs to
3115
3157
  // transform them must map onto copies.
3116
- let results = cached.results;
3117
- if (callerRequestedFields && params.ResultType !== 'entity_object') {
3118
- results = ProjectRowsToFields(results, callerRequestedFields);
3158
+ // Materialize any encrypted fields stored as ciphertext at rest before projection.
3159
+ let results = await this.MaterializeCachedRowsForCaller(cached.results, entity, contextUser, Boolean(params.PreserveRestrictedApiCiphertext));
3160
+ if (projectedCallerFields && params.ResultType !== 'entity_object') {
3161
+ results = ProjectRowsToFields(results, projectedCallerFields);
3119
3162
  }
3120
3163
  // Field-level security MUST be applied here, not only in PostRunView: this path
3121
3164
  // RETURNS (see RunView) without ever calling PostRunView, and it is the exact
@@ -3149,6 +3192,15 @@ export class ProviderBase {
3149
3192
  cacheStatus = 'miss';
3150
3193
  }
3151
3194
  }
3195
+ // On cache MISS (or uninitialized cache), widen Fields ONLY when populating a superset
3196
+ // cache slot or materializing full BaseEntity objects.
3197
+ let callerRequestedFieldsForPost = null;
3198
+ if (cacheStatus !== 'hit' && entity && (willPopulateCache || widenForEntityObject)) {
3199
+ params.Fields = this.ComputeRunViewFetchFields(entity, params);
3200
+ if (willPopulateCache && projectedCallerFields) {
3201
+ callerRequestedFieldsForPost = projectedCallerFields;
3202
+ }
3203
+ }
3152
3204
  const cacheCheckTime = performance.now() - cacheCheckStart;
3153
3205
  const totalPreTime = performance.now() - preViewStart;
3154
3206
  if (totalPreTime > 50) {
@@ -3159,9 +3211,10 @@ export class ProviderBase {
3159
3211
  cacheStatus,
3160
3212
  cachedResult,
3161
3213
  fingerprint,
3162
- // Only non-null when params.Fields was actually widened above — tells
3214
+ // Only non-null when params.Fields was actually widened for cache population — tells
3163
3215
  // PostRunView to project cache-miss DB results back to the caller's shape
3164
- callerRequestedFields: (entity && willCache) ? callerRequestedFields : null
3216
+ callerRequestedFields: callerRequestedFieldsForPost,
3217
+ populateCache: willPopulateCache,
3165
3218
  };
3166
3219
  }
3167
3220
  /**
@@ -3283,6 +3336,7 @@ export class ProviderBase {
3283
3336
  const cacheStatusMap = new Map();
3284
3337
  const callerFieldsMap = new Map();
3285
3338
  const fingerprintMap = new Map();
3339
+ const populateCacheSet = new Set();
3286
3340
  const uncachedParams = [];
3287
3341
  const cachedResults = [];
3288
3342
  let allCached = true;
@@ -3290,31 +3344,22 @@ export class ProviderBase {
3290
3344
  const param = params[i];
3291
3345
  // Entity status check
3292
3346
  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
3347
+ // Save caller's original Fields. Consult warm superset cache slots first (0-SQL hit),
3348
+ // and on cache miss widen Fields ONLY when populating a superset cache slot or
3349
+ // materializing BaseEntity instances.
3350
+ const rawCallerFields = param.Fields && param.Fields.length > 0
3296
3351
  ? param.Fields.map(f => f.trim().toLowerCase())
3297
3352
  : 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
3353
  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
3354
  if (batchEntity)
3307
3355
  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
- }
3356
+ const projectedCallerFields = (rawCallerFields && batchEntity)
3357
+ ? ProviderBase.UnionFieldsWithPrimaryKeys(rawCallerFields, batchEntity)
3358
+ : rawCallerFields;
3359
+ const batchCanReadCache = this.runViewCacheEligible(param);
3360
+ const batchWillPopulateCache = this.shouldWidenAndPopulateCache(param) && (!this.hasNarrowFieldsOverride(param) || Boolean(batchEntity));
3361
+ // Same entity_object-always-widens rule as the single-view path above.
3362
+ const batchWidenForEntityObject = param.ResultType === 'entity_object';
3318
3363
  // Check local cache if enabled or if server trusts its cache completely
3319
3364
  // BypassCache skips cache entirely — used by maintenance actions querying for
3320
3365
  // records that were inserted via direct SQL (bypassing BaseEntity.Save())
@@ -3324,15 +3369,15 @@ export class ProviderBase {
3324
3369
  // view-only fail-closed gate for ViewID/ViewName-only requests (no resolvable entity).
3325
3370
  const batchCacheReadDenied = (!!batchEntity && !!contextUser && !(batchEntity.GetUserPermisions(contextUser)?.CanRead ?? false)) ||
3326
3371
  this.cacheDeniedForViewOnlyRequest(param, contextUser);
3327
- if (batchWillCache && !batchCacheReadDenied && LocalCacheManager.Instance.IsInitialized) {
3372
+ if (batchCanReadCache && !batchCacheReadDenied && LocalCacheManager.Instance.IsInitialized) {
3328
3373
  const fingerprint = this.ComputeRunViewCacheFingerprint(param, contextUser);
3329
3374
  fingerprintMap.set(i, fingerprint);
3330
3375
  const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
3331
3376
  if (cached) {
3332
3377
  // 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);
3378
+ let results = await this.MaterializeCachedRowsForCaller(cached.results, batchEntity, contextUser, Boolean(param.PreserveRestrictedApiCiphertext));
3379
+ if (projectedCallerFields && param.ResultType !== 'entity_object') {
3380
+ results = ProjectRowsToFields(results, projectedCallerFields);
3336
3381
  }
3337
3382
  // Field-level security on the batch cache-hit path — same reasoning as the
3338
3383
  // single-view path in PreRunView: this result is returned without
@@ -3368,6 +3413,15 @@ export class ProviderBase {
3368
3413
  else {
3369
3414
  cacheStatusMap.set(i, { status: 'disabled' });
3370
3415
  }
3416
+ if (batchWillPopulateCache) {
3417
+ populateCacheSet.add(i);
3418
+ }
3419
+ if (batchEntity && (batchWillPopulateCache || batchWidenForEntityObject)) {
3420
+ param.Fields = this.ComputeRunViewFetchFields(batchEntity, param);
3421
+ if (batchWillPopulateCache && projectedCallerFields) {
3422
+ callerFieldsMap.set(i, projectedCallerFields);
3423
+ }
3424
+ }
3371
3425
  allCached = false;
3372
3426
  uncachedParams.push(param);
3373
3427
  cachedResults.push(null); // Placeholder for uncached
@@ -3382,7 +3436,8 @@ export class ProviderBase {
3382
3436
  uncachedParams: allCached ? undefined : uncachedParams,
3383
3437
  cacheStatusMap,
3384
3438
  callerFieldsMap: callerFieldsMap.size > 0 ? callerFieldsMap : undefined,
3385
- fingerprintMap: fingerprintMap.size > 0 ? fingerprintMap : undefined
3439
+ fingerprintMap: fingerprintMap.size > 0 ? fingerprintMap : undefined,
3440
+ populateCacheSet,
3386
3441
  };
3387
3442
  }
3388
3443
  /**
@@ -3762,18 +3817,39 @@ export class ProviderBase {
3762
3817
  * @param preResult - The pre-processing result
3763
3818
  * @param contextUser - Optional user context
3764
3819
  */
3820
+ /**
3821
+ * Hook allowing server database providers to substitute pre-decryption (ciphertext-preserving)
3822
+ * rows before storing view results in {@link LocalCacheManager} / Redis, ensuring decrypted
3823
+ * plaintext secrets are never persisted in local or shared caches.
3824
+ *
3825
+ * Client-side providers inherit this default identity implementation.
3826
+ */
3827
+ PrepareRowsForCacheStorage(rows, _entityInfo) {
3828
+ return rows;
3829
+ }
3830
+ /**
3831
+ * Hook allowing server database providers to decrypt ciphertext-at-rest rows retrieved from
3832
+ * {@link LocalCacheManager} for in-process callers (or selectively preserve restricted
3833
+ * ciphertext when `preserveRestrictedApiCiphertext` is `true`).
3834
+ *
3835
+ * Client-side providers inherit this default identity implementation.
3836
+ */
3837
+ async MaterializeCachedRowsForCaller(rows, _entityInfo, _contextUser, _preserveRestrictedApiCiphertext) {
3838
+ return rows;
3839
+ }
3765
3840
  async PostRunView(result, params, preResult, contextUser) {
3766
3841
  // Store in local cache BEFORE entity transformation — the cache needs
3767
3842
  // plain JSON-serializable objects. BaseEntity objects contain RxJS Subjects
3768
3843
  // with circular subscriber references that break JSON.stringify.
3769
3844
  // On cache read, TransformSimpleObjectToEntityObject is called to restore
3770
3845
  // 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) {
3846
+ // preResult.populateCache records PreRunView's widening decision so narrow Fields
3847
+ // queries (without CacheLocal: true) never poison the Fields-agnostic superset slot.
3848
+ const resolvedEntity = this.resolveEntityForRunViewParams(params);
3849
+ const shouldPopulateSlot = preResult.populateCache ?? this.shouldWidenAndPopulateCache(params);
3850
+ if (shouldPopulateSlot && result.Success && preResult.fingerprint && LocalCacheManager.Instance.IsInitialized) {
3775
3851
  const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
3776
- await LocalCacheManager.Instance.SetRunViewResult(preResult.fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
3852
+ await LocalCacheManager.Instance.SetRunViewResult(preResult.fingerprint, params, this.PrepareRowsForCacheStorage(result.Results, resolvedEntity), maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
3777
3853
  }
3778
3854
  else if (this.shouldAutoCache(params, result, contextUser)) {
3779
3855
  // Server-side auto-cache: small, unfiltered, unsorted results are
@@ -3781,7 +3857,7 @@ export class ProviderBase {
3781
3857
  // safe for in-place upsert on entity changes (no filter to evaluate).
3782
3858
  const fingerprint = preResult.fingerprint || this.ComputeRunViewCacheFingerprint(params, contextUser);
3783
3859
  const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
3784
- await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
3860
+ await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, this.PrepareRowsForCacheStorage(result.Results, resolvedEntity), maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
3785
3861
  LogStatusEx({ message: ` 📦 [Auto-Cache] RunView "${params.EntityName || params.ViewName || 'unknown'}" — ${result.Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
3786
3862
  }
3787
3863
  // Project cache-miss DB results back down to the caller's originally requested
@@ -3857,17 +3933,20 @@ export class ProviderBase {
3857
3933
  // was disabled for them but auto-cache/OnDataChanged may still need it).
3858
3934
  const fingerprint = preResult.fingerprintMap?.get(i)
3859
3935
  ?? this.ComputeRunViewCacheFingerprint(params[i], contextUser);
3860
- // CRITICAL: must be the SAME eligibility predicate PreRunViews used to decide
3936
+ // CRITICAL: must reflect the SAME widening predicate PreRunViews used to decide
3861
3937
  // 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) {
3938
+ // result here poisons the Fields-agnostic superset slot.
3939
+ const itemEntity = this.resolveEntityForRunViewParams(params[i]);
3940
+ const shouldPopulateBatchSlot = preResult.populateCacheSet
3941
+ ? preResult.populateCacheSet.has(i)
3942
+ : this.shouldWidenAndPopulateCache(params[i]);
3943
+ if (shouldPopulateBatchSlot && results[i].Success && LocalCacheManager.Instance.IsInitialized) {
3865
3944
  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));
3945
+ cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], this.PrepareRowsForCacheStorage(results[i].Results, itemEntity), maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount, this));
3867
3946
  }
3868
3947
  else if (this.shouldAutoCache(params[i], results[i], contextUser)) {
3869
3948
  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));
3949
+ cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], this.PrepareRowsForCacheStorage(results[i].Results, itemEntity), maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount, this));
3871
3950
  LogStatusEx({ message: ` 📦 [Auto-Cache] RunViews "${params[i].EntityName || params[i].ViewName || 'unknown'}" — ${results[i].Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
3872
3951
  }
3873
3952
  // Register OnDataChanged callback if provided
@@ -4138,6 +4217,8 @@ export class ProviderBase {
4138
4217
  // main-path variants of those bugs.
4139
4218
  if (!this.runViewCacheEligible(params))
4140
4219
  return false;
4220
+ if (this.hasNarrowFieldsOverride(params))
4221
+ return false;
4141
4222
  if (!LocalCacheManager.Instance.IsInitialized)
4142
4223
  return false;
4143
4224
  if (!result.Success)
@@ -4453,6 +4534,9 @@ export class ProviderBase {
4453
4534
  * Resolves the {@link EntityInfo} normalization should read field types from, using only
4454
4535
  * synchronously available information on the params.
4455
4536
  */
4537
+ resolveEntityForRunViewParams(param) {
4538
+ return this.resolveEntityForNormalization(param);
4539
+ }
4456
4540
  resolveEntityForNormalization(param) {
4457
4541
  if (param.EntityName) {
4458
4542
  return this.EntityByName(param.EntityName);
@@ -5155,6 +5239,13 @@ export class ProviderBase {
5155
5239
  return false; // if we fail to copy the metadata, we will return false
5156
5240
  }
5157
5241
  }
5242
+ /**
5243
+ * Re-adopts the immutable read-only metadata catalog shell and lookup maps from
5244
+ * `Metadata.Provider` after the global provider completes a metadata refresh.
5245
+ */
5246
+ SyncMetadataFromGlobalProvider() {
5247
+ return this.CopyMetadataFromGlobalProvider();
5248
+ }
5158
5249
  /**
5159
5250
  * Builds dataset filters based on the provider configuration.
5160
5251
  * Ensures MJ Core schema is always included and never excluded.
@@ -5584,6 +5675,27 @@ export class ProviderBase {
5584
5675
  * RefreshIfNeeded load outside Config's reload single-flight). For the same reason it gets
5585
5676
  * `false`, not the owner's rejection, when the owner's check throws.
5586
5677
  * @returns True if refresh is needed, false otherwise
5678
+ *
5679
+ * @remarks
5680
+ * This is a **freshness question, not a load**, so it never throws on an unreachable
5681
+ * server. {@link ReadRemoteMetadataTimestamps} reports whether the server was reached at
5682
+ * all, and when it was NOT the honest answer here depends on whether we hold anything to
5683
+ * serve:
5684
+ *
5685
+ * - **We hold local metadata** → answer `false` ("do not tear this down"). The cached
5686
+ * snapshot may be a few seconds stale; a rejected status query is no reason to throw
5687
+ * it away, and every caller of this method treats `false` as "carry on with what you
5688
+ * have".
5689
+ * - **We hold nothing** → answer `true`, so the caller attempts a real load against the
5690
+ * actual metadata fetch. That load logs its own failure and leaves the graph empty;
5691
+ * `SetupGraphQLClient` turns an empty graph into an error carrying it, while a caller
5692
+ * that only checks {@link Config}'s return value sees `true`.
5693
+ *
5694
+ * Before this, an unreachable status query propagated an exception out of
5695
+ * {@link Config} and the Explorer's single boot catch turned it into a non-retryable
5696
+ * "System Error" — the whole app dead over a freshness check it did not need an answer
5697
+ * to. The two warm callers ({@link backgroundValidateAndRefresh},
5698
+ * {@link preValidateAndRefresh}) already caught it; {@link Config} did not.
5587
5699
  */
5588
5700
  async CheckToSeeIfRefreshNeeded(providerToUse, bypassMinCheckInterval) {
5589
5701
  if (!this.AllowRefresh)
@@ -5622,10 +5734,29 @@ export class ProviderBase {
5622
5734
  */
5623
5735
  async runRefreshCheck(providerToUse) {
5624
5736
  const gotTimestamps = await this.RefreshRemoteMetadataTimestamps(providerToUse);
5737
+ // Read the reachability flag in the SAME synchronous turn the await above resumed in —
5738
+ // before the next await can let a concurrent check overwrite it.
5739
+ const serverReached = this._lastRemoteTimestampReadReached;
5625
5740
  if (gotTimestamps) {
5626
5741
  this._lastRefreshCheckAt = Date.now();
5627
5742
  }
5628
5743
  await this.syncLocalMetadataFromStorage();
5744
+ if (!gotTimestamps && !serverReached) {
5745
+ // Could not ask the server. `LocalMetadataObsolete()` would answer `true` here
5746
+ // purely because the remote timestamp list is empty — a "yes, reload everything"
5747
+ // derived from the absence of an answer rather than from any comparison.
5748
+ const haveLocal = (this._localMetadata?.AllEntities?.length ?? 0) > 0;
5749
+ if (haveLocal) {
5750
+ LogError(`[RefreshCheck] Could not read metadata timestamps from the server — serving the ` +
5751
+ `cached metadata snapshot (${this._localMetadata.AllEntities.length} entities) and ` +
5752
+ `deferring the staleness check to the next call.`);
5753
+ return false;
5754
+ }
5755
+ LogError(`[RefreshCheck] Could not read metadata timestamps from the server and there is no ` +
5756
+ `cached snapshot to fall back on — reporting a refresh as needed so the caller loads ` +
5757
+ `metadata; that load reports its own failure.`);
5758
+ return true;
5759
+ }
5629
5760
  return this.LocalMetadataObsolete();
5630
5761
  }
5631
5762
  /**
@@ -5817,8 +5948,30 @@ export class ProviderBase {
5817
5948
  if (cachedDataset && cachedDateStr) {
5818
5949
  // We have a candidate cache entry — confirm freshness with the server.
5819
5950
  const localDate = new Date(cachedDateStr);
5820
- const status = await this.GetDatasetStatusByName(datasetName, itemFilters);
5821
- if (status && localDate.getTime() >= status.LatestUpdateDate.getTime()) {
5951
+ let status;
5952
+ try {
5953
+ status = await this.GetDatasetStatusByName(datasetName, itemFilters);
5954
+ }
5955
+ catch (e) {
5956
+ // Only the TRANSPORT failing earns the fallback. A server that answered "no"
5957
+ // (an identity without the scope, a validation rejection) gave a definite
5958
+ // answer that waiting will not change, and the cache would hand the caller
5959
+ // exactly what the server just declined to — so that propagates as it always did.
5960
+ if (!IsTransportError(e)) {
5961
+ throw e;
5962
+ }
5963
+ // We are HOLDING a complete cached dataset and only asked whether it is still
5964
+ // current. A failed freshness question is not grounds for throwing the answer
5965
+ // away and propagating: the line below re-fetches the whole dataset from the
5966
+ // same unreachable server, so the alternative to serving this cache is an
5967
+ // exception, not fresher data. Serve it and say so.
5968
+ LogError(`GetAndCacheDatasetByName('${datasetName}'): the freshness check failed — ` +
5969
+ `${e instanceof Error ? e.message : String(e)}. Serving the cached dataset ` +
5970
+ `(cached ${cachedDateStr}), which may be stale.`);
5971
+ return cachedDataset;
5972
+ }
5973
+ const serverDate = this.validDatasetDate(status?.LatestUpdateDate);
5974
+ if (status?.Success && serverDate && localDate.getTime() >= serverDate.getTime()) {
5822
5975
  // Timestamps suggest cache is fresh; verify per-entity row counts to
5823
5976
  // catch deleted rows (timestamp comparison alone misses pure deletes).
5824
5977
  // Shared with IsDatasetCacheUpToDate so the two cannot drift again.
@@ -5827,9 +5980,11 @@ export class ProviderBase {
5827
5980
  }
5828
5981
  }
5829
5982
  }
5830
- // Cold path or stale: fetch from server and cache.
5983
+ // Cold path or stale: fetch from server and cache only when the load succeeded.
5831
5984
  const dataset = await this.GetDatasetByName(datasetName, itemFilters, contextUser, providerToUse);
5832
- await this.CacheDataset(datasetName, itemFilters, dataset);
5985
+ if (dataset?.Success) {
5986
+ await this.CacheDataset(datasetName, itemFilters, dataset);
5987
+ }
5833
5988
  return dataset;
5834
5989
  }
5835
5990
  /**
@@ -5860,8 +6015,9 @@ export class ProviderBase {
5860
6015
  if (localDate) {
5861
6016
  // we have a local cached timestamp, so compare it to the server timestamp
5862
6017
  const status = await this.GetDatasetStatusByName(datasetName, itemFilters);
5863
- if (status) {
5864
- const serverTimestamp = status.LatestUpdateDate.getTime();
6018
+ const serverDate = this.validDatasetDate(status?.LatestUpdateDate);
6019
+ if (status?.Success && serverDate) {
6020
+ const serverTimestamp = serverDate.getTime();
5865
6021
  if (localDate.getTime() >= serverTimestamp) {
5866
6022
  // this situation means our local cache timestamp is >= the server timestamp, so we're most likely up to date
5867
6023
  // in this situation, the last thing we check is for each entity, if the rowcount is the same as the server, if it is, we're good
@@ -5884,6 +6040,17 @@ export class ProviderBase {
5884
6040
  return false;
5885
6041
  }
5886
6042
  }
6043
+ /**
6044
+ * Normalizes a dataset `LatestUpdateDate` to a valid `Date`, or `null` when absent or invalid
6045
+ * (for example, when a dataset or dataset-status call fails and returns `LatestUpdateDate: null`).
6046
+ */
6047
+ validDatasetDate(value) {
6048
+ if (value == null) {
6049
+ return null;
6050
+ }
6051
+ const date = value instanceof Date ? value : new Date(value);
6052
+ return Number.isNaN(date.getTime()) ? null : date;
6053
+ }
5887
6054
  /**
5888
6055
  * Compares the per-entity row counts of a cached dataset against the server's status.
5889
6056
  *
@@ -5942,6 +6109,12 @@ export class ProviderBase {
5942
6109
  * @param dataset
5943
6110
  */
5944
6111
  async CacheDataset(datasetName, itemFilters, dataset) {
6112
+ const latestUpdateDate = this.validDatasetDate(dataset?.LatestUpdateDate);
6113
+ if (!dataset?.Success || !latestUpdateDate) {
6114
+ LogError(`CacheDataset("${datasetName}"): refusing to cache failed or undated dataset result ` +
6115
+ `(Success=${dataset?.Success}, Status=${dataset?.Status ?? 'unknown'})`);
6116
+ return;
6117
+ }
5945
6118
  const ls = this.LocalStorageProvider;
5946
6119
  if (ls) {
5947
6120
  const key = this.GetDatasetCacheKey(datasetName, itemFilters);
@@ -5949,7 +6122,7 @@ export class ProviderBase {
5949
6122
  await ls.SetItem(key, dataset, ProviderBase.DatasetCacheCategory, { TTLSeconds: ProviderBase.DatasetCacheTTLSeconds });
5950
6123
  // Date is stored as ISO string for forward-compatibility across providers
5951
6124
  // (Redis can't natively round-trip Date; localStorage requires string).
5952
- await ls.SetItem(key + '_date', dataset.LatestUpdateDate.toISOString(), ProviderBase.DatasetCacheCategory, { TTLSeconds: ProviderBase.DatasetDateCacheTTLSeconds });
6125
+ await ls.SetItem(key + '_date', latestUpdateDate.toISOString(), ProviderBase.DatasetCacheCategory, { TTLSeconds: ProviderBase.DatasetDateCacheTTLSeconds });
5953
6126
  }
5954
6127
  }
5955
6128
  /**
@@ -6056,11 +6229,70 @@ export class ProviderBase {
6056
6229
  }
6057
6230
  /**
6058
6231
  * Retrieves the latest metadata update timestamps from the server.
6059
- * @returns Array of metadata update information
6232
+ * @returns Array of metadata update information, or `undefined` when the server could not
6233
+ * be asked (transport failure, timeout, gateway error) or answered unsuccessfully.
6234
+ *
6235
+ * @remarks
6236
+ * This is the ONE network call behind every staleness check, and it is a question about
6237
+ * freshness — never the metadata itself. A rejected status query therefore returns
6238
+ * `undefined` (an absent answer) rather than propagating: its callers already treat a
6239
+ * falsy result as "could not tell", while an exception out of here travelled unguarded
6240
+ * through {@link RefreshRemoteMetadataTimestamps} → {@link CheckToSeeIfRefreshNeeded} →
6241
+ * {@link Config} and killed the caller's whole bootstrap.
6242
+ *
6243
+ * The failure is logged at error level, not swallowed quietly: "we are running on a
6244
+ * cached snapshot because the server did not answer" is something an operator needs to
6245
+ * see in the console.
6060
6246
  */
6061
6247
  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);
6248
+ return (await this.ReadRemoteMetadataTimestamps(providerToUse)).Timestamps;
6249
+ }
6250
+ /**
6251
+ * The metadata-status read, with **why** it failed preserved.
6252
+ *
6253
+ * Two failures look the same to {@link GetLatestMetadataUpdates} — both give `undefined` —
6254
+ * but they call for opposite responses, so callers that can act on the difference read it
6255
+ * here instead:
6256
+ *
6257
+ * - `Reached: false` — the request never got an answer (transport failure, timeout, 504).
6258
+ * Transient. Nothing can be concluded about freshness, and a full metadata load would hit
6259
+ * the same wall, so the right move is to keep serving what we have.
6260
+ * - `Reached: true` with no `Timestamps` — the server answered `Success: false`. A
6261
+ * *permanent* condition (the dataset is missing or misconfigured), not a network blip.
6262
+ * Degrading here would serve a stale cache forever; the historical behaviour of treating
6263
+ * it as "obsolete" and attempting the real load is correct, because `GetDatasetByName`
6264
+ * may well still work.
6265
+ * - A rejection that is NOT a transport failure — an authorization denial ("this API key
6266
+ * requires the dataset:read scope"), a validation rejection, a programming error — is
6267
+ * the server (or our own code) answering, not the wire failing. It propagates exactly
6268
+ * as it did before this method existed. {@link IsTransportError} draws the line. The
6269
+ * integration tier's FLS fixture depends on this: it builds a reader whose scope rule
6270
+ * has not propagated yet and retries `Config()` until the denial stops — a denial
6271
+ * swallowed into `Reached: false` left that reader with an empty snapshot for good.
6272
+ *
6273
+ * Conflating these is how a broken status endpoint — or a refused identity — would become
6274
+ * permanently invisible.
6275
+ */
6276
+ async ReadRemoteMetadataTimestamps(providerToUse) {
6277
+ let d;
6278
+ try {
6279
+ // No schema filters for metadata — see comment in GetAllMetadata
6280
+ d = await this.GetDatasetStatusByName(ProviderBase._mjMetadataDatasetName, null, this.CurrentUser, providerToUse);
6281
+ }
6282
+ catch (e) {
6283
+ if (!IsTransportError(e)) {
6284
+ // The server answered — with a refusal. Not ours to soften.
6285
+ throw e;
6286
+ }
6287
+ LogError(`ReadRemoteMetadataTimestamps: the metadata status query failed — ` +
6288
+ `${e instanceof Error ? e.message : String(e)}. Reporting the server as unreachable; ` +
6289
+ `callers fall back to the cached metadata snapshot.`);
6290
+ return { Reached: false, Timestamps: undefined };
6291
+ }
6292
+ return { Reached: true, Timestamps: this.mapDatasetStatusToTimestamps(d) };
6293
+ }
6294
+ /** Shape a dataset-status answer into the timestamp list, or `undefined` if it failed. */
6295
+ mapDatasetStatusToTimestamps(d) {
6064
6296
  if (d && d.Success) {
6065
6297
  const ret = d.EntityUpdateDates.map(e => {
6066
6298
  return {
@@ -6079,6 +6311,7 @@ export class ProviderBase {
6079
6311
  });
6080
6312
  return ret;
6081
6313
  }
6314
+ return undefined;
6082
6315
  }
6083
6316
  /**
6084
6317
  * Refreshes the remote metadata timestamps from the server.
@@ -6086,9 +6319,14 @@ export class ProviderBase {
6086
6319
  * @returns True if timestamps were successfully refreshed
6087
6320
  */
6088
6321
  async RefreshRemoteMetadataTimestamps(providerToUse) {
6089
- const mdTimeStamps = await this.GetLatestMetadataUpdates(providerToUse);
6090
- if (mdTimeStamps) {
6091
- this._latestRemoteMetadataTimestamps = mdTimeStamps;
6322
+ const read = await this.ReadRemoteMetadataTimestamps(providerToUse);
6323
+ // Recorded rather than returned: this method's `boolean` contract is "did the timestamps
6324
+ // refresh", and widening it would change a public signature that callers outside this
6325
+ // class already consume. {@link CheckToSeeIfRefreshNeeded} reads the flag in the same
6326
+ // synchronous turn this call resolves in, so no interleaving call can overwrite it first.
6327
+ this._lastRemoteTimestampReadReached = read.Reached;
6328
+ if (read.Timestamps) {
6329
+ this._latestRemoteMetadataTimestamps = read.Timestamps;
6092
6330
  return true;
6093
6331
  }
6094
6332
  else