@memberjunction/core 5.8.0 → 5.10.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.js +1 -1
- package/dist/generic/RegisterForStartup.js.map +1 -1
- package/dist/generic/baseEngine.d.ts +64 -1
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +229 -22
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +31 -3
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +46 -0
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts +19 -1
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +44 -1
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/hookRegistry.d.ts +83 -0
- package/dist/generic/hookRegistry.d.ts.map +1 -0
- package/dist/generic/hookRegistry.js +87 -0
- package/dist/generic/hookRegistry.js.map +1 -0
- package/dist/generic/interfaces.d.ts +16 -0
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +200 -19
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +504 -135
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/providerBase.d.ts +106 -2
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +379 -40
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/queryInfo.d.ts +14 -6
- package/dist/generic/queryInfo.d.ts.map +1 -1
- package/dist/generic/queryInfo.js +15 -10
- package/dist/generic/queryInfo.js.map +1 -1
- package/dist/generic/queryInfoInterfaces.d.ts +6 -1
- package/dist/generic/queryInfoInterfaces.d.ts.map +1 -1
- package/dist/generic/securityInfo.d.ts +41 -0
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +23 -2
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +33 -0
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js.map +1 -1
- package/package.json +2 -2
- package/readme.md +58 -0
|
@@ -14,6 +14,7 @@ import { ExplorerNavigationItem } from "./explorerNavigationItem.js";
|
|
|
14
14
|
import { Metadata } from "./metadata.js";
|
|
15
15
|
import { RunView } from "../views/runView.js";
|
|
16
16
|
import { IsPlatformSQL } from "./platformSQL.js";
|
|
17
|
+
import { HookRegistry } from "./hookRegistry.js";
|
|
17
18
|
/**
|
|
18
19
|
* Creates a new instance of AllMetadata from a simple object.
|
|
19
20
|
* Handles deserialization and proper instantiation of all metadata classes.
|
|
@@ -96,8 +97,65 @@ export class ProviderBase {
|
|
|
96
97
|
this._localMetadata = new AllMetadata();
|
|
97
98
|
this._entityRecordNameCache = new Map();
|
|
98
99
|
this._refresh = false;
|
|
100
|
+
this._lastRefreshCheckAt = 0;
|
|
101
|
+
/**
|
|
102
|
+
* In-flight + linger cache keyed by a deterministic fingerprint of
|
|
103
|
+
* the RunViewParams batch. While a request is in-flight, concurrent
|
|
104
|
+
* identical calls share the same promise. After resolution the entry
|
|
105
|
+
* lingers so that near-sequential identical calls return immediately.
|
|
106
|
+
*/
|
|
107
|
+
this._inflightViews = new Map();
|
|
99
108
|
this._cachedVisibleExplorerNavigationItems = null;
|
|
100
109
|
}
|
|
110
|
+
// ── Metadata Refresh Check Debounce ────────────────────────────────
|
|
111
|
+
/**
|
|
112
|
+
* Minimum interval (ms) between metadata refresh checks to prevent
|
|
113
|
+
* redundant network calls when Config()/RefreshIfNeeded() fire in
|
|
114
|
+
* quick succession (e.g., multiple engines during startup).
|
|
115
|
+
* Does NOT affect forced Refresh() calls. Default: 30 000 ms.
|
|
116
|
+
*/
|
|
117
|
+
static { this.MinRefreshCheckIntervalMs = 30000; }
|
|
118
|
+
// ── Server-Side Auto-Cache ────────────────────────────────────────
|
|
119
|
+
/**
|
|
120
|
+
* Maximum row count for auto-caching on the server side. When
|
|
121
|
+
* `TrustLocalCacheCompletely` is true and a RunView result has no
|
|
122
|
+
* ExtraFilter, no OrderBy, and the result count is at or below this
|
|
123
|
+
* threshold, the result is automatically stored in LocalCacheManager
|
|
124
|
+
* even without an explicit `CacheLocal` flag.
|
|
125
|
+
*
|
|
126
|
+
* This captures small reference/lookup tables that are repeatedly
|
|
127
|
+
* queried by multiple clients while avoiding caching large ad-hoc
|
|
128
|
+
* result sets. Invalidation is handled by the standard BaseEntity
|
|
129
|
+
* event-driven upsert (safe because unfiltered caches can be updated
|
|
130
|
+
* in-place).
|
|
131
|
+
*
|
|
132
|
+
* Set to 0 to disable auto-caching. Default 250.
|
|
133
|
+
*/
|
|
134
|
+
static { this.ServerAutoCacheMaxRows = 250; }
|
|
135
|
+
// ── Request Deduplication + Linger Window ──────────────────────────
|
|
136
|
+
/**
|
|
137
|
+
* How long (ms) a resolved RunViews result stays available for instant
|
|
138
|
+
* replay. Set to 0 to disable the linger window (in-flight dedup
|
|
139
|
+
* still applies). Default 5 000 ms.
|
|
140
|
+
*/
|
|
141
|
+
static { this.DedupLingerMs = 5000; }
|
|
142
|
+
/******** ABSTRACT SECTION ****************************************************************** */
|
|
143
|
+
/**
|
|
144
|
+
* When true, cached RunView/RunQuery results are returned immediately on a
|
|
145
|
+
* cache hit without any server-side validation round-trip.
|
|
146
|
+
*
|
|
147
|
+
* Server-side providers (DatabaseProviderBase and its subclasses) override
|
|
148
|
+
* this to return `true` because the cache is kept in perfect sync via
|
|
149
|
+
* BaseEntity save/delete events and cross-server Redis pub/sub — the DB
|
|
150
|
+
* validation query is unnecessary overhead.
|
|
151
|
+
*
|
|
152
|
+
* Client-side providers (e.g. GraphQLDataProvider) keep the default `false`
|
|
153
|
+
* so that the lightweight smart cache check (maxUpdatedAt + rowCount) is
|
|
154
|
+
* still performed against the server before trusting the browser cache.
|
|
155
|
+
*/
|
|
156
|
+
get TrustLocalCacheCompletely() {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
101
159
|
/**
|
|
102
160
|
* Helper to generate cache key for entity record names
|
|
103
161
|
*/
|
|
@@ -230,20 +288,119 @@ export class ProviderBase {
|
|
|
230
288
|
* @returns The view results
|
|
231
289
|
*/
|
|
232
290
|
async RunView(params, contextUser) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
291
|
+
if (this.TrustLocalCacheCompletely) {
|
|
292
|
+
// Server-side: use direct Pre → Internal → Post pipeline.
|
|
293
|
+
// Cache is kept in sync via BaseEntity events + Redis pub/sub,
|
|
294
|
+
// so PreRunView cache hits are returned immediately with no DB round-trip.
|
|
295
|
+
const preResult = await this.PreRunView(params, contextUser);
|
|
296
|
+
if (preResult.cachedResult) {
|
|
297
|
+
// Cache hit — transform and return directly
|
|
298
|
+
LogStatusEx({ message: ` ✅ [Cache HIT] RunView "${params.EntityName || params.ViewName || 'unknown'}" — ${preResult.cachedResult.Results?.length ?? 0} rows from cache, no DB query`, verboseOnly: true });
|
|
299
|
+
await this.TransformSimpleObjectToEntityObject(params, preResult.cachedResult, contextUser);
|
|
300
|
+
TelemetryManager.Instance.EndEvent(preResult.telemetryEventId, {
|
|
301
|
+
cacheHit: true,
|
|
302
|
+
cacheStatus: preResult.cacheStatus,
|
|
303
|
+
resultCount: preResult.cachedResult.Results?.length ?? 0
|
|
304
|
+
});
|
|
305
|
+
if (params.OnDataChanged && preResult.fingerprint) {
|
|
306
|
+
preResult.cachedResult.Unsubscribe = LocalCacheManager.Instance.RegisterChangeCallback(preResult.fingerprint, params.OnDataChanged);
|
|
307
|
+
}
|
|
308
|
+
return preResult.cachedResult;
|
|
309
|
+
}
|
|
310
|
+
// Cache miss — execute query, then post-process (stores in cache)
|
|
311
|
+
LogStatusEx({ message: ` 🔍 [Cache MISS] RunView "${params.EntityName || params.ViewName || 'unknown'}" — querying database`, verboseOnly: true });
|
|
312
|
+
const result = await this.InternalRunView(params, contextUser);
|
|
313
|
+
await this.PostRunView(result, params, preResult, contextUser);
|
|
314
|
+
return result;
|
|
315
|
+
}
|
|
316
|
+
// Client-side: delegate to RunViews which uses the smart cache check
|
|
317
|
+
// (lightweight maxUpdatedAt + rowCount validation against the server)
|
|
236
318
|
const results = await this.RunViews([params], contextUser);
|
|
237
319
|
return results[0];
|
|
238
320
|
}
|
|
239
321
|
/**
|
|
240
322
|
* Runs multiple views based on the provided parameters.
|
|
241
|
-
*
|
|
323
|
+
* Wraps the execution pipeline with request deduplication and a linger
|
|
324
|
+
* window so that concurrent (and near-sequential) identical calls share
|
|
325
|
+
* a single server round-trip. Every caller receives a shallow-copied
|
|
326
|
+
* Results array to protect against cross-caller mutations (push/sort/splice).
|
|
327
|
+
*
|
|
242
328
|
* @param params - Array of view parameters
|
|
243
329
|
* @param contextUser - Optional user context for permissions (required server-side)
|
|
244
|
-
* @returns Array of view results
|
|
330
|
+
* @returns Array of view results (shallow-copied Results per caller)
|
|
245
331
|
*/
|
|
246
332
|
async RunViews(params, contextUser) {
|
|
333
|
+
// Bypass dedup for side-effect calls (SaveViewResults creates DB records)
|
|
334
|
+
if (this.ShouldBypassDedup(params)) {
|
|
335
|
+
return this.ExecuteRunViewsPipeline(params, contextUser);
|
|
336
|
+
}
|
|
337
|
+
const key = this.GenerateDedupKey(params, contextUser);
|
|
338
|
+
const existing = this._inflightViews.get(key);
|
|
339
|
+
// ── Linger hit: resolved result still within the linger window ──
|
|
340
|
+
if (existing?.resolvedResults && existing.resolvedAt) {
|
|
341
|
+
const age = Date.now() - existing.resolvedAt;
|
|
342
|
+
if (age < ProviderBase.DedupLingerMs) {
|
|
343
|
+
const entities = params.map(p => p.EntityName || p.ViewName || 'unknown').join(', ');
|
|
344
|
+
LogStatusEx({
|
|
345
|
+
message: `[Dedup] Linger hit for [${entities}] — returning cached result (age ${age}ms, window ${ProviderBase.DedupLingerMs}ms)`,
|
|
346
|
+
verboseOnly: true
|
|
347
|
+
});
|
|
348
|
+
return existing.resolvedResults.map(r => this.ShallowCopyResult(r));
|
|
349
|
+
}
|
|
350
|
+
// Linger expired — fall through to fresh execution
|
|
351
|
+
this._inflightViews.delete(key);
|
|
352
|
+
}
|
|
353
|
+
// ── In-flight hit: another caller is already executing this exact request ──
|
|
354
|
+
if (existing && !existing.resolvedResults) {
|
|
355
|
+
const entities = params.map(p => p.EntityName || p.ViewName || 'unknown').join(', ');
|
|
356
|
+
LogStatusEx({
|
|
357
|
+
message: `[Dedup] In-flight hit for [${entities}] — sharing pending execution`,
|
|
358
|
+
verboseOnly: true
|
|
359
|
+
});
|
|
360
|
+
const results = await existing.promise;
|
|
361
|
+
return results.map(r => this.ShallowCopyResult(r));
|
|
362
|
+
}
|
|
363
|
+
// ── Fresh execution ──
|
|
364
|
+
const promise = this.ExecuteRunViewsPipeline(params, contextUser)
|
|
365
|
+
.then(results => {
|
|
366
|
+
// Stash resolved results for the linger window
|
|
367
|
+
const entry = this._inflightViews.get(key);
|
|
368
|
+
if (entry && entry.promise === promise) {
|
|
369
|
+
entry.resolvedResults = results;
|
|
370
|
+
entry.resolvedAt = Date.now();
|
|
371
|
+
// Schedule cleanup after linger expires
|
|
372
|
+
if (ProviderBase.DedupLingerMs > 0) {
|
|
373
|
+
setTimeout(() => {
|
|
374
|
+
const current = this._inflightViews.get(key);
|
|
375
|
+
if (current && current.promise === promise) {
|
|
376
|
+
this._inflightViews.delete(key);
|
|
377
|
+
}
|
|
378
|
+
}, ProviderBase.DedupLingerMs);
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
this._inflightViews.delete(key);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return results;
|
|
385
|
+
})
|
|
386
|
+
.catch(err => {
|
|
387
|
+
// Clean up so retries aren't stuck on a failed entry
|
|
388
|
+
const entry = this._inflightViews.get(key);
|
|
389
|
+
if (entry && entry.promise === promise) {
|
|
390
|
+
this._inflightViews.delete(key);
|
|
391
|
+
}
|
|
392
|
+
throw err;
|
|
393
|
+
});
|
|
394
|
+
this._inflightViews.set(key, { promise });
|
|
395
|
+
const results = await promise;
|
|
396
|
+
return results.map(r => this.ShallowCopyResult(r));
|
|
397
|
+
}
|
|
398
|
+
// ── Dedup helpers ──────────────────────────────────────────────────
|
|
399
|
+
/**
|
|
400
|
+
* The original RunViews execution pipeline (pre-processing, cache,
|
|
401
|
+
* internal execution, post-processing).
|
|
402
|
+
*/
|
|
403
|
+
async ExecuteRunViewsPipeline(params, contextUser) {
|
|
247
404
|
// Pre-processing for batch
|
|
248
405
|
const preResult = await this.PreRunViews(params, contextUser);
|
|
249
406
|
// Check for smart cache check mode
|
|
@@ -252,7 +409,9 @@ export class ProviderBase {
|
|
|
252
409
|
}
|
|
253
410
|
// Check for cached results - if all are cached, end telemetry and return early
|
|
254
411
|
if (preResult.allCached && preResult.cachedResults) {
|
|
412
|
+
const entities = params.map(p => p.EntityName || p.ViewName || 'unknown').join(', ');
|
|
255
413
|
const totalResults = preResult.cachedResults.reduce((sum, r) => sum + (r.Results?.length ?? 0), 0);
|
|
414
|
+
LogStatusEx({ message: ` ✅ [Cache HIT] RunViews batch [${entities}] — all ${params.length} views served from cache (${totalResults} total rows), no DB queries`, verboseOnly: true });
|
|
256
415
|
TelemetryManager.Instance.EndEvent(preResult.telemetryEventId, {
|
|
257
416
|
cacheHit: true,
|
|
258
417
|
allCached: true,
|
|
@@ -262,6 +421,8 @@ export class ProviderBase {
|
|
|
262
421
|
return preResult.cachedResults;
|
|
263
422
|
}
|
|
264
423
|
// Execute the internal implementation for non-cached items
|
|
424
|
+
const uncachedEntities = (preResult.uncachedParams || params).map(p => p.EntityName || p.ViewName || 'unknown').join(', ');
|
|
425
|
+
LogStatusEx({ message: ` 🔍 [Cache MISS] RunViews batch [${uncachedEntities}] — querying database for ${(preResult.uncachedParams || params).length} view(s)`, verboseOnly: true });
|
|
265
426
|
const results = await this.InternalRunViews(preResult.uncachedParams || params, contextUser);
|
|
266
427
|
// Merge cached and fresh results if needed
|
|
267
428
|
const finalResults = preResult.cachedResults
|
|
@@ -271,6 +432,45 @@ export class ProviderBase {
|
|
|
271
432
|
await this.PostRunViews(finalResults, params, preResult, contextUser);
|
|
272
433
|
return finalResults;
|
|
273
434
|
}
|
|
435
|
+
/**
|
|
436
|
+
* Generates a deterministic dedup key for a batch of RunViewParams.
|
|
437
|
+
* Extends the local-cache fingerprint with additional fields that
|
|
438
|
+
* affect result identity (Fields, UserSearchString, ViewID, ViewName,
|
|
439
|
+
* contextUser).
|
|
440
|
+
*/
|
|
441
|
+
GenerateDedupKey(params, contextUser) {
|
|
442
|
+
const parts = params.map(p => {
|
|
443
|
+
const base = LocalCacheManager.Instance.GenerateRunViewFingerprint(p, this.InstanceConnectionString);
|
|
444
|
+
const extras = [
|
|
445
|
+
p.Fields?.join(',') ?? '',
|
|
446
|
+
p.UserSearchString ?? '',
|
|
447
|
+
p.ViewID ?? '',
|
|
448
|
+
p.ViewName ?? '',
|
|
449
|
+
contextUser?.ID ?? ''
|
|
450
|
+
].join('|');
|
|
451
|
+
return `${base}|${extras}`;
|
|
452
|
+
});
|
|
453
|
+
return parts.join('||');
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Returns true if any param in the batch has SaveViewResults set,
|
|
457
|
+
* which means the call has a side effect (creating UserViewRun records)
|
|
458
|
+
* and must not be deduplicated.
|
|
459
|
+
*/
|
|
460
|
+
ShouldBypassDedup(params) {
|
|
461
|
+
return params.some(p => p.SaveViewResults === true);
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Returns a shallow copy of a RunViewResult: the Results array is a
|
|
465
|
+
* new array instance (protecting against push/sort/splice by other
|
|
466
|
+
* callers) but the individual row objects inside are shared references.
|
|
467
|
+
*/
|
|
468
|
+
ShallowCopyResult(result) {
|
|
469
|
+
return {
|
|
470
|
+
...result,
|
|
471
|
+
Results: [...result.Results]
|
|
472
|
+
};
|
|
473
|
+
}
|
|
274
474
|
/**
|
|
275
475
|
* Runs a query based on the provided parameters.
|
|
276
476
|
* This method orchestrates the full execution flow: pre-processing, cache check,
|
|
@@ -402,6 +602,10 @@ export class ProviderBase {
|
|
|
402
602
|
const preViewStart = performance.now();
|
|
403
603
|
// Resolve any PlatformSQL values to plain strings for the active platform
|
|
404
604
|
this.ResolvePlatformSQLInParams(params);
|
|
605
|
+
// Run registered PreRunView hooks (e.g., tenant filter injection)
|
|
606
|
+
// Hooks run after PlatformSQL resolution so they see plain-string filters,
|
|
607
|
+
// and before cache fingerprinting so injected filters affect the cache key.
|
|
608
|
+
params = await this.RunPreRunViewHooks(params, contextUser);
|
|
405
609
|
// Start telemetry tracking
|
|
406
610
|
const telemetryStart = performance.now();
|
|
407
611
|
// After ResolvePlatformSQLInParams, ExtraFilter/OrderBy are guaranteed to be strings
|
|
@@ -436,7 +640,7 @@ export class ProviderBase {
|
|
|
436
640
|
let cacheStatus = 'disabled';
|
|
437
641
|
let cachedResult;
|
|
438
642
|
let fingerprint;
|
|
439
|
-
if (params.CacheLocal && LocalCacheManager.Instance.IsInitialized) {
|
|
643
|
+
if ((params.CacheLocal || this.TrustLocalCacheCompletely) && LocalCacheManager.Instance.IsInitialized) {
|
|
440
644
|
fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString);
|
|
441
645
|
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
442
646
|
if (cached) {
|
|
@@ -452,6 +656,9 @@ export class ProviderBase {
|
|
|
452
656
|
AggregateResults: cached.aggregateResults // Include cached aggregate results
|
|
453
657
|
};
|
|
454
658
|
cacheStatus = 'hit';
|
|
659
|
+
if (!params.CacheLocal && this.TrustLocalCacheCompletely) {
|
|
660
|
+
LogStatusEx({ message: ` ✅ [Server Cache HIT] RunView "${params.EntityName || params.ViewName || 'unknown'}" — ${cached.results.length} rows served from server cache (no DB query)`, verboseOnly: true });
|
|
661
|
+
}
|
|
455
662
|
}
|
|
456
663
|
else {
|
|
457
664
|
cacheStatus = 'miss';
|
|
@@ -481,6 +688,10 @@ export class ProviderBase {
|
|
|
481
688
|
for (const p of params) {
|
|
482
689
|
this.ResolvePlatformSQLInParams(p);
|
|
483
690
|
}
|
|
691
|
+
// Run registered PreRunView hooks on each param in the batch
|
|
692
|
+
for (let i = 0; i < params.length; i++) {
|
|
693
|
+
params[i] = await this.RunPreRunViewHooks(params[i], contextUser);
|
|
694
|
+
}
|
|
484
695
|
// Start telemetry tracking for batch operation
|
|
485
696
|
const fromEngine = params.some(p => p._fromEngine);
|
|
486
697
|
const telemetryEventId = TelemetryManager.Instance.StartEvent('RunView', 'ProviderBase.RunViews', {
|
|
@@ -488,11 +699,14 @@ export class ProviderBase {
|
|
|
488
699
|
Entities: params.map(p => p.EntityName || p.ViewName || p.ViewID).filter(Boolean),
|
|
489
700
|
_fromEngine: fromEngine
|
|
490
701
|
}, contextUser?.ID);
|
|
491
|
-
//
|
|
492
|
-
|
|
493
|
-
//
|
|
494
|
-
if (
|
|
495
|
-
|
|
702
|
+
// Client-side providers use smart cache check (lightweight server validation)
|
|
703
|
+
// Server-side providers trust the cache completely and fall through to
|
|
704
|
+
// the traditional flow which returns cached data immediately on hit.
|
|
705
|
+
if (!this.TrustLocalCacheCompletely) {
|
|
706
|
+
const useSmartCacheCheck = params.some(p => p.CacheLocal);
|
|
707
|
+
if (useSmartCacheCheck && LocalCacheManager.Instance.IsInitialized) {
|
|
708
|
+
return this.prepareSmartCacheCheckParams(params, telemetryEventId, contextUser);
|
|
709
|
+
}
|
|
496
710
|
}
|
|
497
711
|
// Traditional caching flow
|
|
498
712
|
const cacheStatusMap = new Map();
|
|
@@ -511,8 +725,8 @@ export class ProviderBase {
|
|
|
511
725
|
}
|
|
512
726
|
param.Fields = entity.Fields.map(f => f.Name);
|
|
513
727
|
}
|
|
514
|
-
// Check local cache if enabled
|
|
515
|
-
if (param.CacheLocal && LocalCacheManager.Instance.IsInitialized) {
|
|
728
|
+
// Check local cache if enabled or if server trusts its cache completely
|
|
729
|
+
if ((param.CacheLocal || this.TrustLocalCacheCompletely) && LocalCacheManager.Instance.IsInitialized) {
|
|
516
730
|
const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
|
|
517
731
|
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
518
732
|
if (cached) {
|
|
@@ -528,10 +742,15 @@ export class ProviderBase {
|
|
|
528
742
|
};
|
|
529
743
|
// if needed this will transform each result into an entity object
|
|
530
744
|
await this.TransformSimpleObjectToEntityObject(param, cachedViewResult, contextUser);
|
|
745
|
+
if (!param.CacheLocal && this.TrustLocalCacheCompletely) {
|
|
746
|
+
LogStatusEx({ message: ` ✅ [Server Cache HIT] RunViews "${param.EntityName || param.ViewName || 'unknown'}" — ${cached.results.length} rows served from server cache (no DB query)`, verboseOnly: true });
|
|
747
|
+
}
|
|
748
|
+
LogStatusEx({ message: ` ✅ [Cache HIT] "${param.EntityName || param.ViewName || 'unknown'}" — ${cached.results.length} rows from cache`, verboseOnly: true });
|
|
531
749
|
cacheStatusMap.set(i, { status: 'hit', result: cachedViewResult });
|
|
532
750
|
cachedResults.push(cachedViewResult);
|
|
533
751
|
continue;
|
|
534
752
|
}
|
|
753
|
+
LogStatusEx({ message: ` 🔍 [Cache MISS] "${param.EntityName || param.ViewName || 'unknown'}" — will query database`, verboseOnly: true });
|
|
535
754
|
cacheStatusMap.set(i, { status: 'miss' });
|
|
536
755
|
}
|
|
537
756
|
else {
|
|
@@ -541,10 +760,13 @@ export class ProviderBase {
|
|
|
541
760
|
uncachedParams.push(param);
|
|
542
761
|
cachedResults.push(null); // Placeholder for uncached
|
|
543
762
|
}
|
|
763
|
+
const hasCacheHits = cacheStatusMap.size > 0 && [...cacheStatusMap.values()].some(v => v.status === 'hit');
|
|
544
764
|
return {
|
|
545
765
|
telemetryEventId,
|
|
546
766
|
allCached,
|
|
547
|
-
cachedResults: allCached
|
|
767
|
+
cachedResults: allCached
|
|
768
|
+
? cachedResults.filter(r => r !== null)
|
|
769
|
+
: (hasCacheHits ? cachedResults : undefined),
|
|
548
770
|
uncachedParams: allCached ? undefined : uncachedParams,
|
|
549
771
|
cacheStatusMap
|
|
550
772
|
};
|
|
@@ -838,14 +1060,31 @@ export class ProviderBase {
|
|
|
838
1060
|
* @param contextUser - Optional user context
|
|
839
1061
|
*/
|
|
840
1062
|
async PostRunView(result, params, preResult, contextUser) {
|
|
841
|
-
//
|
|
842
|
-
|
|
843
|
-
//
|
|
1063
|
+
// Store in local cache BEFORE entity transformation — the cache needs
|
|
1064
|
+
// plain JSON-serializable objects. BaseEntity objects contain RxJS Subjects
|
|
1065
|
+
// with circular subscriber references that break JSON.stringify.
|
|
1066
|
+
// On cache read, TransformSimpleObjectToEntityObject is called to restore
|
|
1067
|
+
// entity objects when ResultType === 'entity_object'.
|
|
844
1068
|
if (params.CacheLocal && result.Success && preResult.fingerprint && LocalCacheManager.Instance.IsInitialized) {
|
|
845
|
-
// Extract maxUpdatedAt from results if available
|
|
846
1069
|
const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
|
|
847
|
-
await LocalCacheManager.Instance.SetRunViewResult(preResult.fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults
|
|
848
|
-
|
|
1070
|
+
await LocalCacheManager.Instance.SetRunViewResult(preResult.fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults);
|
|
1071
|
+
}
|
|
1072
|
+
else if (this.shouldAutoCache(params, result)) {
|
|
1073
|
+
// Server-side auto-cache: small, unfiltered, unsorted results are
|
|
1074
|
+
// automatically cached even without explicit CacheLocal. These are
|
|
1075
|
+
// safe for in-place upsert on entity changes (no filter to evaluate).
|
|
1076
|
+
const fingerprint = preResult.fingerprint || LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString);
|
|
1077
|
+
const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
|
|
1078
|
+
await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults);
|
|
1079
|
+
LogStatusEx({ message: ` 📦 [Auto-Cache] RunView "${params.EntityName || params.ViewName || 'unknown'}" — ${result.Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
|
|
1080
|
+
}
|
|
1081
|
+
// Transform the result set into BaseEntity-derived objects, if needed
|
|
1082
|
+
await this.TransformSimpleObjectToEntityObject(params, result, contextUser);
|
|
1083
|
+
// Run registered PostRunView hooks (e.g., data masking, audit logging)
|
|
1084
|
+
result = await this.RunPostRunViewHooks(params, result, contextUser);
|
|
1085
|
+
// Register OnDataChanged callback if provided and we have a fingerprint
|
|
1086
|
+
if (params.OnDataChanged && preResult.fingerprint) {
|
|
1087
|
+
result.Unsubscribe = LocalCacheManager.Instance.RegisterChangeCallback(preResult.fingerprint, params.OnDataChanged);
|
|
849
1088
|
}
|
|
850
1089
|
// End telemetry tracking with cache miss info
|
|
851
1090
|
if (preResult.telemetryEventId) {
|
|
@@ -866,19 +1105,48 @@ export class ProviderBase {
|
|
|
866
1105
|
* @param contextUser - Optional user context
|
|
867
1106
|
*/
|
|
868
1107
|
async PostRunViews(results, params, preResult, contextUser) {
|
|
869
|
-
//
|
|
870
|
-
|
|
1108
|
+
// Store in local cache BEFORE entity transformation — the cache needs
|
|
1109
|
+
// plain JSON-serializable objects. BaseEntity objects contain RxJS Subjects
|
|
1110
|
+
// with circular subscriber references that break JSON.stringify.
|
|
1111
|
+
const cachePromises = [];
|
|
871
1112
|
for (let i = 0; i < results.length; i++) {
|
|
872
|
-
|
|
873
|
-
//
|
|
1113
|
+
// Skip results that came from cache hits — they're already cached and
|
|
1114
|
+
// already transformed to entity objects (would break JSON.stringify).
|
|
1115
|
+
const cacheInfo = preResult.cacheStatusMap?.get(i);
|
|
1116
|
+
if (cacheInfo?.status === 'hit') {
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
1119
|
+
const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params[i], this.InstanceConnectionString);
|
|
874
1120
|
if (params[i].CacheLocal && results[i].Success && LocalCacheManager.Instance.IsInitialized) {
|
|
875
|
-
const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params[i], this.InstanceConnectionString);
|
|
876
1121
|
const maxUpdatedAt = this.extractMaxUpdatedAt(results[i].Results);
|
|
877
|
-
|
|
878
|
-
|
|
1122
|
+
cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults));
|
|
1123
|
+
}
|
|
1124
|
+
else if (this.shouldAutoCache(params[i], results[i])) {
|
|
1125
|
+
const maxUpdatedAt = this.extractMaxUpdatedAt(results[i].Results);
|
|
1126
|
+
cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults));
|
|
1127
|
+
LogStatusEx({ message: ` 📦 [Auto-Cache] RunViews "${params[i].EntityName || params[i].ViewName || 'unknown'}" — ${results[i].Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
|
|
1128
|
+
}
|
|
1129
|
+
// Register OnDataChanged callback if provided
|
|
1130
|
+
if (params[i].OnDataChanged && fingerprint) {
|
|
1131
|
+
results[i].Unsubscribe = LocalCacheManager.Instance.RegisterChangeCallback(fingerprint, params[i].OnDataChanged);
|
|
879
1132
|
}
|
|
880
1133
|
}
|
|
881
|
-
await Promise.all(
|
|
1134
|
+
await Promise.all(cachePromises);
|
|
1135
|
+
// Transform results to entity objects AFTER caching plain objects.
|
|
1136
|
+
// Skip results that came from cache hits — they're already entity objects.
|
|
1137
|
+
const transformPromises = [];
|
|
1138
|
+
for (let i = 0; i < results.length; i++) {
|
|
1139
|
+
const cacheInfo = preResult.cacheStatusMap?.get(i);
|
|
1140
|
+
if (cacheInfo?.status === 'hit') {
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
transformPromises.push(this.TransformSimpleObjectToEntityObject(params[i], results[i], contextUser));
|
|
1144
|
+
}
|
|
1145
|
+
await Promise.all(transformPromises);
|
|
1146
|
+
// Run registered PostRunView hooks on each result in the batch
|
|
1147
|
+
for (let i = 0; i < results.length; i++) {
|
|
1148
|
+
results[i] = await this.RunPostRunViewHooks(params[i], results[i], contextUser);
|
|
1149
|
+
}
|
|
882
1150
|
// End telemetry tracking with batch info
|
|
883
1151
|
if (preResult.telemetryEventId) {
|
|
884
1152
|
const totalResults = results.reduce((sum, r) => sum + (r.Results?.length ?? 0), 0);
|
|
@@ -895,6 +1163,28 @@ export class ProviderBase {
|
|
|
895
1163
|
});
|
|
896
1164
|
}
|
|
897
1165
|
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Runs all registered PreRunView hooks against a single RunViewParams,
|
|
1168
|
+
* returning the (possibly mutated) params.
|
|
1169
|
+
*/
|
|
1170
|
+
async RunPreRunViewHooks(params, contextUser) {
|
|
1171
|
+
const hooks = HookRegistry.GetHooks('PreRunView');
|
|
1172
|
+
for (const hook of hooks) {
|
|
1173
|
+
params = await hook(params, contextUser);
|
|
1174
|
+
}
|
|
1175
|
+
return params;
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Runs all registered PostRunView hooks against a single result,
|
|
1179
|
+
* returning the (possibly mutated) result.
|
|
1180
|
+
*/
|
|
1181
|
+
async RunPostRunViewHooks(params, result, contextUser) {
|
|
1182
|
+
const hooks = HookRegistry.GetHooks('PostRunView');
|
|
1183
|
+
for (const hook of hooks) {
|
|
1184
|
+
result = await hook(params, result, contextUser);
|
|
1185
|
+
}
|
|
1186
|
+
return result;
|
|
1187
|
+
}
|
|
898
1188
|
/**
|
|
899
1189
|
* Post-processing hook for RunQuery.
|
|
900
1190
|
* Handles cache storage and telemetry end.
|
|
@@ -950,6 +1240,42 @@ export class ProviderBase {
|
|
|
950
1240
|
* @param results - Array of result objects that may contain __mj_UpdatedAt
|
|
951
1241
|
* @returns ISO string of the max timestamp, or current time if none found
|
|
952
1242
|
*/
|
|
1243
|
+
/**
|
|
1244
|
+
* Determines if a RunView result should be automatically cached on the
|
|
1245
|
+
* server side. Auto-caching is limited to small, unfiltered, unsorted
|
|
1246
|
+
* result sets that are safe for in-place upsert on entity changes.
|
|
1247
|
+
*
|
|
1248
|
+
* Criteria (all must be true):
|
|
1249
|
+
* - `TrustLocalCacheCompletely` is true (server-side only)
|
|
1250
|
+
* - `CacheLocal` is NOT already set (already handled by explicit path)
|
|
1251
|
+
* - `LocalCacheManager` is initialized
|
|
1252
|
+
* - Result was successful
|
|
1253
|
+
* - Result row count is at or below `ServerAutoCacheMaxRows`
|
|
1254
|
+
* - No `ExtraFilter` (empty or undefined)
|
|
1255
|
+
* - No `OrderBy` (empty or undefined)
|
|
1256
|
+
*/
|
|
1257
|
+
shouldAutoCache(params, result) {
|
|
1258
|
+
if (!this.TrustLocalCacheCompletely)
|
|
1259
|
+
return false;
|
|
1260
|
+
if (params.CacheLocal)
|
|
1261
|
+
return false; // already handled
|
|
1262
|
+
if (!LocalCacheManager.Instance.IsInitialized)
|
|
1263
|
+
return false;
|
|
1264
|
+
if (!result.Success)
|
|
1265
|
+
return false;
|
|
1266
|
+
if (ProviderBase.ServerAutoCacheMaxRows <= 0)
|
|
1267
|
+
return false;
|
|
1268
|
+
if ((result.Results?.length ?? 0) > ProviderBase.ServerAutoCacheMaxRows)
|
|
1269
|
+
return false;
|
|
1270
|
+
// Only auto-cache unfiltered, unsorted queries — these are safe for
|
|
1271
|
+
// in-place upsert because LocalCacheManager doesn't need to evaluate
|
|
1272
|
+
// SQL predicates or sort expressions.
|
|
1273
|
+
const filter = typeof params.ExtraFilter === 'string' ? params.ExtraFilter.trim() : '';
|
|
1274
|
+
const orderBy = typeof params.OrderBy === 'string' ? params.OrderBy.trim() : '';
|
|
1275
|
+
if (filter.length > 0 || orderBy.length > 0)
|
|
1276
|
+
return false;
|
|
1277
|
+
return true;
|
|
1278
|
+
}
|
|
953
1279
|
extractMaxUpdatedAt(results) {
|
|
954
1280
|
let maxDate = null;
|
|
955
1281
|
for (const item of results) {
|
|
@@ -1163,6 +1489,12 @@ export class ProviderBase {
|
|
|
1163
1489
|
*/
|
|
1164
1490
|
async Config(data, providerToUse) {
|
|
1165
1491
|
this._ConfigData = data;
|
|
1492
|
+
// Initialize LocalCacheManager early so dataset loading can use the cache.
|
|
1493
|
+
// Initialize() is idempotent — subsequent calls (e.g. from StartupManager) are no-ops.
|
|
1494
|
+
if (!LocalCacheManager.Instance.IsInitialized) {
|
|
1495
|
+
const storageProvider = this.LocalStorageProvider;
|
|
1496
|
+
await LocalCacheManager.Instance.Initialize(storageProvider);
|
|
1497
|
+
}
|
|
1166
1498
|
// first, let's check to see if we have an existing Metadata.Provider registered, if so
|
|
1167
1499
|
// unless our data.IgnoreExistingMetadata is set to true, we will not refresh the metadata
|
|
1168
1500
|
if (Metadata.Provider && !data.IgnoreExistingMetadata) {
|
|
@@ -1268,13 +1600,13 @@ export class ProviderBase {
|
|
|
1268
1600
|
async GetAllMetadata(providerToUse) {
|
|
1269
1601
|
try {
|
|
1270
1602
|
// we are now using datasets instead of the custom metadata to GraphQL to simplify GraphQL's work as it was very slow preivously
|
|
1271
|
-
//
|
|
1272
|
-
|
|
1603
|
+
// NOTE: Schema filters (IncludeSchemas/ExcludeSchemas) are for CodeGen only, not for runtime
|
|
1604
|
+
// metadata loading. We always load all schemas — there are no sys/staging entities in metadata anyway.
|
|
1273
1605
|
// Get the dataset and cache it for anyone else who wants to use it
|
|
1274
|
-
const d = await this.GetDatasetByName(ProviderBase._mjMetadataDatasetName,
|
|
1606
|
+
const d = await this.GetDatasetByName(ProviderBase._mjMetadataDatasetName, null, this.CurrentUser, providerToUse);
|
|
1275
1607
|
if (d && d.Success) {
|
|
1276
1608
|
// cache the dataset for anyone who wants to use it
|
|
1277
|
-
await this.CacheDataset(ProviderBase._mjMetadataDatasetName,
|
|
1609
|
+
await this.CacheDataset(ProviderBase._mjMetadataDatasetName, null, d);
|
|
1278
1610
|
// got the results, let's build our response in the format we need
|
|
1279
1611
|
const simpleMetadata = {};
|
|
1280
1612
|
for (let r of d.Results) {
|
|
@@ -1502,13 +1834,20 @@ export class ProviderBase {
|
|
|
1502
1834
|
* @returns True if refresh is needed, false otherwise
|
|
1503
1835
|
*/
|
|
1504
1836
|
async CheckToSeeIfRefreshNeeded(providerToUse) {
|
|
1505
|
-
if (this.AllowRefresh)
|
|
1506
|
-
await this.RefreshRemoteMetadataTimestamps(providerToUse); // get the latest timestamps from the server first
|
|
1507
|
-
await this.LoadLocalMetadataFromStorage(); // then, attempt to load before we check to see if it is obsolete
|
|
1508
|
-
return this.LocalMetadataObsolete();
|
|
1509
|
-
}
|
|
1510
|
-
else //subclass is telling us not to do any refresh ops right now
|
|
1837
|
+
if (!this.AllowRefresh)
|
|
1511
1838
|
return false;
|
|
1839
|
+
const now = Date.now();
|
|
1840
|
+
if ((now - this._lastRefreshCheckAt) < ProviderBase.MinRefreshCheckIntervalMs) {
|
|
1841
|
+
LogStatusEx({
|
|
1842
|
+
message: `[RefreshCheck] Skipped — last check was ${now - this._lastRefreshCheckAt}ms ago (min interval ${ProviderBase.MinRefreshCheckIntervalMs}ms)`,
|
|
1843
|
+
verboseOnly: true
|
|
1844
|
+
});
|
|
1845
|
+
return false;
|
|
1846
|
+
}
|
|
1847
|
+
this._lastRefreshCheckAt = now;
|
|
1848
|
+
await this.RefreshRemoteMetadataTimestamps(providerToUse);
|
|
1849
|
+
await this.LoadLocalMetadataFromStorage();
|
|
1850
|
+
return this.LocalMetadataObsolete();
|
|
1512
1851
|
}
|
|
1513
1852
|
/**
|
|
1514
1853
|
* Refreshes metadata only if needed based on timestamp comparison.
|
|
@@ -1802,8 +2141,8 @@ export class ProviderBase {
|
|
|
1802
2141
|
* @returns Array of metadata update information
|
|
1803
2142
|
*/
|
|
1804
2143
|
async GetLatestMetadataUpdates(providerToUse) {
|
|
1805
|
-
|
|
1806
|
-
const d = await this.GetDatasetStatusByName(ProviderBase._mjMetadataDatasetName,
|
|
2144
|
+
// No schema filters for metadata — see comment in GetAllMetadata
|
|
2145
|
+
const d = await this.GetDatasetStatusByName(ProviderBase._mjMetadataDatasetName, null, this.CurrentUser, providerToUse);
|
|
1807
2146
|
if (d && d.Success) {
|
|
1808
2147
|
const ret = d.EntityUpdateDates.map(e => {
|
|
1809
2148
|
return {
|