@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.
- package/dist/generic/RegisterForStartup.d.ts +78 -1
- package/dist/generic/RegisterForStartup.d.ts.map +1 -1
- package/dist/generic/RegisterForStartup.js +108 -7
- package/dist/generic/RegisterForStartup.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +5 -0
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +25 -4
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/compositeKey.d.ts.map +1 -1
- package/dist/generic/compositeKey.js +8 -1
- package/dist/generic/compositeKey.js.map +1 -1
- package/dist/generic/interfaces.d.ts +7 -0
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +179 -7
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +409 -26
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/permissionInterfaces.d.ts +11 -0
- package/dist/generic/permissionInterfaces.d.ts.map +1 -1
- package/dist/generic/permissionInterfaces.js +13 -2
- package/dist/generic/permissionInterfaces.js.map +1 -1
- package/dist/generic/providerBase.d.ts +87 -4
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +325 -62
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +6 -0
- package/dist/generic/util.js.map +1 -1
- package/dist/views/runView.d.ts +1 -1
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +18 -3
- package/dist/views/runView.js.map +1 -1
- package/dist/views/viewInfo.js +1 -1
- package/dist/views/viewInfo.js.map +1 -1
- 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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
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
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2031
|
-
//
|
|
2032
|
-
//
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
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
|
-
|
|
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();
|