@memberjunction/core 5.48.0 → 5.50.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.
@@ -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 },
@@ -970,6 +964,40 @@ export class ProviderBase {
970
964
  (param.CacheLocal === true || this.TrustLocalCacheCompletely) &&
971
965
  this.IsServerCacheAllowedForEntity(param);
972
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
+ }
973
1001
  /**
974
1002
  * Returns the caller's requested fields (lowercased) unioned with the entity's
975
1003
  * primary key field names. Platform contract: when `Fields` is explicitly
@@ -1050,10 +1078,42 @@ export class ProviderBase {
1050
1078
  return memoized;
1051
1079
  }
1052
1080
  const base = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
1053
- 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}`;
1054
1094
  this._clientFingerprintMemo.set(param, fingerprint);
1055
1095
  return fingerprint;
1056
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
+ }
1057
1117
  /**
1058
1118
  * Ranked search over **one** entity's records. See {@link IMetadataProvider.SearchEntity}
1059
1119
  * for the contract and how this differs from {@link EntityByName} /
@@ -1348,6 +1408,51 @@ export class ProviderBase {
1348
1408
  IsExternalQuery(_params) {
1349
1409
  return false;
1350
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
+ }
1351
1456
  async RunQuery(params, contextUser) {
1352
1457
  // Shallow-clone for symmetry with RunView — pipeline must never mutate caller objects
1353
1458
  params = { ...params };
@@ -1372,6 +1477,11 @@ export class ProviderBase {
1372
1477
  && LocalCacheManager.Instance.IsInitialized;
1373
1478
  let queryFingerprint;
1374
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);
1375
1485
  // MaxRows/StartRow shape the result set — they MUST distinguish cache slots,
1376
1486
  // so fold them into the parameters portion of the fingerprint.
1377
1487
  const fingerprintParams = {
@@ -1379,7 +1489,12 @@ export class ProviderBase {
1379
1489
  __maxRows: params.MaxRows ?? -1,
1380
1490
  __startRow: params.StartRow ?? 0
1381
1491
  };
1382
- 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);
1383
1498
  const cached = await LocalCacheManager.Instance.GetRunQueryResult(queryFingerprint); // TTL-enforced
1384
1499
  if (cached) {
1385
1500
  const serveFromSlot = () => ({
@@ -1396,33 +1511,93 @@ export class ProviderBase {
1396
1511
  });
1397
1512
  const checker = this.RunQueriesWithCacheCheck?.bind(this);
1398
1513
  if (!checker || this.TrustLocalCacheCompletely) {
1399
- // TTL mode (server providers / no validation transport)
1400
- return serveFromSlot();
1401
- }
1402
- // Client smart validation round trip
1403
- const response = await checker([{
1404
- params,
1405
- cacheStatus: { maxUpdatedAt: cached.maxUpdatedAt, rowCount: cached.rowCount }
1406
- }], contextUser);
1407
- const check = response.results?.[0];
1408
- if (response.success && check) {
1409
- 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 {
1410
1560
  return serveFromSlot();
1411
1561
  }
1412
- if ((check.status === 'stale' || check.status === 'no_validation') && check.results) {
1413
- const freshRows = check.results;
1414
- // Fire-and-forget slot rewrite — same pattern as the RunView client path
1415
- LocalCacheManager.Instance.SetRunQueryResult(queryFingerprint, params.QueryName ?? '', freshRows, check.maxUpdatedAt ?? '', check.rowCount, check.queryId, params.CacheLocalTTL).catch(e => LogError(`RunQuery cache rewrite failed: ${e}`));
1416
- return {
1417
- QueryID: check.queryId ?? params.QueryID ?? '',
1418
- QueryName: params.QueryName ?? '',
1419
- Success: true,
1420
- Results: freshRows,
1421
- RowCount: freshRows.length,
1422
- TotalRowCount: check.rowCount ?? freshRows.length,
1423
- ExecutionTime: 0,
1424
- ErrorMessage: ''
1425
- };
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
+ }
1426
1601
  }
1427
1602
  }
1428
1603
  // validation transport failed — fall through to a normal execution
@@ -1447,7 +1622,7 @@ export class ProviderBase {
1447
1622
  // or the LocalCacheManager default). maxUpdatedAt is unknown for a plain run —
1448
1623
  // the smart-validation path stamps it when the Query has CacheValidationSQL.
1449
1624
  if (queryCacheEngaged && queryFingerprint && result.Success) {
1450
- 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}`));
1451
1626
  }
1452
1627
  return result;
1453
1628
  }
@@ -1500,8 +1675,10 @@ export class ProviderBase {
1500
1675
  * @param entityName
1501
1676
  * @param callerName
1502
1677
  */
1503
- async EntityStatusCheck(params, callerName) {
1504
- 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);
1505
1682
  const entity = entityName ? this.EntityByName(entityName) : undefined;
1506
1683
  if (!entity) {
1507
1684
  throw new Error(`Entity ${entityName} not found in metadata`);
@@ -1616,7 +1793,7 @@ export class ProviderBase {
1616
1793
  const telemetryTime = performance.now() - telemetryStart;
1617
1794
  // Entity status check
1618
1795
  const entityCheckStart = performance.now();
1619
- await this.EntityStatusCheck(params, 'PreRunView');
1796
+ await this.EntityStatusCheck(params, 'PreRunView', contextUser);
1620
1797
  const entityCheckTime = performance.now() - entityCheckStart;
1621
1798
  // Save the caller's original Fields request for post-cache filtering.
1622
1799
  // We always fetch ALL fields from the DB so the cache entry is a universal superset
@@ -1644,7 +1821,18 @@ export class ProviderBase {
1644
1821
  let cacheStatus = 'disabled';
1645
1822
  let cachedResult;
1646
1823
  let fingerprint;
1647
- 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) {
1648
1836
  const rlsWhereClause = this.ComputeRunViewRLSWhereClause(params, contextUser);
1649
1837
  fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, rlsWhereClause);
1650
1838
  const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
@@ -1663,7 +1851,9 @@ export class ProviderBase {
1663
1851
  ExecutionTime: 0, // Cached, no execution time
1664
1852
  ErrorMessage: '',
1665
1853
  UserViewRunID: '',
1666
- 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)
1667
1857
  };
1668
1858
  cacheStatus = 'hit';
1669
1859
  if (!params.CacheLocal && this.TrustLocalCacheCompletely) {
@@ -1737,7 +1927,7 @@ export class ProviderBase {
1737
1927
  for (let i = 0; i < params.length; i++) {
1738
1928
  const param = params[i];
1739
1929
  // Entity status check
1740
- await this.EntityStatusCheck(param, 'PreRunViews');
1930
+ await this.EntityStatusCheck(param, 'PreRunViews', contextUser);
1741
1931
  // Save caller's original Fields, then always fetch all fields from DB.
1742
1932
  // One cache entry per entity+filter satisfies all field subsets.
1743
1933
  let callerFields = param.Fields && param.Fields.length > 0
@@ -1761,7 +1951,13 @@ export class ProviderBase {
1761
1951
  // Check local cache if enabled or if server trusts its cache completely
1762
1952
  // BypassCache skips cache entirely — used by maintenance actions querying for
1763
1953
  // records that were inserted via direct SQL (bypassing BaseEntity.Save())
1764
- 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) {
1765
1961
  const rlsWhereClause = this.ComputeRunViewRLSWhereClause(param, contextUser);
1766
1962
  const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString, rlsWhereClause);
1767
1963
  fingerprintMap.set(i, fingerprint);
@@ -1780,7 +1976,9 @@ export class ProviderBase {
1780
1976
  ExecutionTime: 0,
1781
1977
  ErrorMessage: '',
1782
1978
  UserViewRunID: '',
1783
- 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)
1784
1982
  };
1785
1983
  // if needed this will transform each result into an entity object
1786
1984
  await this.TransformSimpleObjectToEntityObject(param, cachedViewResult, contextUser);
@@ -1827,12 +2025,23 @@ export class ProviderBase {
1827
2025
  const cacheable = [];
1828
2026
  for (let i = 0; i < params.length; i++) {
1829
2027
  const param = params[i];
1830
- await this.EntityStatusCheck(param, 'PreRunViews');
2028
+ await this.EntityStatusCheck(param, 'PreRunViews', contextUser);
1831
2029
  if (param.ResultType === 'entity_object') {
1832
2030
  const entity = this.EntityByName(param.EntityName);
1833
2031
  if (!entity) {
1834
2032
  throw new Error(`Entity ${param.EntityName} not found in metadata`);
1835
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.
1836
2045
  param.Fields = entity.Fields.map(f => f.Name);
1837
2046
  }
1838
2047
  if (param.CacheLocal && LocalCacheManager.Instance.IsInitialized) {
@@ -1980,7 +2189,9 @@ export class ProviderBase {
1980
2189
  ExecutionTime: 0,
1981
2190
  ErrorMessage: '',
1982
2191
  UserViewRunID: '',
1983
- 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)
1984
2195
  };
1985
2196
  // Transform to entity objects if needed
1986
2197
  await this.TransformSimpleObjectToEntityObject(param, cachedResult, contextUser);
@@ -2030,12 +2241,25 @@ export class ProviderBase {
2030
2241
  return { result: mergedResult, cacheHit: true, cacheMiss: false };
2031
2242
  }
2032
2243
  }
2033
- // Differential merge failed - this should not happen normally
2034
- // Throwing an exception rather than returning partial data which would be dangerous
2035
- // as the caller would have no way of knowing the data is incomplete
2036
- throw new Error(`Differential cache merge failed for entity '${param.EntityName}'. ` +
2037
- `Cache fingerprint may be invalid or cache data corrupted. ` +
2038
- `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 };
2039
2263
  }
2040
2264
  else if (checkResult.status === 'stale') {
2041
2265
  // Cache is stale - use fresh data and update cache (entity doesn't support differential)
@@ -2285,6 +2509,13 @@ export class ProviderBase {
2285
2509
  /**
2286
2510
  * Runs all registered PreRunView hooks against a single RunViewParams,
2287
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.
2288
2519
  */
2289
2520
  async RunPreRunViewHooks(params, contextUser) {
2290
2521
  const hooks = GetDataHooks('PreRunView');
@@ -2296,6 +2527,13 @@ export class ProviderBase {
2296
2527
  /**
2297
2528
  * Runs all registered PostRunView hooks against a single result,
2298
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.
2299
2537
  */
2300
2538
  async RunPostRunViewHooks(params, result, contextUser) {
2301
2539
  const hooks = GetDataHooks('PostRunView');
@@ -2526,7 +2764,7 @@ export class ProviderBase {
2526
2764
  }, contextUser?.ID);
2527
2765
  // Store on params object for retrieval in PostProcessRunView
2528
2766
  params._telemetryEventId = eventId;
2529
- await this.EntityStatusCheck(params, 'PreProcessRunView');
2767
+ await this.EntityStatusCheck(params, 'PreProcessRunView', contextUser);
2530
2768
  // FIRST, if the resultType is entity_object, we need to run the view with ALL fields in the entity
2531
2769
  // so that we can get the data to populate the entity object with.
2532
2770
  if (params.ResultType === 'entity_object') {
@@ -3259,7 +3497,29 @@ export class ProviderBase {
3259
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
3260
3498
  // type reference registration by any module via MJ Global is the way to go as it is reliable across all platforms.
3261
3499
  try {
3262
- 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
+ }
3263
3523
  await newObject.Config(actualContextUser);
3264
3524
  // Initialize IS-A parent entity composition chain before any data operations
3265
3525
  await newObject.InitializeParentEntity();