@memberjunction/core 5.40.1 → 5.41.0

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.
@@ -113,6 +113,63 @@ export const AllMetadataArrays = [
113
113
  { key: 'AllLibraries', class: LibraryInfo },
114
114
  { key: 'AllExplorerNavigationItems', class: ExplorerNavigationItem }
115
115
  ];
116
+ /**
117
+ * Projects plain-object rows down to a caller-requested field subset, matching
118
+ * field names case-insensitively (and ignoring surrounding whitespace).
119
+ *
120
+ * Used by the RunView caching pipeline: when a query is cacheable, the provider
121
+ * widens `params.Fields` to ALL entity fields so the cache entry is a universal
122
+ * superset that satisfies any future field subset. This helper restores the
123
+ * caller's originally requested shape — on cache hits (filtering the cached
124
+ * superset) AND on cache misses (filtering the widened DB result) — so callers
125
+ * always receive the same columns regardless of cache temperature.
126
+ *
127
+ * Returns the original array untouched when no projection is requested
128
+ * (`requestedFields` null/empty) or there are no rows. Never mutates input rows.
129
+ *
130
+ * @param rows - Plain-object result rows (NOT BaseEntity objects)
131
+ * @param requestedFields - The caller's original Fields list, or null for "all fields"
132
+ */
133
+ export function ProjectRowsToFields(rows, requestedFields) {
134
+ if (!requestedFields || requestedFields.length === 0 || !rows || rows.length === 0) {
135
+ return rows;
136
+ }
137
+ const requestedFieldSet = new Set(requestedFields.map(f => f.trim().toLowerCase()));
138
+ // No-op probe: SQL result rows are uniform (same SELECT list), so if every key of
139
+ // the first row is requested, the projection would copy every row unchanged —
140
+ // return the original array and skip the per-row object rebuilds entirely. This
141
+ // is the common case for entity_object-widened requests and full-coverage Fields.
142
+ const probe = rows[0];
143
+ if (probe && typeof probe === 'object') {
144
+ let allKept = true;
145
+ for (const key of Object.keys(probe)) {
146
+ if (!requestedFieldSet.has(key.toLowerCase())) {
147
+ allKept = false;
148
+ break;
149
+ }
150
+ }
151
+ if (allKept) {
152
+ return rows;
153
+ }
154
+ }
155
+ // Cache lowercase key→keep decisions across rows to avoid repeated allocations
156
+ const keyCache = new Map();
157
+ return rows.map((row) => {
158
+ const source = row;
159
+ const filtered = {};
160
+ for (const key of Object.keys(source)) {
161
+ let keep = keyCache.get(key);
162
+ if (keep === undefined) {
163
+ keep = requestedFieldSet.has(key.toLowerCase());
164
+ keyCache.set(key, keep);
165
+ }
166
+ if (keep) {
167
+ filtered[key] = source[key];
168
+ }
169
+ }
170
+ return filtered;
171
+ });
172
+ }
116
173
  /**
117
174
  * Base class for all metadata providers in MemberJunction.
118
175
  * Implements common functionality for metadata caching, refresh, and dataset management.
@@ -141,6 +198,7 @@ export class ProviderBase {
141
198
  * lingers so that near-sequential identical calls return immediately.
142
199
  */
143
200
  this._inflightViews = new Map();
201
+ this._clientFingerprintMemo = new WeakMap();
144
202
  this._cachedVisibleExplorerNavigationItems = null;
145
203
  }
146
204
  // ── Metadata Refresh Check Debounce ────────────────────────────────
@@ -187,6 +245,13 @@ export class ProviderBase {
187
245
  * still applies). Default 5 000 ms.
188
246
  */
189
247
  static { this.DedupLingerMs = 5000; }
248
+ /**
249
+ * Safety cap on the number of linger entries held simultaneously. The linger
250
+ * window is a latency optimization — under extreme churn (more distinct query
251
+ * keys than this resolving within one window) new resolutions skip lingering
252
+ * instead of accumulating result arrays in memory.
253
+ */
254
+ static { this.MaxLingerEntries = 500; }
190
255
  /******** ABSTRACT SECTION ****************************************************************** */
191
256
  /**
192
257
  * When true, cached RunView/RunQuery results are returned immediately on a
@@ -336,6 +401,10 @@ export class ProviderBase {
336
401
  * @returns The view results
337
402
  */
338
403
  async RunView(params, contextUser) {
404
+ // Shallow-clone so the pipeline's in-place modifications (PlatformSQL resolution,
405
+ // Fields widening for cache-superset storage) never leak into the CALLER's params
406
+ // object — reusing a params object across calls must be safe.
407
+ params = { ...params };
339
408
  // Keyset (AfterKey) queries always bypass the server cache: each call uses a
340
409
  // different seek key, so a cached entry would never be reusable. Treat them like
341
410
  // explicit BypassCache=true requests.
@@ -383,6 +452,10 @@ export class ProviderBase {
383
452
  * @returns Array of view results (shallow-copied Results per caller)
384
453
  */
385
454
  async RunViews(params, contextUser) {
455
+ // Shallow-clone every param so the pipeline's in-place modifications (PlatformSQL
456
+ // resolution, Fields widening for cache-superset storage) never leak into the
457
+ // CALLER's objects — reusing params across calls must be safe.
458
+ params = params.map(p => ({ ...p }));
386
459
  // Bypass dedup for side-effect calls (SaveViewResults creates DB records)
387
460
  if (this.ShouldBypassDedup(params)) {
388
461
  return this.ExecuteRunViewsPipeline(params, contextUser);
@@ -422,13 +495,16 @@ export class ProviderBase {
422
495
  // ── Fresh execution ──
423
496
  const promise = this.ExecuteRunViewsPipeline(params, contextUser)
424
497
  .then(results => {
425
- // Stash resolved results for the linger window
498
+ // Stash resolved results for the linger window. Safety cap: under
499
+ // extreme churn (hundreds of distinct keys resolving within one linger
500
+ // window) skip lingering rather than hold more result arrays in memory —
501
+ // the linger is a latency optimization, never a correctness requirement.
426
502
  const entry = this._inflightViews.get(key);
427
503
  if (entry && entry.promise === promise) {
428
- entry.resolvedResults = results;
429
- entry.resolvedAt = Date.now();
430
- // Schedule cleanup after linger expires
431
- if (ProviderBase.DedupLingerMs > 0) {
504
+ if (ProviderBase.DedupLingerMs > 0 && this._inflightViews.size <= ProviderBase.MaxLingerEntries) {
505
+ entry.resolvedResults = results;
506
+ entry.resolvedAt = Date.now();
507
+ // Schedule cleanup after linger expires
432
508
  setTimeout(() => {
433
509
  const current = this._inflightViews.get(key);
434
510
  if (current && current.promise === promise) {
@@ -751,15 +827,67 @@ export class ProviderBase {
751
827
  /**
752
828
  * Generates a deterministic dedup key for a batch of RunViewParams.
753
829
  * Extends the local-cache fingerprint with additional fields that
754
- * affect result identity (Fields, UserSearchString, ViewID, ViewName,
755
- * contextUser).
756
- */
830
+ * affect result identity (Fields, ResultType, UserSearchString, ViewID,
831
+ * ViewName, contextUser).
832
+ *
833
+ * Unlike the cache fingerprint — which deliberately excludes Fields and
834
+ * ResultType because the cache stores the full-width superset and projects
835
+ * / transforms per-read — the dedup layer shares the FINAL pipeline output:
836
+ * results already projected to one caller's Fields and already transformed
837
+ * per that caller's ResultType. A linger or in-flight hit hands those rows
838
+ * to the next caller verbatim (shallow array copy only), so callers with
839
+ * different Fields or ResultType must NOT share a dedup slot or the second
840
+ * caller silently receives the first caller's shape.
841
+ */
842
+ /**
843
+ * Single source of truth for whether a RunView call participates in the local
844
+ * cache (both READ and WRITE). Pre/Post hooks for the singular and batch paths
845
+ * must all use this predicate — historically each site recomputed it inline and
846
+ * they drifted (PostRunViews wrote BypassCache results into the cache, poisoning
847
+ * the Fields-agnostic superset slot with narrow rows).
848
+ *
849
+ * Ineligible:
850
+ * - `BypassCache` — caller explicitly wants true DB state, no cache interaction
851
+ * - `AfterKey` — keyset pages are single-use AND the fingerprint doesn't include
852
+ * the seek key, so caching a page would poison the entity+filter slot
853
+ * - `ResultType 'count_only'` — returns no rows; caching its empty Results under
854
+ * a fingerprint that excludes ResultType would poison row queries
855
+ * - entities where server caching is disallowed
856
+ */
857
+ runViewCacheEligible(param) {
858
+ return !param.BypassCache &&
859
+ !param.AfterKey &&
860
+ param.ResultType !== 'count_only' &&
861
+ (param.CacheLocal === true || this.TrustLocalCacheCompletely) &&
862
+ this.IsServerCacheAllowedForEntity(param);
863
+ }
864
+ /**
865
+ * Returns the caller's requested fields (lowercased) unioned with the entity's
866
+ * primary key field names. Platform contract: when `Fields` is explicitly
867
+ * specified, results ALWAYS include the primary key(s) — the direct SQL path has
868
+ * always done this, differential smart-cache merges require it, and entity
869
+ * linking in UIs depends on it. Applying the same union at every projection site
870
+ * keeps result shapes identical across cached, non-cached, and smart-cache paths.
871
+ */
872
+ static UnionFieldsWithPrimaryKeys(fields, entity) {
873
+ const result = [...fields];
874
+ const present = new Set(fields);
875
+ // Defensive ?? [] — virtual entities can be PK-less, and test doubles may not model PrimaryKeys
876
+ for (const pk of entity.PrimaryKeys ?? []) {
877
+ const name = pk.Name.trim().toLowerCase();
878
+ if (!present.has(name)) {
879
+ present.add(name);
880
+ result.push(name);
881
+ }
882
+ }
883
+ return result;
884
+ }
757
885
  GenerateDedupKey(params, contextUser) {
758
886
  const parts = params.map(p => {
759
887
  const base = LocalCacheManager.Instance.GenerateRunViewFingerprint(p, this.InstanceConnectionString);
760
- // Fields is intentionally excluded — cache stores full entity width
761
- // and filters on return, so different Fields values are the same query.
762
888
  const extras = [
889
+ ProviderBase.NormalizeFieldsKey(p.Fields),
890
+ p.ResultType ?? 'simple',
763
891
  p.UserSearchString ?? '',
764
892
  p.ViewID ?? '',
765
893
  p.ViewName ?? '',
@@ -769,6 +897,54 @@ export class ProviderBase {
769
897
  });
770
898
  return parts.join('||');
771
899
  }
900
+ /**
901
+ * Normalizes a Fields list into a stable key segment: trimmed, lowercased,
902
+ * sorted, comma-joined — `'*'` when the caller wants all fields. Matches the
903
+ * matching semantics of `ProjectRowsToFields` (trim + lowercase) so that
904
+ * semantically identical requests collapse to the same key. Used by both the
905
+ * request-dedup key and the client-side cache fingerprint.
906
+ */
907
+ static NormalizeFieldsKey(fields) {
908
+ return fields && fields.length > 0
909
+ ? fields.map(f => f.trim().toLowerCase()).sort().join(',')
910
+ : '*';
911
+ }
912
+ /**
913
+ * Client-side cache fingerprint: the shared RunView fingerprint plus a
914
+ * normalized Fields suffix (`|f:<fields>` or `|f:*`).
915
+ *
916
+ * Why the client fingerprint includes Fields when the server's deliberately
917
+ * does NOT: the server cache widens every cacheable query to ALL entity
918
+ * fields before the DB hit, stores one full-width superset per entity+filter,
919
+ * and projects per-read — so a single Fields-agnostic slot can serve any
920
+ * field subset. The client smart-cache flow does NOT widen (narrow wire
921
+ * payloads are the point of `Fields` client-side) and does NOT project on
922
+ * read: rows are stored exactly as the server returned them. Under a
923
+ * Fields-agnostic fingerprint, a narrow entry would pass the staleness check
924
+ * for a DIFFERENT field subset of the same entity+filter — `maxUpdatedAt`
925
+ * and `rowCount` are column-independent — and silently serve rows missing
926
+ * the newly requested columns. Per-Fields slots make client entries
927
+ * exact-match only: each field subset stores, validates, and serves its own
928
+ * shape. (Subset-serving from wider entries was considered and deliberately
929
+ * rejected: it requires candidate enumeration, per-entry field metadata, and
930
+ * careful staleness attribution for marginal hit-rate gains.)
931
+ */
932
+ clientCacheFingerprint(param) {
933
+ // Memoized per params object: RunViews shallow-clones params once at entry and
934
+ // the SAME object references flow through prepare → execute → process, where this
935
+ // fingerprint was previously recomputed up to 3× per param (string building +
936
+ // Fields normalization each time). Safe because params are not mutated after the
937
+ // first computation (entity_object Fields widening happens in
938
+ // prepareSmartCacheCheckParams BEFORE the first fingerprint call).
939
+ const memoized = this._clientFingerprintMemo.get(param);
940
+ if (memoized) {
941
+ return memoized;
942
+ }
943
+ const base = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
944
+ const fingerprint = `${base}|f:${ProviderBase.NormalizeFieldsKey(param.Fields)}`;
945
+ this._clientFingerprintMemo.set(param, fingerprint);
946
+ return fingerprint;
947
+ }
772
948
  /**
773
949
  * Ranked search over **one** entity's records. See {@link IMetadataProvider.SearchEntity}
774
950
  * for the contract and how this differs from {@link EntityByName} /
@@ -1055,6 +1231,80 @@ export class ProviderBase {
1055
1231
  * @returns The query results
1056
1232
  */
1057
1233
  async RunQuery(params, contextUser) {
1234
+ // Shallow-clone for symmetry with RunView — pipeline must never mutate caller objects
1235
+ params = { ...params };
1236
+ // ── CacheLocal: the RunQuery result cache (RunQueryCache category) ──
1237
+ // Engages ONLY on explicit opt-in. Saved queries only (QueryID/QueryName) —
1238
+ // ad-hoc SQL is never cached. Semantics per RunQueryParams JSDoc:
1239
+ // - cached + unexpired + provider supports RunQueriesWithCacheCheck (client):
1240
+ // server validates via the Query's CacheValidationSQL → 'current' serves the
1241
+ // local slot, 'stale'/'no_validation' returns fresh rows and rewrites the slot
1242
+ // - cached + unexpired + no validation transport (server providers): TTL mode —
1243
+ // serve directly until expiry
1244
+ // - miss/expired: run normally, then store with TTL (CacheLocalTTL override,
1245
+ // else the LocalCacheManager default)
1246
+ const queryCacheEngaged = params.CacheLocal === true
1247
+ && !params.SQL
1248
+ && (!!params.QueryID || !!params.QueryName)
1249
+ && LocalCacheManager.Instance.IsInitialized;
1250
+ let queryFingerprint;
1251
+ if (queryCacheEngaged) {
1252
+ // MaxRows/StartRow shape the result set — they MUST distinguish cache slots,
1253
+ // so fold them into the parameters portion of the fingerprint.
1254
+ const fingerprintParams = {
1255
+ ...(params.Parameters ?? {}),
1256
+ __maxRows: params.MaxRows ?? -1,
1257
+ __startRow: params.StartRow ?? 0
1258
+ };
1259
+ queryFingerprint = LocalCacheManager.Instance.GenerateRunQueryFingerprint(params.QueryID, params.QueryName, fingerprintParams, this.InstanceConnectionString);
1260
+ const cached = await LocalCacheManager.Instance.GetRunQueryResult(queryFingerprint); // TTL-enforced
1261
+ if (cached) {
1262
+ const serveFromSlot = () => ({
1263
+ QueryID: cached.queryId ?? params.QueryID ?? '',
1264
+ QueryName: params.QueryName ?? '',
1265
+ Success: true,
1266
+ Results: cached.results,
1267
+ RowCount: cached.results.length,
1268
+ TotalRowCount: cached.rowCount ?? cached.results.length,
1269
+ ExecutionTime: 0,
1270
+ ErrorMessage: '',
1271
+ CacheHit: true,
1272
+ CacheKey: queryFingerprint
1273
+ });
1274
+ const checker = this.RunQueriesWithCacheCheck?.bind(this);
1275
+ if (!checker || this.TrustLocalCacheCompletely) {
1276
+ // TTL mode (server providers / no validation transport)
1277
+ return serveFromSlot();
1278
+ }
1279
+ // Client smart validation round trip
1280
+ const response = await checker([{
1281
+ params,
1282
+ cacheStatus: { maxUpdatedAt: cached.maxUpdatedAt, rowCount: cached.rowCount }
1283
+ }], contextUser);
1284
+ const check = response.results?.[0];
1285
+ if (response.success && check) {
1286
+ if (check.status === 'current') {
1287
+ return serveFromSlot();
1288
+ }
1289
+ if ((check.status === 'stale' || check.status === 'no_validation') && check.results) {
1290
+ const freshRows = check.results;
1291
+ // Fire-and-forget slot rewrite — same pattern as the RunView client path
1292
+ LocalCacheManager.Instance.SetRunQueryResult(queryFingerprint, params.QueryName ?? '', freshRows, check.maxUpdatedAt ?? '', check.rowCount, check.queryId, params.CacheLocalTTL).catch(e => LogError(`RunQuery cache rewrite failed: ${e}`));
1293
+ return {
1294
+ QueryID: check.queryId ?? params.QueryID ?? '',
1295
+ QueryName: params.QueryName ?? '',
1296
+ Success: true,
1297
+ Results: freshRows,
1298
+ RowCount: freshRows.length,
1299
+ TotalRowCount: check.rowCount ?? freshRows.length,
1300
+ ExecutionTime: 0,
1301
+ ErrorMessage: ''
1302
+ };
1303
+ }
1304
+ }
1305
+ // validation transport failed — fall through to a normal execution
1306
+ }
1307
+ }
1058
1308
  // Pre-processing: telemetry, cache check
1059
1309
  const preResult = await this.PreRunQuery(params, contextUser);
1060
1310
  // Check for cached result - end telemetry with cache hit info
@@ -1070,6 +1320,12 @@ export class ProviderBase {
1070
1320
  const result = await this.InternalRunQuery(params, contextUser);
1071
1321
  // Post-processing: cache storage, telemetry end
1072
1322
  await this.PostRunQuery(result, params, preResult, contextUser);
1323
+ // Store in the RunQuery cache on success (fire-and-forget; TTL per CacheLocalTTL
1324
+ // or the LocalCacheManager default). maxUpdatedAt is unknown for a plain run —
1325
+ // the smart-validation path stamps it when the Query has CacheValidationSQL.
1326
+ if (queryCacheEngaged && queryFingerprint && result.Success) {
1327
+ LocalCacheManager.Instance.SetRunQueryResult(queryFingerprint, result.QueryName, result.Results, '', result.TotalRowCount, result.QueryID, params.CacheLocalTTL).catch(e => LogError(`RunQuery cache write failed: ${e}`));
1328
+ }
1073
1329
  return result;
1074
1330
  }
1075
1331
  /**
@@ -1238,22 +1494,21 @@ export class ProviderBase {
1238
1494
  // We always fetch ALL fields from the DB so the cache entry is a universal superset
1239
1495
  // that satisfies any future query for the same entity+filter regardless of field subset.
1240
1496
  const entityLookupStart = performance.now();
1241
- const callerRequestedFields = params.Fields && params.Fields.length > 0
1497
+ let callerRequestedFields = params.Fields && params.Fields.length > 0
1242
1498
  ? params.Fields.map(f => f.trim().toLowerCase())
1243
1499
  : null; // null = caller wants all fields
1244
1500
  // Only override Fields to all entity fields when caching will actually happen
1245
1501
  // for this call. For non-cached calls we respect the caller's narrow Fields
1246
1502
  // end-to-end — there's no cache-coherence concern to preserve.
1247
1503
  const entity = params.EntityName ? this.EntityByName(params.EntityName) : null;
1248
- const entityCacheAllowed = this.IsServerCacheAllowedForEntity(params);
1249
- // Keyset (AfterKey) queries are inherently single-use, so we never read from or
1250
- // write to the cache for them. See RunViewParams.AfterKey JSDoc for rationale.
1251
- const willCache = !params.BypassCache &&
1252
- !params.AfterKey &&
1253
- (params.CacheLocal || this.TrustLocalCacheCompletely) &&
1254
- entityCacheAllowed;
1504
+ const willCache = this.runViewCacheEligible(params);
1255
1505
  if (entity && willCache) {
1256
1506
  params.Fields = entity.Fields.map(f => f.Name);
1507
+ // Platform contract: explicit Fields always include the primary key(s) —
1508
+ // project back down to requested ∪ PK, matching the direct SQL path.
1509
+ if (callerRequestedFields) {
1510
+ callerRequestedFields = ProviderBase.UnionFieldsWithPrimaryKeys(callerRequestedFields, entity);
1511
+ }
1257
1512
  }
1258
1513
  const entityLookupTime = performance.now() - entityLookupStart;
1259
1514
  // Check local cache if enabled
@@ -1269,23 +1524,7 @@ export class ProviderBase {
1269
1524
  // Filter cached results to only the caller's requested fields (if specified)
1270
1525
  let results = cached.results;
1271
1526
  if (callerRequestedFields && params.ResultType !== 'entity_object') {
1272
- // Cache lowercase key→keep decisions across rows to avoid repeated allocations
1273
- const requestedFieldSet = new Set(callerRequestedFields);
1274
- const keyCache = new Map();
1275
- results = results.map((row) => {
1276
- const filtered = {};
1277
- for (const key of Object.keys(row)) {
1278
- let keep = keyCache.get(key);
1279
- if (keep === undefined) {
1280
- keep = requestedFieldSet.has(key.toLowerCase());
1281
- keyCache.set(key, keep);
1282
- }
1283
- if (keep) {
1284
- filtered[key] = row[key];
1285
- }
1286
- }
1287
- return filtered;
1288
- });
1527
+ results = ProjectRowsToFields(results, callerRequestedFields);
1289
1528
  }
1290
1529
  // Reconstruct RunViewResult from cached data
1291
1530
  cachedResult = {
@@ -1316,7 +1555,10 @@ export class ProviderBase {
1316
1555
  telemetryEventId,
1317
1556
  cacheStatus,
1318
1557
  cachedResult,
1319
- fingerprint
1558
+ fingerprint,
1559
+ // Only non-null when params.Fields was actually widened above — tells
1560
+ // PostRunView to project cache-miss DB results back to the caller's shape
1561
+ callerRequestedFields: (entity && willCache) ? callerRequestedFields : null
1320
1562
  };
1321
1563
  }
1322
1564
  /**
@@ -1340,6 +1582,10 @@ export class ProviderBase {
1340
1582
  const telemetryEventId = TelemetryManager.Instance.StartEvent('RunView', 'ProviderBase.RunViews', {
1341
1583
  BatchSize: params.length,
1342
1584
  Entities: params.map(p => p.EntityName || p.ViewName || p.ViewID).filter(Boolean),
1585
+ // Per-view filter/orderBy parallel to Entities so the telemetry fingerprint can
1586
+ // tell apart two batches over the same entity set but with different filters.
1587
+ Filters: params.map(p => p.ExtraFilter),
1588
+ OrderBys: params.map(p => p.OrderBy),
1343
1589
  _fromEngine: fromEngine
1344
1590
  }, contextUser?.ID);
1345
1591
  // Client-side providers route any CacheLocal params through smart-cache-check:
@@ -1355,6 +1601,8 @@ export class ProviderBase {
1355
1601
  }
1356
1602
  // Traditional caching flow
1357
1603
  const cacheStatusMap = new Map();
1604
+ const callerFieldsMap = new Map();
1605
+ const fingerprintMap = new Map();
1358
1606
  const uncachedParams = [];
1359
1607
  const cachedResults = [];
1360
1608
  let allCached = true;
@@ -1364,21 +1612,23 @@ export class ProviderBase {
1364
1612
  await this.EntityStatusCheck(param, 'PreRunViews');
1365
1613
  // Save caller's original Fields, then always fetch all fields from DB.
1366
1614
  // One cache entry per entity+filter satisfies all field subsets.
1367
- const callerFields = param.Fields && param.Fields.length > 0
1615
+ let callerFields = param.Fields && param.Fields.length > 0
1368
1616
  ? param.Fields.map(f => f.trim().toLowerCase())
1369
1617
  : null;
1370
1618
  // Only override Fields to all entity fields when caching will actually happen
1371
1619
  // for this call. For non-cached calls we respect the caller's narrow Fields
1372
1620
  // end-to-end — there's no cache-coherence concern to preserve.
1373
1621
  const batchEntity = param.EntityName ? this.EntityByName(param.EntityName) : null;
1374
- const batchEntityCacheAllowed = this.IsServerCacheAllowedForEntity(param);
1375
- // Keyset (AfterKey) queries are inherently single-use; never use the cache for them.
1376
- const batchWillCache = !param.BypassCache &&
1377
- !param.AfterKey &&
1378
- (param.CacheLocal || this.TrustLocalCacheCompletely) &&
1379
- batchEntityCacheAllowed;
1622
+ const batchWillCache = this.runViewCacheEligible(param);
1380
1623
  if (batchEntity && batchWillCache) {
1381
1624
  param.Fields = batchEntity.Fields.map(f => f.Name);
1625
+ // Platform contract: explicit Fields always include the primary key(s)
1626
+ if (callerFields) {
1627
+ callerFields = ProviderBase.UnionFieldsWithPrimaryKeys(callerFields, batchEntity);
1628
+ // Remember the caller's original shape so PostRunViews can project
1629
+ // cache-miss DB results back down to it
1630
+ callerFieldsMap.set(i, callerFields);
1631
+ }
1382
1632
  }
1383
1633
  // Check local cache if enabled or if server trusts its cache completely
1384
1634
  // BypassCache skips cache entirely — used by maintenance actions querying for
@@ -1386,29 +1636,13 @@ export class ProviderBase {
1386
1636
  if (batchWillCache && LocalCacheManager.Instance.IsInitialized) {
1387
1637
  const rlsWhereClause = this.ComputeRunViewRLSWhereClause(param, contextUser);
1388
1638
  const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString, rlsWhereClause);
1639
+ fingerprintMap.set(i, fingerprint);
1389
1640
  const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
1390
1641
  if (cached) {
1391
1642
  // Filter cached results to caller's requested fields (if specified and not entity_object)
1392
1643
  let results = cached.results;
1393
1644
  if (callerFields && param.ResultType !== 'entity_object') {
1394
- // Bolt: Cache key-to-lowercase string resolutions to eliminate O(n*c) string allocations and array search operations.
1395
- // This improves post-cache filtering by ~40-50% for large datasets with many columns.
1396
- const requestedFieldSet = new Set(callerFields);
1397
- const keyCache = new Map();
1398
- results = results.map((row) => {
1399
- const filtered = {};
1400
- for (const key of Object.keys(row)) {
1401
- let keep = keyCache.get(key);
1402
- if (keep === undefined) {
1403
- keep = requestedFieldSet.has(key.toLowerCase());
1404
- keyCache.set(key, keep);
1405
- }
1406
- if (keep) {
1407
- filtered[key] = row[key];
1408
- }
1409
- }
1410
- return filtered;
1411
- });
1645
+ results = ProjectRowsToFields(results, callerFields);
1412
1646
  }
1413
1647
  const cachedViewResult = {
1414
1648
  Success: true,
@@ -1448,7 +1682,9 @@ export class ProviderBase {
1448
1682
  ? cachedResults.filter(r => r !== null)
1449
1683
  : (hasCacheHits ? cachedResults : undefined),
1450
1684
  uncachedParams: allCached ? undefined : uncachedParams,
1451
- cacheStatusMap
1685
+ cacheStatusMap,
1686
+ callerFieldsMap: callerFieldsMap.size > 0 ? callerFieldsMap : undefined,
1687
+ fingerprintMap: fingerprintMap.size > 0 ? fingerprintMap : undefined
1452
1688
  };
1453
1689
  }
1454
1690
  /**
@@ -1472,8 +1708,7 @@ export class ProviderBase {
1472
1708
  param.Fields = entity.Fields.map(f => f.Name);
1473
1709
  }
1474
1710
  if (param.CacheLocal && LocalCacheManager.Instance.IsInitialized) {
1475
- const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
1476
- cacheable.push({ paramIndex: i, fingerprint });
1711
+ cacheable.push({ paramIndex: i, fingerprint: this.clientCacheFingerprint(param) });
1477
1712
  }
1478
1713
  }
1479
1714
  // Phase 2 — batched read: one IDB transaction (or one Redis MGET) returns
@@ -1541,7 +1776,7 @@ export class ProviderBase {
1541
1776
  const currentFingerprints = [];
1542
1777
  for (const sr of response.results) {
1543
1778
  if (sr.status === 'current' && params[sr.viewIndex]) {
1544
- currentFingerprints.push(LocalCacheManager.Instance.GenerateRunViewFingerprint(params[sr.viewIndex], this.InstanceConnectionString));
1779
+ currentFingerprints.push(this.clientCacheFingerprint(params[sr.viewIndex]));
1545
1780
  }
1546
1781
  }
1547
1782
  const preResolvedCache = currentFingerprints.length > 0
@@ -1606,7 +1841,7 @@ export class ProviderBase {
1606
1841
  // Cache is current - use the pre-resolved cache entry from the batched read
1607
1842
  // (executeSmartCacheCheck reads all 'current' fingerprints in one IDB
1608
1843
  // transaction up front, so we don't pay per-param transaction overhead here).
1609
- const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
1844
+ const fingerprint = this.clientCacheFingerprint(param);
1610
1845
  const cached = preResolvedCache.get(fingerprint) ?? null;
1611
1846
  if (cached) {
1612
1847
  const cachedResult = {
@@ -1642,7 +1877,7 @@ export class ProviderBase {
1642
1877
  }
1643
1878
  else if (checkResult.status === 'differential') {
1644
1879
  // Cache is stale but we have differential data - merge with cached data
1645
- const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
1880
+ const fingerprint = this.clientCacheFingerprint(param);
1646
1881
  // Get entity info for primary key field name
1647
1882
  const entity = this.EntityByName(param.EntityName);
1648
1883
  const primaryKeyFieldName = entity?.FirstPrimaryKey?.Name || 'ID';
@@ -1689,7 +1924,7 @@ export class ProviderBase {
1689
1924
  };
1690
1925
  // Update the local cache with fresh data (don't await - fire and forget for performance)
1691
1926
  if (param.CacheLocal && checkResult.maxUpdatedAt && LocalCacheManager.Instance.IsInitialized) {
1692
- const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
1927
+ const fingerprint = this.clientCacheFingerprint(param);
1693
1928
  // Note: We don't await here to avoid blocking the response
1694
1929
  // Cache update happens in background
1695
1930
  LocalCacheManager.Instance.SetRunViewResult(fingerprint, param, checkResult.results || [], checkResult.maxUpdatedAt, checkResult.aggregateResults, // Include aggregate results in cache
@@ -1782,8 +2017,10 @@ export class ProviderBase {
1782
2017
  // with circular subscriber references that break JSON.stringify.
1783
2018
  // On cache read, TransformSimpleObjectToEntityObject is called to restore
1784
2019
  // entity objects when ResultType === 'entity_object'.
1785
- const postEntityCacheAllowed = this.IsServerCacheAllowedForEntity(params);
1786
- if ((params.CacheLocal || this.TrustLocalCacheCompletely) && postEntityCacheAllowed && result.Success && preResult.fingerprint && LocalCacheManager.Instance.IsInitialized) {
2020
+ // runViewCacheEligible is the same predicate PreRunView used to decide whether to
2021
+ // widen Fields — only widened (superset) results may be written to the cache.
2022
+ // preResult.fingerprint doubles as a guard (only computed when eligible).
2023
+ if (this.runViewCacheEligible(params) && result.Success && preResult.fingerprint && LocalCacheManager.Instance.IsInitialized) {
1787
2024
  const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
1788
2025
  await LocalCacheManager.Instance.SetRunViewResult(preResult.fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
1789
2026
  }
@@ -1796,6 +2033,15 @@ export class ProviderBase {
1796
2033
  await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
1797
2034
  LogStatusEx({ message: ` 📦 [Auto-Cache] RunView "${params.EntityName || params.ViewName || 'unknown'}" — ${result.Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
1798
2035
  }
2036
+ // Project cache-miss DB results back down to the caller's originally requested
2037
+ // fields. PreRunView widened params.Fields to ALL entity fields so the cache
2038
+ // entry (written above) is a universal superset — but the caller must receive
2039
+ // the same shape on a miss as they do on a hit (which projects from cache).
2040
+ // Must run AFTER the cache writes (cache keeps the superset) and only for
2041
+ // plain-object results (entity objects need all fields).
2042
+ if (result.Success && preResult.callerRequestedFields && params.ResultType !== 'entity_object') {
2043
+ result.Results = ProjectRowsToFields(result.Results, preResult.callerRequestedFields);
2044
+ }
1799
2045
  // Transform the result set into BaseEntity-derived objects, if needed
1800
2046
  await this.TransformSimpleObjectToEntityObject(params, result, contextUser);
1801
2047
  // Run registered PostRunView hooks (e.g., data masking, audit logging)
@@ -1834,10 +2080,17 @@ export class ProviderBase {
1834
2080
  if (cacheInfo?.status === 'hit') {
1835
2081
  continue;
1836
2082
  }
1837
- const rlsWhereClause = this.ComputeRunViewRLSWhereClause(params[i], contextUser);
1838
- const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params[i], this.InstanceConnectionString, rlsWhereClause);
1839
- const batchEntityCacheAllowed = this.IsServerCacheAllowedForEntity(params[i]);
1840
- if ((params[i].CacheLocal || this.TrustLocalCacheCompletely) && batchEntityCacheAllowed && results[i].Success && LocalCacheManager.Instance.IsInitialized) {
2083
+ // Reuse the fingerprint PreRunViews already computed for this index —
2084
+ // recomputing means rebuilding the RLS where-clause + fingerprint string
2085
+ // per item. Compute lazily only for indexes PreRunViews skipped (cache
2086
+ // was disabled for them but auto-cache/OnDataChanged may still need it).
2087
+ const fingerprint = preResult.fingerprintMap?.get(i)
2088
+ ?? LocalCacheManager.Instance.GenerateRunViewFingerprint(params[i], this.InstanceConnectionString, this.ComputeRunViewRLSWhereClause(params[i], contextUser));
2089
+ // CRITICAL: must be the SAME eligibility predicate PreRunViews used to decide
2090
+ // whether to widen Fields. Writing a non-widened (narrow or keyset-paged)
2091
+ // result here poisons the Fields-agnostic superset slot — this exact gate
2092
+ // previously omitted BypassCache/AfterKey and cached narrow BypassCache rows.
2093
+ if (this.runViewCacheEligible(params[i]) && results[i].Success && LocalCacheManager.Instance.IsInitialized) {
1841
2094
  const maxUpdatedAt = this.extractMaxUpdatedAt(results[i].Results);
1842
2095
  cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount, this));
1843
2096
  }
@@ -1852,6 +2105,24 @@ export class ProviderBase {
1852
2105
  }
1853
2106
  }
1854
2107
  await Promise.all(cachePromises);
2108
+ // Project cache-miss DB results back down to each caller's originally
2109
+ // requested fields. PreRunViews widened those params' Fields to ALL entity
2110
+ // fields so the cache entries (written above) are universal supersets — but
2111
+ // callers must receive the same shape on a miss as on a hit (which projects
2112
+ // from cache). Skip hits (already projected) and entity_object results
2113
+ // (need all fields).
2114
+ if (preResult.callerFieldsMap) {
2115
+ for (let i = 0; i < results.length; i++) {
2116
+ const cacheInfo = preResult.cacheStatusMap?.get(i);
2117
+ if (cacheInfo?.status === 'hit') {
2118
+ continue;
2119
+ }
2120
+ const callerFields = preResult.callerFieldsMap.get(i);
2121
+ if (callerFields && results[i].Success && params[i].ResultType !== 'entity_object') {
2122
+ results[i].Results = ProjectRowsToFields(results[i].Results, callerFields);
2123
+ }
2124
+ }
2125
+ }
1855
2126
  // Transform results to entity objects AFTER caching plain objects.
1856
2127
  // Skip results that came from cache hits — they're already entity objects.
1857
2128
  const transformPromises = [];
@@ -2001,16 +2272,18 @@ export class ProviderBase {
2001
2272
  shouldAutoCache(params, result) {
2002
2273
  if (!this.TrustLocalCacheCompletely)
2003
2274
  return false;
2004
- if (params.BypassCache)
2005
- return false; // caller explicitly wants no caching
2006
2275
  if (params.CacheLocal)
2007
2276
  return false; // already handled
2277
+ // Same eligibility predicate as the main cache path — covers BypassCache,
2278
+ // AfterKey (keyset pages), count_only, and cache-disallowed entities. An
2279
+ // auto-cached keyset page or count_only result would poison the
2280
+ // entity+filter slot just like the main-path variants of those bugs.
2281
+ if (!this.runViewCacheEligible(params))
2282
+ return false;
2008
2283
  if (!LocalCacheManager.Instance.IsInitialized)
2009
2284
  return false;
2010
2285
  if (!result.Success)
2011
2286
  return false;
2012
- if (!this.IsServerCacheAllowedForEntity(params))
2013
- return false;
2014
2287
  if (ProviderBase.ServerAutoCacheMaxRows <= 0)
2015
2288
  return false;
2016
2289
  if ((result.Results?.length ?? 0) > ProviderBase.ServerAutoCacheMaxRows)
@@ -2026,6 +2299,14 @@ export class ProviderBase {
2026
2299
  }
2027
2300
  extractMaxUpdatedAt(results) {
2028
2301
  let maxDate = null;
2302
+ // Early exit: SQL result rows are uniform — if the first row carries neither
2303
+ // timestamp column, none do, and the full O(rows) scan is pointless.
2304
+ if (results.length > 0 && results[0] && typeof results[0] === 'object') {
2305
+ const probe = results[0];
2306
+ if (probe['__mj_UpdatedAt'] === undefined && probe['UpdatedAt'] === undefined) {
2307
+ return '';
2308
+ }
2309
+ }
2029
2310
  for (const item of results) {
2030
2311
  if (item && typeof item === 'object') {
2031
2312
  const record = item;
@@ -2153,6 +2434,10 @@ export class ProviderBase {
2153
2434
  const eventId = TelemetryManager.Instance.StartEvent('RunView', 'ProviderBase.RunViews', {
2154
2435
  BatchSize: params.length,
2155
2436
  Entities: params.map(p => p.EntityName || p.ViewName || p.ViewID).filter(Boolean),
2437
+ // Per-view filter/orderBy parallel to Entities so the telemetry fingerprint can
2438
+ // tell apart two batches over the same entity set but with different filters.
2439
+ Filters: params.map(p => p.ExtraFilter),
2440
+ OrderBys: params.map(p => p.OrderBy),
2156
2441
  _fromEngine: fromEngine
2157
2442
  }, contextUser?.ID);
2158
2443
  // Store on first param for retrieval in PostProcessRunViews (using a special key to avoid collision)