@memberjunction/core 5.47.0 → 5.49.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 (36) hide show
  1. package/dist/generic/RegisterForStartup.d.ts +78 -1
  2. package/dist/generic/RegisterForStartup.d.ts.map +1 -1
  3. package/dist/generic/RegisterForStartup.js +108 -7
  4. package/dist/generic/RegisterForStartup.js.map +1 -1
  5. package/dist/generic/baseEntity.d.ts +5 -0
  6. package/dist/generic/baseEntity.d.ts.map +1 -1
  7. package/dist/generic/baseEntity.js +25 -4
  8. package/dist/generic/baseEntity.js.map +1 -1
  9. package/dist/generic/compositeKey.d.ts.map +1 -1
  10. package/dist/generic/compositeKey.js +8 -1
  11. package/dist/generic/compositeKey.js.map +1 -1
  12. package/dist/generic/interfaces.d.ts +7 -0
  13. package/dist/generic/interfaces.d.ts.map +1 -1
  14. package/dist/generic/interfaces.js.map +1 -1
  15. package/dist/generic/localCacheManager.d.ts +179 -7
  16. package/dist/generic/localCacheManager.d.ts.map +1 -1
  17. package/dist/generic/localCacheManager.js +409 -26
  18. package/dist/generic/localCacheManager.js.map +1 -1
  19. package/dist/generic/permissionInterfaces.d.ts +11 -0
  20. package/dist/generic/permissionInterfaces.d.ts.map +1 -1
  21. package/dist/generic/permissionInterfaces.js +13 -2
  22. package/dist/generic/permissionInterfaces.js.map +1 -1
  23. package/dist/generic/providerBase.d.ts +87 -4
  24. package/dist/generic/providerBase.d.ts.map +1 -1
  25. package/dist/generic/providerBase.js +325 -62
  26. package/dist/generic/providerBase.js.map +1 -1
  27. package/dist/generic/util.d.ts.map +1 -1
  28. package/dist/generic/util.js +6 -0
  29. package/dist/generic/util.js.map +1 -1
  30. package/dist/views/runView.d.ts +1 -1
  31. package/dist/views/runView.d.ts.map +1 -1
  32. package/dist/views/runView.js +18 -3
  33. package/dist/views/runView.js.map +1 -1
  34. package/dist/views/viewInfo.js +1 -1
  35. package/dist/views/viewInfo.js.map +1 -1
  36. package/package.json +3 -3
@@ -5,7 +5,7 @@ import { ComputeRRF } from "./scoring/ReciprocalRankFusion.js";
5
5
  import { LocalCacheManager } from "./localCacheManager.js";
6
6
  import { ApplicationInfo } from "../generic/applicationInfo.js";
7
7
  import { AuditLogTypeInfo, AuthorizationInfo, AuthorizationRoleInfo, RoleInfo, RowLevelSecurityFilterInfo, UserInfo } from "./securityInfo.js";
8
- import { MJGlobal, MJEventType, NormalizeUUID, UUIDsEqual } from "@memberjunction/global";
8
+ import { MJGlobal, MJEventType, NormalizeUUID, UUIDsEqual, MJLruCache } from "@memberjunction/global";
9
9
  import { TelemetryManager } from "./telemetryManager.js";
10
10
  import { LogError, LogStatus, LogStatusEx } from "./logging.js";
11
11
  import { QueryCategoryInfo, QueryFieldInfo, QueryInfo, QueryPermissionInfo, QueryEntityInfo, QueryParameterInfo, QueryDependencyInfo, SQLDialectInfo, QuerySQLInfo } from "./queryInfo.js";
@@ -81,12 +81,6 @@ export function MetadataFromSimpleObjectWithoutUser(data, md) {
81
81
  return undefined;
82
82
  }
83
83
  }
84
- /**
85
- * This is a list of all metadata classes that are used in the AllMetadata class.
86
- * Used to automatically determine the class type when deserializing the metadata and
87
- * for iterating through all metadata collections.
88
- * Each entry maps a property key to its corresponding class constructor.
89
- */
90
84
  export const AllMetadataArrays = [
91
85
  { key: 'AllEntities', class: EntityInfo },
92
86
  { key: 'AllApplications', class: ApplicationInfo },
@@ -180,7 +174,10 @@ export class ProviderBase {
180
174
  this._localMetadata = new AllMetadata();
181
175
  this._entityMapByName = new Map();
182
176
  this._entityMapByID = new Map();
183
- this._entityRecordNameCache = new Map();
177
+ // Bounded LRU (unlike its siblings above, this cache holds one entry per distinct
178
+ // *record* touched via Load()/Save()/LoadFromData() — not per entity definition — so
179
+ // it can't be reset on metadata refresh; it needs its own eviction policy.
180
+ this._entityRecordNameCache = new MJLruCache({ maxSize: 10000, ttlMs: 60 * 60 * 1000 });
184
181
  this._refresh = false;
185
182
  this._lastRefreshCheckAt = 0;
186
183
  /**
@@ -348,7 +345,7 @@ export class ProviderBase {
348
345
  * @returns The cached display name, or undefined if not in cache
349
346
  */
350
347
  async GetCachedRecordName(entityName, compositeKey, loadIfNeeded) {
351
- let cachedEntry = this._entityRecordNameCache.get(this.getCacheKey(entityName, compositeKey));
348
+ let cachedEntry = this._entityRecordNameCache.Get(this.getCacheKey(entityName, compositeKey));
352
349
  if (!cachedEntry && loadIfNeeded) {
353
350
  cachedEntry = await this.GetEntityRecordName(entityName, compositeKey);
354
351
  }
@@ -362,7 +359,7 @@ export class ProviderBase {
362
359
  * @param recordName - The display name to cache
363
360
  */
364
361
  SetCachedRecordName(entityName, compositeKey, recordName) {
365
- this._entityRecordNameCache.set(this.getCacheKey(entityName, compositeKey), recordName);
362
+ this._entityRecordNameCache.Set(this.getCacheKey(entityName, compositeKey), recordName);
366
363
  }
367
364
  /**
368
365
  * Gets the display name for a single entity record with caching.
@@ -377,7 +374,7 @@ export class ProviderBase {
377
374
  const cacheKey = this.getCacheKey(entityName, compositeKey);
378
375
  // Check cache unless forceRefresh
379
376
  if (!forceRefresh) {
380
- const cached = this._entityRecordNameCache.get(cacheKey);
377
+ const cached = this._entityRecordNameCache.Get(cacheKey);
381
378
  if (cached !== undefined) {
382
379
  return cached;
383
380
  }
@@ -385,7 +382,7 @@ export class ProviderBase {
385
382
  // Fetch from database via provider-specific implementation
386
383
  const name = await this.InternalGetEntityRecordName(entityName, compositeKey, contextUser);
387
384
  if (name) {
388
- this._entityRecordNameCache.set(cacheKey, name);
385
+ this._entityRecordNameCache.Set(cacheKey, name);
389
386
  }
390
387
  return name;
391
388
  }
@@ -406,7 +403,7 @@ export class ProviderBase {
406
403
  for (let i = 0; i < info.length; i++) {
407
404
  const item = info[i];
408
405
  const cacheKey = this.getCacheKey(item.EntityName, item.CompositeKey);
409
- const cached = this._entityRecordNameCache.get(cacheKey);
406
+ const cached = this._entityRecordNameCache.Get(cacheKey);
410
407
  if (cached !== undefined) {
411
408
  // Cache hit
412
409
  results[i] = {
@@ -434,7 +431,7 @@ export class ProviderBase {
434
431
  // Cache successful results
435
432
  if (result.Success && result.RecordName) {
436
433
  const cacheKey = this.getCacheKey(result.EntityName, result.CompositeKey);
437
- this._entityRecordNameCache.set(cacheKey, result.RecordName);
434
+ this._entityRecordNameCache.Set(cacheKey, result.RecordName);
438
435
  }
439
436
  }
440
437
  }
@@ -447,7 +444,7 @@ export class ProviderBase {
447
444
  for (const result of results) {
448
445
  if (result.Success && result.RecordName) {
449
446
  const cacheKey = this.getCacheKey(result.EntityName, result.CompositeKey);
450
- this._entityRecordNameCache.set(cacheKey, result.RecordName);
447
+ this._entityRecordNameCache.Set(cacheKey, result.RecordName);
451
448
  }
452
449
  }
453
450
  return results;
@@ -876,7 +873,7 @@ export class ProviderBase {
876
873
  */
877
874
  findBestField(entity, preferredNames) {
878
875
  for (const name of preferredNames) {
879
- const field = entity.Fields.find(f => f.Name === name);
876
+ const field = entity.FieldByName(name);
880
877
  if (field)
881
878
  return field.Name;
882
879
  }
@@ -967,6 +964,40 @@ export class ProviderBase {
967
964
  (param.CacheLocal === true || this.TrustLocalCacheCompletely) &&
968
965
  this.IsServerCacheAllowedForEntity(param);
969
966
  }
967
+ /**
968
+ * SECURITY — decide whether the shared cache must be BYPASSED for a RunView that targets
969
+ * a saved VIEW rather than a named entity (no `EntityName`), under a context user.
970
+ *
971
+ * The cache-hit path returns BEFORE the DB provider's read-permission gate
972
+ * (`CheckUserReadPermissions`). The primary gate keys off the entity resolved from
973
+ * `params.EntityName`, so a ViewID-/ViewName-only request (the Explorer-standard shape for a
974
+ * saved view) yields no entity there and the gate is disarmed — a read-denied user could be
975
+ * served rows a permitted user warmed for the same ViewID. The `vw:` fingerprint segment makes
976
+ * the two users' requests collide on exactly one slot, so the leak is clean.
977
+ *
978
+ * Returns true when the cache must be skipped for this call (fail-closed):
979
+ * - `ViewEntity` supplied and its entity resolves → apply the normal `CanRead` gate on it
980
+ * (allow caching for a permitted user; deny for a read-denied one).
981
+ * - `ViewEntity` absent/unresolvable but `ViewID`/`ViewName` present → fail closed: the view's
982
+ * real entity (hence the user's permission) is only known after the async `MJ: User Views`
983
+ * lookup that the cache-hit path deliberately skips, so we cannot safely consult the cache.
984
+ * Returns false when there is no context user, when `EntityName` is set (the normal gate owns
985
+ * that path), or when no view identifier is present at all (nothing to gate).
986
+ */
987
+ cacheDeniedForViewOnlyRequest(params, contextUser) {
988
+ if (!contextUser || params.EntityName) {
989
+ return false; // EntityName path is handled by the entity-resolved read-permission gate
990
+ }
991
+ if (params.ViewEntity) {
992
+ const entityID = params.ViewEntity.Get('EntityID');
993
+ const viewEntity = entityID ? this.EntityByID(entityID) : undefined;
994
+ if (viewEntity) {
995
+ return !(viewEntity.GetUserPermisions(contextUser)?.CanRead ?? false);
996
+ }
997
+ // ViewEntity present but its entity can't be resolved — fall through to fail-closed.
998
+ }
999
+ return !!(params.ViewID || params.ViewName || params.ViewEntity);
1000
+ }
970
1001
  /**
971
1002
  * Returns the caller's requested fields (lowercased) unioned with the entity's
972
1003
  * primary key field names. Platform contract: when `Fields` is explicitly
@@ -1047,10 +1078,42 @@ export class ProviderBase {
1047
1078
  return memoized;
1048
1079
  }
1049
1080
  const base = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
1050
- const fingerprint = `${base}|f:${ProviderBase.NormalizeFieldsKey(param.Fields)}`;
1081
+ // Normalize a FULL-COVERAGE field list to '*' — in the FINGERPRINT only (B44).
1082
+ //
1083
+ // entity_object params get widened to an explicit list of every entity field
1084
+ // (prepareSmartCacheCheckParams), which is semantically identical to "no Fields" — but
1085
+ // it keyed the slot as f:<every field>, so the maintenance classifier read the client's
1086
+ // most common slot shape (the BaseEngine default) as a NARROW projection and stopped
1087
+ // maintaining it in place. Purely a hit-rate loss, but a pervasive one.
1088
+ //
1089
+ // This touches ONLY how the slot is keyed. param.Fields is left untouched, so what the
1090
+ // provider FETCHES is unchanged — an earlier attempt cleared param.Fields instead and was
1091
+ // reverted precisely because fetch behavior could not be verified.
1092
+ const fieldsKey = this.isFullCoverageFieldList(param) ? '*' : ProviderBase.NormalizeFieldsKey(param.Fields);
1093
+ const fingerprint = `${base}|f:${fieldsKey}`;
1051
1094
  this._clientFingerprintMemo.set(param, fingerprint);
1052
1095
  return fingerprint;
1053
1096
  }
1097
+ /**
1098
+ * True when `param.Fields` names EVERY field of the entity — i.e. an explicit list that is
1099
+ * semantically "full width". Case-insensitive; order-independent. Returns false on any
1100
+ * uncertainty (unknown entity, missing fields) so the fingerprint falls back to the explicit
1101
+ * list — the safe direction, since misclassifying narrow-as-full would cross-serve shapes.
1102
+ */
1103
+ isFullCoverageFieldList(param) {
1104
+ if (!param.Fields || param.Fields.length === 0) {
1105
+ return false; // no Fields at all already normalizes to '*' downstream
1106
+ }
1107
+ const entity = param.EntityName ? this.EntityByName(param.EntityName) : undefined;
1108
+ if (!entity || entity.Fields.length === 0) {
1109
+ return false;
1110
+ }
1111
+ if (param.Fields.length < entity.Fields.length) {
1112
+ return false; // cheap reject: cannot cover every field with fewer names
1113
+ }
1114
+ const requested = new Set(param.Fields.map(f => f.trim().toLowerCase()));
1115
+ return entity.Fields.every(f => requested.has(f.Name.trim().toLowerCase()));
1116
+ }
1054
1117
  /**
1055
1118
  * Ranked search over **one** entity's records. See {@link IMetadataProvider.SearchEntity}
1056
1119
  * for the contract and how this differs from {@link EntityByName} /
@@ -1345,6 +1408,51 @@ export class ProviderBase {
1345
1408
  IsExternalQuery(_params) {
1346
1409
  return false;
1347
1410
  }
1411
+ /**
1412
+ * The RunQuery cache-serve seam (B45/B46) — resolves a RunQuery request against this
1413
+ * provider's query metadata and answers, in ONE computation performed BEFORE fingerprinting:
1414
+ *
1415
+ * - `categoryPath`: the RESOLVED query's canonical full category path. This becomes a
1416
+ * distinguishing fingerprint segment (B46) so two same-named queries in different
1417
+ * categories can never collide onto one cache slot. When the request is unresolvable the
1418
+ * caller falls back to the CALLER-STATED `params.CategoryPath` (still distinguishing,
1419
+ * just not canonicalized).
1420
+ * - `resolvable`: whether metadata could resolve the request at all. Runtime-created
1421
+ * queries are typically NOT resolvable from the base metadata cache (it does not refresh
1422
+ * in-process) — the gate then applies the warmer tie-break instead.
1423
+ * - `authorized`: whether `user` may run the resolved query. Meaningful only when
1424
+ * `resolvable` is true.
1425
+ *
1426
+ * The BASE implementation resolves from the metadata `Queries` cache and enforces the
1427
+ * ROLES-ONLY `QueryInfo.UserCanRun` — the strongest check available at this layer.
1428
+ * Providers with richer query metadata MUST override this to enforce the SAME authorization
1429
+ * their miss path enforces (`GenericDatabaseProvider` overrides with
1430
+ * `MJQueryEntityExtended.UserCanRun`, which adds entity CanRead + recursive composition
1431
+ * checks — the exact check `ValidateQueryForExecution` applies on a cache miss). The
1432
+ * invariant this seam exists to hold: **a cache HIT must never be easier to read than a
1433
+ * cache MISS** (B45 was precisely that asymmetry — the TTL gate checked roles only while
1434
+ * the miss path also checked entity read permissions).
1435
+ */
1436
+ ResolveQueryCacheAuthorization(params, user) {
1437
+ const requestedPath = params.CategoryPath?.trim().toLowerCase();
1438
+ const qInfo = this.Queries.find(q => (params.QueryID && UUIDsEqual(q.ID, params.QueryID))
1439
+ || (!params.QueryID && params.QueryName
1440
+ && q.Name?.trim().toLowerCase() === params.QueryName.trim().toLowerCase()
1441
+ // When the caller states a category, honor it as a disambiguator — same-named
1442
+ // queries in other categories must not resolve (mirrors resolveQuery()).
1443
+ && (!requestedPath || q.CategoryPath?.trim().toLowerCase() === requestedPath)));
1444
+ if (!qInfo) {
1445
+ return { resolvable: false, authorized: false };
1446
+ }
1447
+ return {
1448
+ resolvable: true,
1449
+ // No user (client-side / trusted single-user context) ⇒ nothing to authorize against;
1450
+ // the gate only consults `authorized` when a contextUser is present.
1451
+ authorized: !user || qInfo.UserCanRun(user),
1452
+ categoryPath: qInfo.CategoryPath,
1453
+ queryName: qInfo.Name
1454
+ };
1455
+ }
1348
1456
  async RunQuery(params, contextUser) {
1349
1457
  // Shallow-clone for symmetry with RunView — pipeline must never mutate caller objects
1350
1458
  params = { ...params };
@@ -1369,6 +1477,11 @@ export class ProviderBase {
1369
1477
  && LocalCacheManager.Instance.IsInitialized;
1370
1478
  let queryFingerprint;
1371
1479
  if (queryCacheEngaged) {
1480
+ // Resolve + authorize ONCE, BEFORE fingerprinting (B45/B46 seam). The resolved
1481
+ // category feeds the fingerprint below, and the same result drives the permission
1482
+ // gate on a hit — so the slot key and the authorization decision can never disagree
1483
+ // about WHICH query they are talking about.
1484
+ const queryAuth = this.ResolveQueryCacheAuthorization(params, contextUser);
1372
1485
  // MaxRows/StartRow shape the result set — they MUST distinguish cache slots,
1373
1486
  // so fold them into the parameters portion of the fingerprint.
1374
1487
  const fingerprintParams = {
@@ -1376,7 +1489,12 @@ export class ProviderBase {
1376
1489
  __maxRows: params.MaxRows ?? -1,
1377
1490
  __startRow: params.StartRow ?? 0
1378
1491
  };
1379
- queryFingerprint = LocalCacheManager.Instance.GenerateRunQueryFingerprint(params.QueryID, params.QueryName, fingerprintParams, this.InstanceConnectionString);
1492
+ queryFingerprint = LocalCacheManager.Instance.GenerateRunQueryFingerprint(params.QueryID, params.QueryName, fingerprintParams, this.InstanceConnectionString,
1493
+ // B46: the full category path is a DISTINGUISHING element. Canonical resolved
1494
+ // path when metadata resolves; the caller-stated path otherwise (runtime-created
1495
+ // queries) — either way, same-named queries in different categories get
1496
+ // different slots.
1497
+ queryAuth.resolvable ? queryAuth.categoryPath : params.CategoryPath);
1380
1498
  const cached = await LocalCacheManager.Instance.GetRunQueryResult(queryFingerprint); // TTL-enforced
1381
1499
  if (cached) {
1382
1500
  const serveFromSlot = () => ({
@@ -1393,33 +1511,93 @@ export class ProviderBase {
1393
1511
  });
1394
1512
  const checker = this.RunQueriesWithCacheCheck?.bind(this);
1395
1513
  if (!checker || this.TrustLocalCacheCompletely) {
1396
- // TTL mode (server providers / no validation transport)
1397
- return serveFromSlot();
1398
- }
1399
- // Client smart validation round trip
1400
- const response = await checker([{
1401
- params,
1402
- cacheStatus: { maxUpdatedAt: cached.maxUpdatedAt, rowCount: cached.rowCount }
1403
- }], contextUser);
1404
- const check = response.results?.[0];
1405
- if (response.success && check) {
1406
- if (check.status === 'current') {
1514
+ // TTL mode (server providers / no validation transport).
1515
+ //
1516
+ // PERMISSION GATE (B43): the RunQuery fingerprint carries no user segment, so
1517
+ // user A's warmed slot is user B's cache hit — and this path previously served
1518
+ // it with NO UserCanRun check (the gate lived only on the miss path, in
1519
+ // ValidateQueryForExecution). Same shape as the RunView S31/S31b fix, which
1520
+ // was never applied here. The client transport is unaffected (its validation
1521
+ // round trip re-authorizes server-side); the exposure is server-side TTL mode
1522
+ // with per-request users — agents/actions running as different principals.
1523
+ //
1524
+ // Deny fall through to normal execution, which resolves the query and
1525
+ // authorizes with the proper error message. Query metadata missing from the
1526
+ // provider cache ⇒ ALSO fall through (never serve rows we cannot authorize);
1527
+ // that costs one query run, not correctness.
1528
+ if (contextUser) {
1529
+ if (queryAuth.resolvable) {
1530
+ // Metadata can answer — enforce it. `authorized` comes from the
1531
+ // provider's STRONGEST available check (B45): the base enforces
1532
+ // roles-only QueryInfo.UserCanRun; GenericDatabaseProvider's
1533
+ // override enforces the full MJQueryEntityExtended.UserCanRun
1534
+ // (roles + entity CanRead + recursive composition) — the identical
1535
+ // check ValidateQueryForExecution applies on the miss path, so a
1536
+ // hit is never easier to read than a miss.
1537
+ if (queryAuth.authorized) {
1538
+ return serveFromSlot();
1539
+ }
1540
+ LogStatusEx({ message: `RunQuery cache: user '${contextUser.Email}' lacks run permission on '${queryAuth.queryName ?? params.QueryName ?? params.QueryID}' — falling through to authorized execution.`, verboseOnly: true });
1541
+ }
1542
+ else if (cached.warmedForUserID && UUIDsEqual(cached.warmedForUserID, contextUser.ID)) {
1543
+ // Metadata CANNOT answer — runtime-created queries never appear in the
1544
+ // provider's Queries cache (it does not refresh in-process; verified:
1545
+ // a saved Query stays invisible, 21 -> 21). But a slot only exists
1546
+ // because its WARMER executed the fully-authorized miss path — so the
1547
+ // warmer's own permission is already proven. Serve the warmer;
1548
+ // anyone ELSE falls through and pays one authorized execution.
1549
+ //
1550
+ // The first version of this gate failed closed on unresolvable
1551
+ // metadata, which silently disabled TTL caching for every
1552
+ // runtime-created query — caught by Q2/Q5/Q10 going red.
1553
+ return serveFromSlot();
1554
+ }
1555
+ else {
1556
+ LogStatusEx({ message: `RunQuery cache: '${params.QueryID ?? params.QueryName}' not in cached metadata and requester is not the slot's warmer — falling through to authorized execution.`, verboseOnly: true });
1557
+ }
1558
+ }
1559
+ else {
1407
1560
  return serveFromSlot();
1408
1561
  }
1409
- if ((check.status === 'stale' || check.status === 'no_validation') && check.results) {
1410
- const freshRows = check.results;
1411
- // Fire-and-forget slot rewrite — same pattern as the RunView client path
1412
- LocalCacheManager.Instance.SetRunQueryResult(queryFingerprint, params.QueryName ?? '', freshRows, check.maxUpdatedAt ?? '', check.rowCount, check.queryId, params.CacheLocalTTL).catch(e => LogError(`RunQuery cache rewrite failed: ${e}`));
1413
- return {
1414
- QueryID: check.queryId ?? params.QueryID ?? '',
1415
- QueryName: params.QueryName ?? '',
1416
- Success: true,
1417
- Results: freshRows,
1418
- RowCount: freshRows.length,
1419
- TotalRowCount: check.rowCount ?? freshRows.length,
1420
- ExecutionTime: 0,
1421
- ErrorMessage: ''
1422
- };
1562
+ }
1563
+ // Client smart validation round trip.
1564
+ //
1565
+ // Guarded on `checker` (R4-2): the block above is entered when `!checker ||
1566
+ // TrustLocalCacheCompletely`. If we got here via `!checker` AND the B43 permission
1567
+ // gate DECLINED to serve (denied user / non-warmer), execution used to fall
1568
+ // straight into `await checker(...)` = `await undefined(...)` — a TypeError instead
1569
+ // of the promised "fall through to authorized execution". Every in-tree provider
1570
+ // implements the optional `RunQueriesWithCacheCheck`, so this was latent, but the
1571
+ // pre-B43 code handled `!checker` and this must too: skip the round trip and let
1572
+ // the normal PreRunQuery/InternalRunQuery path below authorize and execute.
1573
+ if (!checker) {
1574
+ // fall through to normal execution below
1575
+ }
1576
+ else {
1577
+ const response = await checker([{
1578
+ params,
1579
+ cacheStatus: { maxUpdatedAt: cached.maxUpdatedAt, rowCount: cached.rowCount }
1580
+ }], contextUser);
1581
+ const check = response.results?.[0];
1582
+ if (response.success && check) {
1583
+ if (check.status === 'current') {
1584
+ return serveFromSlot();
1585
+ }
1586
+ if ((check.status === 'stale' || check.status === 'no_validation') && check.results) {
1587
+ const freshRows = check.results;
1588
+ // Fire-and-forget slot rewrite — same pattern as the RunView client path
1589
+ LocalCacheManager.Instance.SetRunQueryResult(queryFingerprint, params.QueryName ?? '', freshRows, check.maxUpdatedAt ?? '', check.rowCount, check.queryId, params.CacheLocalTTL, contextUser?.ID).catch(e => LogError(`RunQuery cache rewrite failed: ${e}`));
1590
+ return {
1591
+ QueryID: check.queryId ?? params.QueryID ?? '',
1592
+ QueryName: params.QueryName ?? '',
1593
+ Success: true,
1594
+ Results: freshRows,
1595
+ RowCount: freshRows.length,
1596
+ TotalRowCount: check.rowCount ?? freshRows.length,
1597
+ ExecutionTime: 0,
1598
+ ErrorMessage: ''
1599
+ };
1600
+ }
1423
1601
  }
1424
1602
  }
1425
1603
  // validation transport failed — fall through to a normal execution
@@ -1444,7 +1622,7 @@ export class ProviderBase {
1444
1622
  // or the LocalCacheManager default). maxUpdatedAt is unknown for a plain run —
1445
1623
  // the smart-validation path stamps it when the Query has CacheValidationSQL.
1446
1624
  if (queryCacheEngaged && queryFingerprint && result.Success) {
1447
- LocalCacheManager.Instance.SetRunQueryResult(queryFingerprint, result.QueryName, result.Results, '', result.TotalRowCount, result.QueryID, params.CacheLocalTTL).catch(e => LogError(`RunQuery cache write failed: ${e}`));
1625
+ LocalCacheManager.Instance.SetRunQueryResult(queryFingerprint, result.QueryName, result.Results, '', result.TotalRowCount, result.QueryID, params.CacheLocalTTL, contextUser?.ID).catch(e => LogError(`RunQuery cache write failed: ${e}`));
1448
1626
  }
1449
1627
  return result;
1450
1628
  }
@@ -1497,8 +1675,10 @@ export class ProviderBase {
1497
1675
  * @param entityName
1498
1676
  * @param callerName
1499
1677
  */
1500
- async EntityStatusCheck(params, callerName) {
1501
- const entityName = await RunView.GetEntityNameFromRunViewParams(params, this);
1678
+ async EntityStatusCheck(params, callerName, contextUser) {
1679
+ // contextUser threads into the ViewID->entity lookup (B39): without it, server-side
1680
+ // ViewID-only reads failed for every caller because the inner User Views read was unscoped.
1681
+ const entityName = await RunView.GetEntityNameFromRunViewParams(params, this, contextUser);
1502
1682
  const entity = entityName ? this.EntityByName(entityName) : undefined;
1503
1683
  if (!entity) {
1504
1684
  throw new Error(`Entity ${entityName} not found in metadata`);
@@ -1613,7 +1793,7 @@ export class ProviderBase {
1613
1793
  const telemetryTime = performance.now() - telemetryStart;
1614
1794
  // Entity status check
1615
1795
  const entityCheckStart = performance.now();
1616
- await this.EntityStatusCheck(params, 'PreRunView');
1796
+ await this.EntityStatusCheck(params, 'PreRunView', contextUser);
1617
1797
  const entityCheckTime = performance.now() - entityCheckStart;
1618
1798
  // Save the caller's original Fields request for post-cache filtering.
1619
1799
  // We always fetch ALL fields from the DB so the cache entry is a universal superset
@@ -1641,7 +1821,18 @@ export class ProviderBase {
1641
1821
  let cacheStatus = 'disabled';
1642
1822
  let cachedResult;
1643
1823
  let fingerprint;
1644
- if (willCache && LocalCacheManager.Instance.IsInitialized) {
1824
+ // SECURITY (S31): the cache-hit path returns BEFORE the DB provider's read-permission
1825
+ // gate (CheckUserReadPermissions), so a user lacking CanRead on the entity must not be
1826
+ // allowed to consult the shared cache — it would leak rows a permitted user warmed. When
1827
+ // we can affirmatively determine the user lacks read permission, skip the cache and fall
1828
+ // through to the normal path, which denies with the proper error. Unknown user/entity →
1829
+ // unchanged behavior (the DB path handles null-user semantics).
1830
+ // S31b closes the view-only variant: a ViewID/ViewName-only request never resolves an
1831
+ // entity above, so the entity-keyed gate is disarmed — cacheDeniedForViewOnlyRequest
1832
+ // fails closed (or resolves ViewEntity synchronously) to plug that hole.
1833
+ const cacheReadDenied = (!!entity && !!contextUser && !(entity.GetUserPermisions(contextUser)?.CanRead ?? false)) ||
1834
+ this.cacheDeniedForViewOnlyRequest(params, contextUser);
1835
+ if (willCache && !cacheReadDenied && LocalCacheManager.Instance.IsInitialized) {
1645
1836
  const rlsWhereClause = this.ComputeRunViewRLSWhereClause(params, contextUser);
1646
1837
  fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, rlsWhereClause);
1647
1838
  const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
@@ -1660,7 +1851,9 @@ export class ProviderBase {
1660
1851
  ExecutionTime: 0, // Cached, no execution time
1661
1852
  ErrorMessage: '',
1662
1853
  UserViewRunID: '',
1663
- AggregateResults: cached.aggregateResults // Include cached aggregate results
1854
+ // Order-insensitive aggHash means this slot may have been warmed with a
1855
+ // different Aggregates[] order — remap to THIS caller's requested order.
1856
+ AggregateResults: LocalCacheManager.Instance.ReorderAggregateResultsToRequest(cached.aggregateResults, params.Aggregates)
1664
1857
  };
1665
1858
  cacheStatus = 'hit';
1666
1859
  if (!params.CacheLocal && this.TrustLocalCacheCompletely) {
@@ -1734,7 +1927,7 @@ export class ProviderBase {
1734
1927
  for (let i = 0; i < params.length; i++) {
1735
1928
  const param = params[i];
1736
1929
  // Entity status check
1737
- await this.EntityStatusCheck(param, 'PreRunViews');
1930
+ await this.EntityStatusCheck(param, 'PreRunViews', contextUser);
1738
1931
  // Save caller's original Fields, then always fetch all fields from DB.
1739
1932
  // One cache entry per entity+filter satisfies all field subsets.
1740
1933
  let callerFields = param.Fields && param.Fields.length > 0
@@ -1758,7 +1951,13 @@ export class ProviderBase {
1758
1951
  // Check local cache if enabled or if server trusts its cache completely
1759
1952
  // BypassCache skips cache entirely — used by maintenance actions querying for
1760
1953
  // records that were inserted via direct SQL (bypassing BaseEntity.Save())
1761
- if (batchWillCache && LocalCacheManager.Instance.IsInitialized) {
1954
+ // SECURITY (S31): same read-permission gate as the single-RunView path — never serve
1955
+ // (or consult) the shared cache for a user who lacks CanRead on the entity; fall
1956
+ // through to the DB path, which denies with the proper error. S31b applies the same
1957
+ // view-only fail-closed gate for ViewID/ViewName-only requests (no resolvable entity).
1958
+ const batchCacheReadDenied = (!!batchEntity && !!contextUser && !(batchEntity.GetUserPermisions(contextUser)?.CanRead ?? false)) ||
1959
+ this.cacheDeniedForViewOnlyRequest(param, contextUser);
1960
+ if (batchWillCache && !batchCacheReadDenied && LocalCacheManager.Instance.IsInitialized) {
1762
1961
  const rlsWhereClause = this.ComputeRunViewRLSWhereClause(param, contextUser);
1763
1962
  const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString, rlsWhereClause);
1764
1963
  fingerprintMap.set(i, fingerprint);
@@ -1777,7 +1976,9 @@ export class ProviderBase {
1777
1976
  ExecutionTime: 0,
1778
1977
  ErrorMessage: '',
1779
1978
  UserViewRunID: '',
1780
- AggregateResults: cached.aggregateResults // Include cached aggregate results
1979
+ // Order-insensitive aggHash means this slot may have been warmed with a
1980
+ // different Aggregates[] order — remap to THIS caller's requested order.
1981
+ AggregateResults: LocalCacheManager.Instance.ReorderAggregateResultsToRequest(cached.aggregateResults, param.Aggregates)
1781
1982
  };
1782
1983
  // if needed this will transform each result into an entity object
1783
1984
  await this.TransformSimpleObjectToEntityObject(param, cachedViewResult, contextUser);
@@ -1824,12 +2025,23 @@ export class ProviderBase {
1824
2025
  const cacheable = [];
1825
2026
  for (let i = 0; i < params.length; i++) {
1826
2027
  const param = params[i];
1827
- await this.EntityStatusCheck(param, 'PreRunViews');
2028
+ await this.EntityStatusCheck(param, 'PreRunViews', contextUser);
1828
2029
  if (param.ResultType === 'entity_object') {
1829
2030
  const entity = this.EntityByName(param.EntityName);
1830
2031
  if (!entity) {
1831
2032
  throw new Error(`Entity ${param.EntityName} not found in metadata`);
1832
2033
  }
2034
+ // NOTE (R2-GAP-1, logged as B44): widening to an explicit full field list makes
2035
+ // the client fingerprint carry `f:<every field>` rather than `f:*`, so the cache
2036
+ // classifier reads the slot as a NARROW projection and stops maintaining it in
2037
+ // place. entity_object is the BaseEngine default, so this costs in-place
2038
+ // maintenance on the client's most common slot shape. It is a PERF gap only —
2039
+ // the slot is invalidated and refetched, never served wrong.
2040
+ //
2041
+ // Deliberately NOT "fixed" by clearing Fields here: that would change what the
2042
+ // provider FETCHES, not just how the slot is keyed, and I could not verify the
2043
+ // downstream fetch behavior tonight. The safe fix is to normalize a full-coverage
2044
+ // field list to `*` in the FINGERPRINT only, which cannot affect fetching.
1833
2045
  param.Fields = entity.Fields.map(f => f.Name);
1834
2046
  }
1835
2047
  if (param.CacheLocal && LocalCacheManager.Instance.IsInitialized) {
@@ -1977,7 +2189,9 @@ export class ProviderBase {
1977
2189
  ExecutionTime: 0,
1978
2190
  ErrorMessage: '',
1979
2191
  UserViewRunID: '',
1980
- AggregateResults: cached.aggregateResults // Include cached aggregate results
2192
+ // Order-insensitive aggHash means this slot may have been warmed with a
2193
+ // different Aggregates[] order — remap to THIS caller's requested order.
2194
+ AggregateResults: LocalCacheManager.Instance.ReorderAggregateResultsToRequest(cached.aggregateResults, param.Aggregates)
1981
2195
  };
1982
2196
  // Transform to entity objects if needed
1983
2197
  await this.TransformSimpleObjectToEntityObject(param, cachedResult, contextUser);
@@ -2027,12 +2241,25 @@ export class ProviderBase {
2027
2241
  return { result: mergedResult, cacheHit: true, cacheMiss: false };
2028
2242
  }
2029
2243
  }
2030
- // Differential merge failed - this should not happen normally
2031
- // Throwing an exception rather than returning partial data which would be dangerous
2032
- // as the caller would have no way of knowing the data is incomplete
2033
- throw new Error(`Differential cache merge failed for entity '${param.EntityName}'. ` +
2034
- `Cache fingerprint may be invalid or cache data corrupted. ` +
2035
- `Consider clearing the local cache and retrying.`);
2244
+ // Differential merge declined fall back to a FULL FETCH of this one param (B41).
2245
+ //
2246
+ // History, because this exact spot has burned us twice:
2247
+ // - This used to THROW, inside a Promise.all over the batch, so ONE undecidable
2248
+ // slot rejected the caller's entire RunViews call. "Decline" is a legitimate
2249
+ // outcome (subset / aggregate / narrowing slots cannot be merged from a row
2250
+ // delta), not corruption — so throwing was the wrong contract.
2251
+ // - Returning null instead was attempted and REVERTED: the batch maps
2252
+ // `r.result` straight to the caller, so null became the caller's RESULT.
2253
+ //
2254
+ // The only correct remedy is what a cache miss does anyway: fetch in full. The
2255
+ // declined slot was already invalidated by ApplyDifferentialUpdate, so the refetch
2256
+ // repopulates it. CacheLocal is stripped and BypassCache set so this single call
2257
+ // takes the plain path — it cannot re-enter the smart-cache transport, which is what
2258
+ // makes the recursion impossible rather than merely unlikely.
2259
+ LogStatusEx({ message: `Differential merge declined for '${param.EntityName}' — refetching in full.`, verboseOnly: true });
2260
+ const fallbackParam = { ...param, CacheLocal: false, BypassCache: true };
2261
+ const freshFetch = await this.RunView(fallbackParam, contextUser);
2262
+ return { result: freshFetch, cacheHit: false, cacheMiss: true };
2036
2263
  }
2037
2264
  else if (checkResult.status === 'stale') {
2038
2265
  // Cache is stale - use fresh data and update cache (entity doesn't support differential)
@@ -2282,6 +2509,13 @@ export class ProviderBase {
2282
2509
  /**
2283
2510
  * Runs all registered PreRunView hooks against a single RunViewParams,
2284
2511
  * returning the (possibly mutated) params.
2512
+ *
2513
+ * Protected (not private) on purpose: any subclass pipeline that executes
2514
+ * view queries WITHOUT passing through PreRunView/PreRunViews — e.g. the
2515
+ * RunViewsWithCacheCheck smart-cache path in GenericDatabaseProvider —
2516
+ * MUST apply these hooks itself. Hooks are an enforcement seam (tenant
2517
+ * scoping middleware injects filters here); a query path that skips them
2518
+ * silently returns rows the hooked paths would have filtered out.
2285
2519
  */
2286
2520
  async RunPreRunViewHooks(params, contextUser) {
2287
2521
  const hooks = GetDataHooks('PreRunView');
@@ -2293,6 +2527,13 @@ export class ProviderBase {
2293
2527
  /**
2294
2528
  * Runs all registered PostRunView hooks against a single result,
2295
2529
  * returning the (possibly mutated) result.
2530
+ *
2531
+ * Protected (not private) for the same reason as RunPreRunViewHooks above:
2532
+ * a subclass pipeline that returns view rows WITHOUT passing through
2533
+ * PostRunView/PostRunViews — e.g. the RunViewsWithCacheCheck smart-cache
2534
+ * path — MUST apply these hooks to the rows it returns. PostRunView is the
2535
+ * OUTPUT half of the enforcement seam (data masking / audit); a path that
2536
+ * skips it returns rows the hooked paths would have masked.
2296
2537
  */
2297
2538
  async RunPostRunViewHooks(params, result, contextUser) {
2298
2539
  const hooks = GetDataHooks('PostRunView');
@@ -2523,7 +2764,7 @@ export class ProviderBase {
2523
2764
  }, contextUser?.ID);
2524
2765
  // Store on params object for retrieval in PostProcessRunView
2525
2766
  params._telemetryEventId = eventId;
2526
- await this.EntityStatusCheck(params, 'PreProcessRunView');
2767
+ await this.EntityStatusCheck(params, 'PreProcessRunView', contextUser);
2527
2768
  // FIRST, if the resultType is entity_object, we need to run the view with ALL fields in the entity
2528
2769
  // so that we can get the data to populate the entity object with.
2529
2770
  if (params.ResultType === 'entity_object') {
@@ -3256,7 +3497,29 @@ export class ProviderBase {
3256
3497
  // Use the MJGlobal Class Factory to do our object instantiation - we do NOT use metadata for this anymore, doesn't work well to have file paths with node dynamically at runtime
3257
3498
  // type reference registration by any module via MJ Global is the way to go as it is reliable across all platforms.
3258
3499
  try {
3259
- const newObject = MJGlobal.Instance.ClassFactory.CreateInstance(BaseEntity, entityName, entity, this);
3500
+ let newObject;
3501
+ try {
3502
+ newObject = MJGlobal.Instance.ClassFactory.CreateInstance(BaseEntity, entityName, entity, this);
3503
+ }
3504
+ catch (instErr) {
3505
+ // The highest-priority registered class for this entity failed to
3506
+ // construct in the current runtime context — most commonly a
3507
+ // server-only entity subclass (e.g. *EntityServer) instantiated
3508
+ // inside a client/GraphQL process, where its constructor intentionally
3509
+ // throws. Fall back to the generic BaseEntity (key=null skips the
3510
+ // subclass lookup and uses the base class), which is exactly what a
3511
+ // context WITHOUT that subclass registered — a real browser client —
3512
+ // resolves. Reads/materialization still work over the wire; only
3513
+ // server-only behaviors are unavailable, which is correct on the client.
3514
+ LogError(`GetEntityObject: the registered class for '${entityName}' failed to construct (${instErr instanceof Error ? instErr.message : String(instErr)}); falling back to BaseEntity.`);
3515
+ newObject = MJGlobal.Instance.ClassFactory.CreateInstance(BaseEntity, null, entity, this);
3516
+ }
3517
+ if (!newObject) {
3518
+ // ClassFactory returned null (missing base class / null registration) —
3519
+ // surface a clear, actionable error rather than letting a null propagate
3520
+ // to a downstream `.constructor`/`.LoadFromData` crash.
3521
+ throw new Error(`Entity '${entityName}' could not be instantiated — MJGlobal ClassFactory returned null. Ensure LoadGeneratedEntities()/LoadCoreEntities() has run so the entity's class is registered.`);
3522
+ }
3260
3523
  await newObject.Config(actualContextUser);
3261
3524
  // Initialize IS-A parent entity composition chain before any data operations
3262
3525
  await newObject.InitializeParentEntity();