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