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