@memberjunction/core 5.40.2 → 5.42.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.
Files changed (47) hide show
  1. package/dist/generic/RegisterForStartup.d.ts.map +1 -1
  2. package/dist/generic/RegisterForStartup.js +6 -5
  3. package/dist/generic/RegisterForStartup.js.map +1 -1
  4. package/dist/generic/baseEntity.d.ts +24 -10
  5. package/dist/generic/baseEntity.d.ts.map +1 -1
  6. package/dist/generic/baseEntity.js +78 -53
  7. package/dist/generic/baseEntity.js.map +1 -1
  8. package/dist/generic/baseRemotableOperation.d.ts +96 -0
  9. package/dist/generic/baseRemotableOperation.d.ts.map +1 -0
  10. package/dist/generic/baseRemotableOperation.js +114 -0
  11. package/dist/generic/baseRemotableOperation.js.map +1 -0
  12. package/dist/generic/databaseProviderBase.d.ts +7 -1
  13. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  14. package/dist/generic/databaseProviderBase.js +19 -7
  15. package/dist/generic/databaseProviderBase.js.map +1 -1
  16. package/dist/generic/entityInfo.d.ts +52 -1
  17. package/dist/generic/entityInfo.d.ts.map +1 -1
  18. package/dist/generic/entityInfo.js +135 -15
  19. package/dist/generic/entityInfo.js.map +1 -1
  20. package/dist/generic/interfaces.d.ts +93 -0
  21. package/dist/generic/interfaces.d.ts.map +1 -1
  22. package/dist/generic/interfaces.js.map +1 -1
  23. package/dist/generic/localCacheManager.d.ts +36 -0
  24. package/dist/generic/localCacheManager.d.ts.map +1 -1
  25. package/dist/generic/localCacheManager.js +136 -26
  26. package/dist/generic/localCacheManager.js.map +1 -1
  27. package/dist/generic/providerBase.d.ts +142 -5
  28. package/dist/generic/providerBase.d.ts.map +1 -1
  29. package/dist/generic/providerBase.js +415 -81
  30. package/dist/generic/providerBase.js.map +1 -1
  31. package/dist/generic/remoteOperationDispatch.d.ts +23 -0
  32. package/dist/generic/remoteOperationDispatch.d.ts.map +1 -0
  33. package/dist/generic/remoteOperationDispatch.js +52 -0
  34. package/dist/generic/remoteOperationDispatch.js.map +1 -0
  35. package/dist/generic/telemetryManager.d.ts +44 -0
  36. package/dist/generic/telemetryManager.d.ts.map +1 -1
  37. package/dist/generic/telemetryManager.js +128 -14
  38. package/dist/generic/telemetryManager.js.map +1 -1
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +2 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/views/runView.d.ts +36 -0
  44. package/dist/views/runView.d.ts.map +1 -1
  45. package/dist/views/runView.js.map +1 -1
  46. package/package.json +3 -3
  47. package/readme.md +23 -0
@@ -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
@@ -327,6 +392,46 @@ export class ProviderBase {
327
392
  // ========================================================================
328
393
  // PUBLIC API METHODS - Orchestrate Pre → Cache → Internal → Post flow
329
394
  // ========================================================================
395
+ /**
396
+ * Routes a typed Remote Operation by key to its implementation (see {@link IRemoteOperationProvider}).
397
+ *
398
+ * This is the public **power-tool** transport seam. Prefer the typed
399
+ * `BaseRemotableOperation.Execute()` entry point in application code — `RouteOperation` is the
400
+ * stringly-typed escape hatch for dynamic dispatch / generic tooling, not for building
401
+ * significant systems. Server providers override {@link InternalRouteOperation} to execute the
402
+ * operation in-process; the client (GraphQL) provider overrides it to marshal over the wire.
403
+ * Only registered, active (and, when AI-authored, approved) operations are routable, and every
404
+ * call is authorized on the server side.
405
+ *
406
+ * @param operationKey - Stable registry key of the operation (e.g. `RecordProcess.RunNow`).
407
+ * @param input - The operation's typed input payload.
408
+ * @param options - Optional invocation options (mode, progress callback, user, provider, fingerprint).
409
+ * @returns The operation result; never throws for logical failures — check `Success`/`ErrorMessage`.
410
+ */
411
+ async RouteOperation(operationKey, input, options) {
412
+ const key = operationKey?.trim();
413
+ if (!key) {
414
+ return { Success: false, ResultCode: 'INVALID_OPERATION_KEY', ErrorMessage: 'operationKey is required' };
415
+ }
416
+ return this.InternalRouteOperation(key, input, options ?? {});
417
+ }
418
+ /**
419
+ * Provider-specific transport for a Remote Operation. The default implementation reports that
420
+ * the provider does not support remote operations; concrete providers override it — server
421
+ * providers resolve and execute the operation in-process, the client provider marshals it over
422
+ * GraphQL. Kept as an overridable (non-abstract) hook so existing providers remain source-
423
+ * compatible until they opt in.
424
+ * @param operationKey - Trimmed, non-empty operation key (validated by {@link RouteOperation}).
425
+ * @param input - The operation's typed input payload.
426
+ * @param options - Invocation options (never undefined here; defaulted by {@link RouteOperation}).
427
+ */
428
+ InternalRouteOperation(operationKey, _input, _options) {
429
+ return Promise.resolve({
430
+ Success: false,
431
+ ResultCode: 'NOT_SUPPORTED',
432
+ ErrorMessage: `This provider does not support remote operations (operationKey='${operationKey}')`,
433
+ });
434
+ }
330
435
  /**
331
436
  * Runs a view based on the provided parameters.
332
437
  * This method orchestrates the full execution flow: pre-processing, cache check,
@@ -336,6 +441,10 @@ export class ProviderBase {
336
441
  * @returns The view results
337
442
  */
338
443
  async RunView(params, contextUser) {
444
+ // Shallow-clone so the pipeline's in-place modifications (PlatformSQL resolution,
445
+ // Fields widening for cache-superset storage) never leak into the CALLER's params
446
+ // object — reusing a params object across calls must be safe.
447
+ params = { ...params };
339
448
  // Keyset (AfterKey) queries always bypass the server cache: each call uses a
340
449
  // different seek key, so a cached entry would never be reusable. Treat them like
341
450
  // explicit BypassCache=true requests.
@@ -383,6 +492,10 @@ export class ProviderBase {
383
492
  * @returns Array of view results (shallow-copied Results per caller)
384
493
  */
385
494
  async RunViews(params, contextUser) {
495
+ // Shallow-clone every param so the pipeline's in-place modifications (PlatformSQL
496
+ // resolution, Fields widening for cache-superset storage) never leak into the
497
+ // CALLER's objects — reusing params across calls must be safe.
498
+ params = params.map(p => ({ ...p }));
386
499
  // Bypass dedup for side-effect calls (SaveViewResults creates DB records)
387
500
  if (this.ShouldBypassDedup(params)) {
388
501
  return this.ExecuteRunViewsPipeline(params, contextUser);
@@ -422,13 +535,16 @@ export class ProviderBase {
422
535
  // ── Fresh execution ──
423
536
  const promise = this.ExecuteRunViewsPipeline(params, contextUser)
424
537
  .then(results => {
425
- // Stash resolved results for the linger window
538
+ // Stash resolved results for the linger window. Safety cap: under
539
+ // extreme churn (hundreds of distinct keys resolving within one linger
540
+ // window) skip lingering rather than hold more result arrays in memory —
541
+ // the linger is a latency optimization, never a correctness requirement.
426
542
  const entry = this._inflightViews.get(key);
427
543
  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) {
544
+ if (ProviderBase.DedupLingerMs > 0 && this._inflightViews.size <= ProviderBase.MaxLingerEntries) {
545
+ entry.resolvedResults = results;
546
+ entry.resolvedAt = Date.now();
547
+ // Schedule cleanup after linger expires
432
548
  setTimeout(() => {
433
549
  const current = this._inflightViews.get(key);
434
550
  if (current && current.promise === promise) {
@@ -751,15 +867,67 @@ export class ProviderBase {
751
867
  /**
752
868
  * Generates a deterministic dedup key for a batch of RunViewParams.
753
869
  * Extends the local-cache fingerprint with additional fields that
754
- * affect result identity (Fields, UserSearchString, ViewID, ViewName,
755
- * contextUser).
756
- */
870
+ * affect result identity (Fields, ResultType, UserSearchString, ViewID,
871
+ * ViewName, contextUser).
872
+ *
873
+ * Unlike the cache fingerprint — which deliberately excludes Fields and
874
+ * ResultType because the cache stores the full-width superset and projects
875
+ * / transforms per-read — the dedup layer shares the FINAL pipeline output:
876
+ * results already projected to one caller's Fields and already transformed
877
+ * per that caller's ResultType. A linger or in-flight hit hands those rows
878
+ * to the next caller verbatim (shallow array copy only), so callers with
879
+ * different Fields or ResultType must NOT share a dedup slot or the second
880
+ * caller silently receives the first caller's shape.
881
+ */
882
+ /**
883
+ * Single source of truth for whether a RunView call participates in the local
884
+ * cache (both READ and WRITE). Pre/Post hooks for the singular and batch paths
885
+ * must all use this predicate — historically each site recomputed it inline and
886
+ * they drifted (PostRunViews wrote BypassCache results into the cache, poisoning
887
+ * the Fields-agnostic superset slot with narrow rows).
888
+ *
889
+ * Ineligible:
890
+ * - `BypassCache` — caller explicitly wants true DB state, no cache interaction
891
+ * - `AfterKey` — keyset pages are single-use AND the fingerprint doesn't include
892
+ * the seek key, so caching a page would poison the entity+filter slot
893
+ * - `ResultType 'count_only'` — returns no rows; caching its empty Results under
894
+ * a fingerprint that excludes ResultType would poison row queries
895
+ * - entities where server caching is disallowed
896
+ */
897
+ runViewCacheEligible(param) {
898
+ return !param.BypassCache &&
899
+ !param.AfterKey &&
900
+ param.ResultType !== 'count_only' &&
901
+ (param.CacheLocal === true || this.TrustLocalCacheCompletely) &&
902
+ this.IsServerCacheAllowedForEntity(param);
903
+ }
904
+ /**
905
+ * Returns the caller's requested fields (lowercased) unioned with the entity's
906
+ * primary key field names. Platform contract: when `Fields` is explicitly
907
+ * specified, results ALWAYS include the primary key(s) — the direct SQL path has
908
+ * always done this, differential smart-cache merges require it, and entity
909
+ * linking in UIs depends on it. Applying the same union at every projection site
910
+ * keeps result shapes identical across cached, non-cached, and smart-cache paths.
911
+ */
912
+ static UnionFieldsWithPrimaryKeys(fields, entity) {
913
+ const result = [...fields];
914
+ const present = new Set(fields);
915
+ // Defensive ?? [] — virtual entities can be PK-less, and test doubles may not model PrimaryKeys
916
+ for (const pk of entity.PrimaryKeys ?? []) {
917
+ const name = pk.Name.trim().toLowerCase();
918
+ if (!present.has(name)) {
919
+ present.add(name);
920
+ result.push(name);
921
+ }
922
+ }
923
+ return result;
924
+ }
757
925
  GenerateDedupKey(params, contextUser) {
758
926
  const parts = params.map(p => {
759
927
  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
928
  const extras = [
929
+ ProviderBase.NormalizeFieldsKey(p.Fields),
930
+ p.ResultType ?? 'simple',
763
931
  p.UserSearchString ?? '',
764
932
  p.ViewID ?? '',
765
933
  p.ViewName ?? '',
@@ -769,6 +937,54 @@ export class ProviderBase {
769
937
  });
770
938
  return parts.join('||');
771
939
  }
940
+ /**
941
+ * Normalizes a Fields list into a stable key segment: trimmed, lowercased,
942
+ * sorted, comma-joined — `'*'` when the caller wants all fields. Matches the
943
+ * matching semantics of `ProjectRowsToFields` (trim + lowercase) so that
944
+ * semantically identical requests collapse to the same key. Used by both the
945
+ * request-dedup key and the client-side cache fingerprint.
946
+ */
947
+ static NormalizeFieldsKey(fields) {
948
+ return fields && fields.length > 0
949
+ ? fields.map(f => f.trim().toLowerCase()).sort().join(',')
950
+ : '*';
951
+ }
952
+ /**
953
+ * Client-side cache fingerprint: the shared RunView fingerprint plus a
954
+ * normalized Fields suffix (`|f:<fields>` or `|f:*`).
955
+ *
956
+ * Why the client fingerprint includes Fields when the server's deliberately
957
+ * does NOT: the server cache widens every cacheable query to ALL entity
958
+ * fields before the DB hit, stores one full-width superset per entity+filter,
959
+ * and projects per-read — so a single Fields-agnostic slot can serve any
960
+ * field subset. The client smart-cache flow does NOT widen (narrow wire
961
+ * payloads are the point of `Fields` client-side) and does NOT project on
962
+ * read: rows are stored exactly as the server returned them. Under a
963
+ * Fields-agnostic fingerprint, a narrow entry would pass the staleness check
964
+ * for a DIFFERENT field subset of the same entity+filter — `maxUpdatedAt`
965
+ * and `rowCount` are column-independent — and silently serve rows missing
966
+ * the newly requested columns. Per-Fields slots make client entries
967
+ * exact-match only: each field subset stores, validates, and serves its own
968
+ * shape. (Subset-serving from wider entries was considered and deliberately
969
+ * rejected: it requires candidate enumeration, per-entry field metadata, and
970
+ * careful staleness attribution for marginal hit-rate gains.)
971
+ */
972
+ clientCacheFingerprint(param) {
973
+ // Memoized per params object: RunViews shallow-clones params once at entry and
974
+ // the SAME object references flow through prepare → execute → process, where this
975
+ // fingerprint was previously recomputed up to 3× per param (string building +
976
+ // Fields normalization each time). Safe because params are not mutated after the
977
+ // first computation (entity_object Fields widening happens in
978
+ // prepareSmartCacheCheckParams BEFORE the first fingerprint call).
979
+ const memoized = this._clientFingerprintMemo.get(param);
980
+ if (memoized) {
981
+ return memoized;
982
+ }
983
+ const base = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
984
+ const fingerprint = `${base}|f:${ProviderBase.NormalizeFieldsKey(param.Fields)}`;
985
+ this._clientFingerprintMemo.set(param, fingerprint);
986
+ return fingerprint;
987
+ }
772
988
  /**
773
989
  * Ranked search over **one** entity's records. See {@link IMetadataProvider.SearchEntity}
774
990
  * for the contract and how this differs from {@link EntityByName} /
@@ -1055,6 +1271,80 @@ export class ProviderBase {
1055
1271
  * @returns The query results
1056
1272
  */
1057
1273
  async RunQuery(params, contextUser) {
1274
+ // Shallow-clone for symmetry with RunView — pipeline must never mutate caller objects
1275
+ params = { ...params };
1276
+ // ── CacheLocal: the RunQuery result cache (RunQueryCache category) ──
1277
+ // Engages ONLY on explicit opt-in. Saved queries only (QueryID/QueryName) —
1278
+ // ad-hoc SQL is never cached. Semantics per RunQueryParams JSDoc:
1279
+ // - cached + unexpired + provider supports RunQueriesWithCacheCheck (client):
1280
+ // server validates via the Query's CacheValidationSQL → 'current' serves the
1281
+ // local slot, 'stale'/'no_validation' returns fresh rows and rewrites the slot
1282
+ // - cached + unexpired + no validation transport (server providers): TTL mode —
1283
+ // serve directly until expiry
1284
+ // - miss/expired: run normally, then store with TTL (CacheLocalTTL override,
1285
+ // else the LocalCacheManager default)
1286
+ const queryCacheEngaged = params.CacheLocal === true
1287
+ && !params.SQL
1288
+ && (!!params.QueryID || !!params.QueryName)
1289
+ && LocalCacheManager.Instance.IsInitialized;
1290
+ let queryFingerprint;
1291
+ if (queryCacheEngaged) {
1292
+ // MaxRows/StartRow shape the result set — they MUST distinguish cache slots,
1293
+ // so fold them into the parameters portion of the fingerprint.
1294
+ const fingerprintParams = {
1295
+ ...(params.Parameters ?? {}),
1296
+ __maxRows: params.MaxRows ?? -1,
1297
+ __startRow: params.StartRow ?? 0
1298
+ };
1299
+ queryFingerprint = LocalCacheManager.Instance.GenerateRunQueryFingerprint(params.QueryID, params.QueryName, fingerprintParams, this.InstanceConnectionString);
1300
+ const cached = await LocalCacheManager.Instance.GetRunQueryResult(queryFingerprint); // TTL-enforced
1301
+ if (cached) {
1302
+ const serveFromSlot = () => ({
1303
+ QueryID: cached.queryId ?? params.QueryID ?? '',
1304
+ QueryName: params.QueryName ?? '',
1305
+ Success: true,
1306
+ Results: cached.results,
1307
+ RowCount: cached.results.length,
1308
+ TotalRowCount: cached.rowCount ?? cached.results.length,
1309
+ ExecutionTime: 0,
1310
+ ErrorMessage: '',
1311
+ CacheHit: true,
1312
+ CacheKey: queryFingerprint
1313
+ });
1314
+ const checker = this.RunQueriesWithCacheCheck?.bind(this);
1315
+ if (!checker || this.TrustLocalCacheCompletely) {
1316
+ // TTL mode (server providers / no validation transport)
1317
+ return serveFromSlot();
1318
+ }
1319
+ // Client smart validation round trip
1320
+ const response = await checker([{
1321
+ params,
1322
+ cacheStatus: { maxUpdatedAt: cached.maxUpdatedAt, rowCount: cached.rowCount }
1323
+ }], contextUser);
1324
+ const check = response.results?.[0];
1325
+ if (response.success && check) {
1326
+ if (check.status === 'current') {
1327
+ return serveFromSlot();
1328
+ }
1329
+ if ((check.status === 'stale' || check.status === 'no_validation') && check.results) {
1330
+ const freshRows = check.results;
1331
+ // Fire-and-forget slot rewrite — same pattern as the RunView client path
1332
+ LocalCacheManager.Instance.SetRunQueryResult(queryFingerprint, params.QueryName ?? '', freshRows, check.maxUpdatedAt ?? '', check.rowCount, check.queryId, params.CacheLocalTTL).catch(e => LogError(`RunQuery cache rewrite failed: ${e}`));
1333
+ return {
1334
+ QueryID: check.queryId ?? params.QueryID ?? '',
1335
+ QueryName: params.QueryName ?? '',
1336
+ Success: true,
1337
+ Results: freshRows,
1338
+ RowCount: freshRows.length,
1339
+ TotalRowCount: check.rowCount ?? freshRows.length,
1340
+ ExecutionTime: 0,
1341
+ ErrorMessage: ''
1342
+ };
1343
+ }
1344
+ }
1345
+ // validation transport failed — fall through to a normal execution
1346
+ }
1347
+ }
1058
1348
  // Pre-processing: telemetry, cache check
1059
1349
  const preResult = await this.PreRunQuery(params, contextUser);
1060
1350
  // Check for cached result - end telemetry with cache hit info
@@ -1070,6 +1360,12 @@ export class ProviderBase {
1070
1360
  const result = await this.InternalRunQuery(params, contextUser);
1071
1361
  // Post-processing: cache storage, telemetry end
1072
1362
  await this.PostRunQuery(result, params, preResult, contextUser);
1363
+ // Store in the RunQuery cache on success (fire-and-forget; TTL per CacheLocalTTL
1364
+ // or the LocalCacheManager default). maxUpdatedAt is unknown for a plain run —
1365
+ // the smart-validation path stamps it when the Query has CacheValidationSQL.
1366
+ if (queryCacheEngaged && queryFingerprint && result.Success) {
1367
+ LocalCacheManager.Instance.SetRunQueryResult(queryFingerprint, result.QueryName, result.Results, '', result.TotalRowCount, result.QueryID, params.CacheLocalTTL).catch(e => LogError(`RunQuery cache write failed: ${e}`));
1368
+ }
1073
1369
  return result;
1074
1370
  }
1075
1371
  /**
@@ -1227,7 +1523,9 @@ export class ProviderBase {
1227
1523
  MaxRows: params.MaxRows,
1228
1524
  StartRow: params.StartRow,
1229
1525
  CacheLocal: params.CacheLocal,
1230
- _fromEngine: params._fromEngine
1526
+ _fromEngine: params._fromEngine,
1527
+ Exempt: params.Telemetry?.Exempt,
1528
+ ExemptReason: params.Telemetry?.Reason
1231
1529
  }, contextUser?.ID);
1232
1530
  const telemetryTime = performance.now() - telemetryStart;
1233
1531
  // Entity status check
@@ -1238,22 +1536,21 @@ export class ProviderBase {
1238
1536
  // We always fetch ALL fields from the DB so the cache entry is a universal superset
1239
1537
  // that satisfies any future query for the same entity+filter regardless of field subset.
1240
1538
  const entityLookupStart = performance.now();
1241
- const callerRequestedFields = params.Fields && params.Fields.length > 0
1539
+ let callerRequestedFields = params.Fields && params.Fields.length > 0
1242
1540
  ? params.Fields.map(f => f.trim().toLowerCase())
1243
1541
  : null; // null = caller wants all fields
1244
1542
  // Only override Fields to all entity fields when caching will actually happen
1245
1543
  // for this call. For non-cached calls we respect the caller's narrow Fields
1246
1544
  // end-to-end — there's no cache-coherence concern to preserve.
1247
1545
  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;
1546
+ const willCache = this.runViewCacheEligible(params);
1255
1547
  if (entity && willCache) {
1256
1548
  params.Fields = entity.Fields.map(f => f.Name);
1549
+ // Platform contract: explicit Fields always include the primary key(s) —
1550
+ // project back down to requested ∪ PK, matching the direct SQL path.
1551
+ if (callerRequestedFields) {
1552
+ callerRequestedFields = ProviderBase.UnionFieldsWithPrimaryKeys(callerRequestedFields, entity);
1553
+ }
1257
1554
  }
1258
1555
  const entityLookupTime = performance.now() - entityLookupStart;
1259
1556
  // Check local cache if enabled
@@ -1269,23 +1566,7 @@ export class ProviderBase {
1269
1566
  // Filter cached results to only the caller's requested fields (if specified)
1270
1567
  let results = cached.results;
1271
1568
  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
- });
1569
+ results = ProjectRowsToFields(results, callerRequestedFields);
1289
1570
  }
1290
1571
  // Reconstruct RunViewResult from cached data
1291
1572
  cachedResult = {
@@ -1316,7 +1597,10 @@ export class ProviderBase {
1316
1597
  telemetryEventId,
1317
1598
  cacheStatus,
1318
1599
  cachedResult,
1319
- fingerprint
1600
+ fingerprint,
1601
+ // Only non-null when params.Fields was actually widened above — tells
1602
+ // PostRunView to project cache-miss DB results back to the caller's shape
1603
+ callerRequestedFields: (entity && willCache) ? callerRequestedFields : null
1320
1604
  };
1321
1605
  }
1322
1606
  /**
@@ -1340,6 +1624,10 @@ export class ProviderBase {
1340
1624
  const telemetryEventId = TelemetryManager.Instance.StartEvent('RunView', 'ProviderBase.RunViews', {
1341
1625
  BatchSize: params.length,
1342
1626
  Entities: params.map(p => p.EntityName || p.ViewName || p.ViewID).filter(Boolean),
1627
+ // Per-view filter/orderBy parallel to Entities so the telemetry fingerprint can
1628
+ // tell apart two batches over the same entity set but with different filters.
1629
+ Filters: params.map(p => p.ExtraFilter),
1630
+ OrderBys: params.map(p => p.OrderBy),
1343
1631
  _fromEngine: fromEngine
1344
1632
  }, contextUser?.ID);
1345
1633
  // Client-side providers route any CacheLocal params through smart-cache-check:
@@ -1355,6 +1643,8 @@ export class ProviderBase {
1355
1643
  }
1356
1644
  // Traditional caching flow
1357
1645
  const cacheStatusMap = new Map();
1646
+ const callerFieldsMap = new Map();
1647
+ const fingerprintMap = new Map();
1358
1648
  const uncachedParams = [];
1359
1649
  const cachedResults = [];
1360
1650
  let allCached = true;
@@ -1364,21 +1654,23 @@ export class ProviderBase {
1364
1654
  await this.EntityStatusCheck(param, 'PreRunViews');
1365
1655
  // Save caller's original Fields, then always fetch all fields from DB.
1366
1656
  // One cache entry per entity+filter satisfies all field subsets.
1367
- const callerFields = param.Fields && param.Fields.length > 0
1657
+ let callerFields = param.Fields && param.Fields.length > 0
1368
1658
  ? param.Fields.map(f => f.trim().toLowerCase())
1369
1659
  : null;
1370
1660
  // Only override Fields to all entity fields when caching will actually happen
1371
1661
  // for this call. For non-cached calls we respect the caller's narrow Fields
1372
1662
  // end-to-end — there's no cache-coherence concern to preserve.
1373
1663
  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;
1664
+ const batchWillCache = this.runViewCacheEligible(param);
1380
1665
  if (batchEntity && batchWillCache) {
1381
1666
  param.Fields = batchEntity.Fields.map(f => f.Name);
1667
+ // Platform contract: explicit Fields always include the primary key(s)
1668
+ if (callerFields) {
1669
+ callerFields = ProviderBase.UnionFieldsWithPrimaryKeys(callerFields, batchEntity);
1670
+ // Remember the caller's original shape so PostRunViews can project
1671
+ // cache-miss DB results back down to it
1672
+ callerFieldsMap.set(i, callerFields);
1673
+ }
1382
1674
  }
1383
1675
  // Check local cache if enabled or if server trusts its cache completely
1384
1676
  // BypassCache skips cache entirely — used by maintenance actions querying for
@@ -1386,29 +1678,13 @@ export class ProviderBase {
1386
1678
  if (batchWillCache && LocalCacheManager.Instance.IsInitialized) {
1387
1679
  const rlsWhereClause = this.ComputeRunViewRLSWhereClause(param, contextUser);
1388
1680
  const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString, rlsWhereClause);
1681
+ fingerprintMap.set(i, fingerprint);
1389
1682
  const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
1390
1683
  if (cached) {
1391
1684
  // Filter cached results to caller's requested fields (if specified and not entity_object)
1392
1685
  let results = cached.results;
1393
1686
  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
- });
1687
+ results = ProjectRowsToFields(results, callerFields);
1412
1688
  }
1413
1689
  const cachedViewResult = {
1414
1690
  Success: true,
@@ -1448,7 +1724,9 @@ export class ProviderBase {
1448
1724
  ? cachedResults.filter(r => r !== null)
1449
1725
  : (hasCacheHits ? cachedResults : undefined),
1450
1726
  uncachedParams: allCached ? undefined : uncachedParams,
1451
- cacheStatusMap
1727
+ cacheStatusMap,
1728
+ callerFieldsMap: callerFieldsMap.size > 0 ? callerFieldsMap : undefined,
1729
+ fingerprintMap: fingerprintMap.size > 0 ? fingerprintMap : undefined
1452
1730
  };
1453
1731
  }
1454
1732
  /**
@@ -1472,8 +1750,7 @@ export class ProviderBase {
1472
1750
  param.Fields = entity.Fields.map(f => f.Name);
1473
1751
  }
1474
1752
  if (param.CacheLocal && LocalCacheManager.Instance.IsInitialized) {
1475
- const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
1476
- cacheable.push({ paramIndex: i, fingerprint });
1753
+ cacheable.push({ paramIndex: i, fingerprint: this.clientCacheFingerprint(param) });
1477
1754
  }
1478
1755
  }
1479
1756
  // Phase 2 — batched read: one IDB transaction (or one Redis MGET) returns
@@ -1541,7 +1818,7 @@ export class ProviderBase {
1541
1818
  const currentFingerprints = [];
1542
1819
  for (const sr of response.results) {
1543
1820
  if (sr.status === 'current' && params[sr.viewIndex]) {
1544
- currentFingerprints.push(LocalCacheManager.Instance.GenerateRunViewFingerprint(params[sr.viewIndex], this.InstanceConnectionString));
1821
+ currentFingerprints.push(this.clientCacheFingerprint(params[sr.viewIndex]));
1545
1822
  }
1546
1823
  }
1547
1824
  const preResolvedCache = currentFingerprints.length > 0
@@ -1606,7 +1883,7 @@ export class ProviderBase {
1606
1883
  // Cache is current - use the pre-resolved cache entry from the batched read
1607
1884
  // (executeSmartCacheCheck reads all 'current' fingerprints in one IDB
1608
1885
  // transaction up front, so we don't pay per-param transaction overhead here).
1609
- const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
1886
+ const fingerprint = this.clientCacheFingerprint(param);
1610
1887
  const cached = preResolvedCache.get(fingerprint) ?? null;
1611
1888
  if (cached) {
1612
1889
  const cachedResult = {
@@ -1642,7 +1919,7 @@ export class ProviderBase {
1642
1919
  }
1643
1920
  else if (checkResult.status === 'differential') {
1644
1921
  // Cache is stale but we have differential data - merge with cached data
1645
- const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
1922
+ const fingerprint = this.clientCacheFingerprint(param);
1646
1923
  // Get entity info for primary key field name
1647
1924
  const entity = this.EntityByName(param.EntityName);
1648
1925
  const primaryKeyFieldName = entity?.FirstPrimaryKey?.Name || 'ID';
@@ -1689,7 +1966,7 @@ export class ProviderBase {
1689
1966
  };
1690
1967
  // Update the local cache with fresh data (don't await - fire and forget for performance)
1691
1968
  if (param.CacheLocal && checkResult.maxUpdatedAt && LocalCacheManager.Instance.IsInitialized) {
1692
- const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
1969
+ const fingerprint = this.clientCacheFingerprint(param);
1693
1970
  // Note: We don't await here to avoid blocking the response
1694
1971
  // Cache update happens in background
1695
1972
  LocalCacheManager.Instance.SetRunViewResult(fingerprint, param, checkResult.results || [], checkResult.maxUpdatedAt, checkResult.aggregateResults, // Include aggregate results in cache
@@ -1782,8 +2059,10 @@ export class ProviderBase {
1782
2059
  // with circular subscriber references that break JSON.stringify.
1783
2060
  // On cache read, TransformSimpleObjectToEntityObject is called to restore
1784
2061
  // 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) {
2062
+ // runViewCacheEligible is the same predicate PreRunView used to decide whether to
2063
+ // widen Fields — only widened (superset) results may be written to the cache.
2064
+ // preResult.fingerprint doubles as a guard (only computed when eligible).
2065
+ if (this.runViewCacheEligible(params) && result.Success && preResult.fingerprint && LocalCacheManager.Instance.IsInitialized) {
1787
2066
  const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
1788
2067
  await LocalCacheManager.Instance.SetRunViewResult(preResult.fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
1789
2068
  }
@@ -1796,6 +2075,15 @@ export class ProviderBase {
1796
2075
  await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
1797
2076
  LogStatusEx({ message: ` 📦 [Auto-Cache] RunView "${params.EntityName || params.ViewName || 'unknown'}" — ${result.Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
1798
2077
  }
2078
+ // Project cache-miss DB results back down to the caller's originally requested
2079
+ // fields. PreRunView widened params.Fields to ALL entity fields so the cache
2080
+ // entry (written above) is a universal superset — but the caller must receive
2081
+ // the same shape on a miss as they do on a hit (which projects from cache).
2082
+ // Must run AFTER the cache writes (cache keeps the superset) and only for
2083
+ // plain-object results (entity objects need all fields).
2084
+ if (result.Success && preResult.callerRequestedFields && params.ResultType !== 'entity_object') {
2085
+ result.Results = ProjectRowsToFields(result.Results, preResult.callerRequestedFields);
2086
+ }
1799
2087
  // Transform the result set into BaseEntity-derived objects, if needed
1800
2088
  await this.TransformSimpleObjectToEntityObject(params, result, contextUser);
1801
2089
  // Run registered PostRunView hooks (e.g., data masking, audit logging)
@@ -1834,10 +2122,17 @@ export class ProviderBase {
1834
2122
  if (cacheInfo?.status === 'hit') {
1835
2123
  continue;
1836
2124
  }
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) {
2125
+ // Reuse the fingerprint PreRunViews already computed for this index —
2126
+ // recomputing means rebuilding the RLS where-clause + fingerprint string
2127
+ // per item. Compute lazily only for indexes PreRunViews skipped (cache
2128
+ // was disabled for them but auto-cache/OnDataChanged may still need it).
2129
+ const fingerprint = preResult.fingerprintMap?.get(i)
2130
+ ?? LocalCacheManager.Instance.GenerateRunViewFingerprint(params[i], this.InstanceConnectionString, this.ComputeRunViewRLSWhereClause(params[i], contextUser));
2131
+ // CRITICAL: must be the SAME eligibility predicate PreRunViews used to decide
2132
+ // whether to widen Fields. Writing a non-widened (narrow or keyset-paged)
2133
+ // result here poisons the Fields-agnostic superset slot — this exact gate
2134
+ // previously omitted BypassCache/AfterKey and cached narrow BypassCache rows.
2135
+ if (this.runViewCacheEligible(params[i]) && results[i].Success && LocalCacheManager.Instance.IsInitialized) {
1841
2136
  const maxUpdatedAt = this.extractMaxUpdatedAt(results[i].Results);
1842
2137
  cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount, this));
1843
2138
  }
@@ -1852,6 +2147,24 @@ export class ProviderBase {
1852
2147
  }
1853
2148
  }
1854
2149
  await Promise.all(cachePromises);
2150
+ // Project cache-miss DB results back down to each caller's originally
2151
+ // requested fields. PreRunViews widened those params' Fields to ALL entity
2152
+ // fields so the cache entries (written above) are universal supersets — but
2153
+ // callers must receive the same shape on a miss as on a hit (which projects
2154
+ // from cache). Skip hits (already projected) and entity_object results
2155
+ // (need all fields).
2156
+ if (preResult.callerFieldsMap) {
2157
+ for (let i = 0; i < results.length; i++) {
2158
+ const cacheInfo = preResult.cacheStatusMap?.get(i);
2159
+ if (cacheInfo?.status === 'hit') {
2160
+ continue;
2161
+ }
2162
+ const callerFields = preResult.callerFieldsMap.get(i);
2163
+ if (callerFields && results[i].Success && params[i].ResultType !== 'entity_object') {
2164
+ results[i].Results = ProjectRowsToFields(results[i].Results, callerFields);
2165
+ }
2166
+ }
2167
+ }
1855
2168
  // Transform results to entity objects AFTER caching plain objects.
1856
2169
  // Skip results that came from cache hits — they're already entity objects.
1857
2170
  const transformPromises = [];
@@ -2001,16 +2314,18 @@ export class ProviderBase {
2001
2314
  shouldAutoCache(params, result) {
2002
2315
  if (!this.TrustLocalCacheCompletely)
2003
2316
  return false;
2004
- if (params.BypassCache)
2005
- return false; // caller explicitly wants no caching
2006
2317
  if (params.CacheLocal)
2007
2318
  return false; // already handled
2319
+ // Same eligibility predicate as the main cache path — covers BypassCache,
2320
+ // AfterKey (keyset pages), count_only, and cache-disallowed entities. An
2321
+ // auto-cached keyset page or count_only result would poison the
2322
+ // entity+filter slot just like the main-path variants of those bugs.
2323
+ if (!this.runViewCacheEligible(params))
2324
+ return false;
2008
2325
  if (!LocalCacheManager.Instance.IsInitialized)
2009
2326
  return false;
2010
2327
  if (!result.Success)
2011
2328
  return false;
2012
- if (!this.IsServerCacheAllowedForEntity(params))
2013
- return false;
2014
2329
  if (ProviderBase.ServerAutoCacheMaxRows <= 0)
2015
2330
  return false;
2016
2331
  if ((result.Results?.length ?? 0) > ProviderBase.ServerAutoCacheMaxRows)
@@ -2026,6 +2341,14 @@ export class ProviderBase {
2026
2341
  }
2027
2342
  extractMaxUpdatedAt(results) {
2028
2343
  let maxDate = null;
2344
+ // Early exit: SQL result rows are uniform — if the first row carries neither
2345
+ // timestamp column, none do, and the full O(rows) scan is pointless.
2346
+ if (results.length > 0 && results[0] && typeof results[0] === 'object') {
2347
+ const probe = results[0];
2348
+ if (probe['__mj_UpdatedAt'] === undefined && probe['UpdatedAt'] === undefined) {
2349
+ return '';
2350
+ }
2351
+ }
2029
2352
  for (const item of results) {
2030
2353
  if (item && typeof item === 'object') {
2031
2354
  const record = item;
@@ -2109,7 +2432,9 @@ export class ProviderBase {
2109
2432
  ResultType: params.ResultType,
2110
2433
  MaxRows: params.MaxRows,
2111
2434
  StartRow: params.StartRow,
2112
- _fromEngine: params._fromEngine
2435
+ _fromEngine: params._fromEngine,
2436
+ Exempt: params.Telemetry?.Exempt,
2437
+ ExemptReason: params.Telemetry?.Reason
2113
2438
  }, contextUser?.ID);
2114
2439
  // Store on params object for retrieval in PostProcessRunView
2115
2440
  params._telemetryEventId = eventId;
@@ -2150,10 +2475,19 @@ export class ProviderBase {
2150
2475
  async PreProcessRunViews(params, contextUser) {
2151
2476
  // Start telemetry tracking for batch operation
2152
2477
  const fromEngine = params.some(p => p._fromEngine);
2478
+ // A batch is exempt only when EVERY constituent view opts out — a mixed batch should still
2479
+ // be analyzed. Reason is taken from the first view that supplied one.
2480
+ const batchExempt = params.length > 0 && params.every(p => p.Telemetry?.Exempt);
2153
2481
  const eventId = TelemetryManager.Instance.StartEvent('RunView', 'ProviderBase.RunViews', {
2154
2482
  BatchSize: params.length,
2155
2483
  Entities: params.map(p => p.EntityName || p.ViewName || p.ViewID).filter(Boolean),
2156
- _fromEngine: fromEngine
2484
+ // Per-view filter/orderBy parallel to Entities so the telemetry fingerprint can
2485
+ // tell apart two batches over the same entity set but with different filters.
2486
+ Filters: params.map(p => p.ExtraFilter),
2487
+ OrderBys: params.map(p => p.OrderBy),
2488
+ _fromEngine: fromEngine,
2489
+ Exempt: batchExempt,
2490
+ ExemptReason: params.find(p => p.Telemetry?.Reason)?.Telemetry?.Reason
2157
2491
  }, contextUser?.ID);
2158
2492
  // Store on first param for retrieval in PostProcessRunViews (using a special key to avoid collision)
2159
2493
  if (params.length > 0) {