@memberjunction/core 5.9.0 → 5.10.1
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.map +1 -1
- package/dist/generic/baseEngine.js +4 -0
- package/dist/generic/baseEngine.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/localCacheManager.d.ts +1 -1
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +32 -20
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/providerBase.d.ts +96 -2
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +336 -47
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/queryInfo.d.ts +5 -0
- package/dist/generic/queryInfo.d.ts.map +1 -1
- package/dist/generic/queryInfo.js +5 -0
- package/dist/generic/queryInfo.js.map +1 -1
- package/dist/generic/queryInfoInterfaces.d.ts +5 -0
- package/dist/generic/queryInfoInterfaces.d.ts.map +1 -1
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +7 -2
- package/dist/generic/securityInfo.js.map +1 -1
- package/package.json +2 -2
|
@@ -97,8 +97,65 @@ export class ProviderBase {
|
|
|
97
97
|
this._localMetadata = new AllMetadata();
|
|
98
98
|
this._entityRecordNameCache = new Map();
|
|
99
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();
|
|
100
108
|
this._cachedVisibleExplorerNavigationItems = null;
|
|
101
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
|
+
}
|
|
102
159
|
/**
|
|
103
160
|
* Helper to generate cache key for entity record names
|
|
104
161
|
*/
|
|
@@ -231,20 +288,119 @@ export class ProviderBase {
|
|
|
231
288
|
* @returns The view results
|
|
232
289
|
*/
|
|
233
290
|
async RunView(params, contextUser) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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)
|
|
237
318
|
const results = await this.RunViews([params], contextUser);
|
|
238
319
|
return results[0];
|
|
239
320
|
}
|
|
240
321
|
/**
|
|
241
322
|
* Runs multiple views based on the provided parameters.
|
|
242
|
-
*
|
|
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
|
+
*
|
|
243
328
|
* @param params - Array of view parameters
|
|
244
329
|
* @param contextUser - Optional user context for permissions (required server-side)
|
|
245
|
-
* @returns Array of view results
|
|
330
|
+
* @returns Array of view results (shallow-copied Results per caller)
|
|
246
331
|
*/
|
|
247
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) {
|
|
248
404
|
// Pre-processing for batch
|
|
249
405
|
const preResult = await this.PreRunViews(params, contextUser);
|
|
250
406
|
// Check for smart cache check mode
|
|
@@ -253,7 +409,9 @@ export class ProviderBase {
|
|
|
253
409
|
}
|
|
254
410
|
// Check for cached results - if all are cached, end telemetry and return early
|
|
255
411
|
if (preResult.allCached && preResult.cachedResults) {
|
|
412
|
+
const entities = params.map(p => p.EntityName || p.ViewName || 'unknown').join(', ');
|
|
256
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 });
|
|
257
415
|
TelemetryManager.Instance.EndEvent(preResult.telemetryEventId, {
|
|
258
416
|
cacheHit: true,
|
|
259
417
|
allCached: true,
|
|
@@ -263,6 +421,8 @@ export class ProviderBase {
|
|
|
263
421
|
return preResult.cachedResults;
|
|
264
422
|
}
|
|
265
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 });
|
|
266
426
|
const results = await this.InternalRunViews(preResult.uncachedParams || params, contextUser);
|
|
267
427
|
// Merge cached and fresh results if needed
|
|
268
428
|
const finalResults = preResult.cachedResults
|
|
@@ -272,6 +432,45 @@ export class ProviderBase {
|
|
|
272
432
|
await this.PostRunViews(finalResults, params, preResult, contextUser);
|
|
273
433
|
return finalResults;
|
|
274
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
|
+
}
|
|
275
474
|
/**
|
|
276
475
|
* Runs a query based on the provided parameters.
|
|
277
476
|
* This method orchestrates the full execution flow: pre-processing, cache check,
|
|
@@ -441,7 +640,7 @@ export class ProviderBase {
|
|
|
441
640
|
let cacheStatus = 'disabled';
|
|
442
641
|
let cachedResult;
|
|
443
642
|
let fingerprint;
|
|
444
|
-
if (params.CacheLocal && LocalCacheManager.Instance.IsInitialized) {
|
|
643
|
+
if ((params.CacheLocal || this.TrustLocalCacheCompletely) && LocalCacheManager.Instance.IsInitialized) {
|
|
445
644
|
fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString);
|
|
446
645
|
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
447
646
|
if (cached) {
|
|
@@ -457,6 +656,9 @@ export class ProviderBase {
|
|
|
457
656
|
AggregateResults: cached.aggregateResults // Include cached aggregate results
|
|
458
657
|
};
|
|
459
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
|
+
}
|
|
460
662
|
}
|
|
461
663
|
else {
|
|
462
664
|
cacheStatus = 'miss';
|
|
@@ -497,11 +699,14 @@ export class ProviderBase {
|
|
|
497
699
|
Entities: params.map(p => p.EntityName || p.ViewName || p.ViewID).filter(Boolean),
|
|
498
700
|
_fromEngine: fromEngine
|
|
499
701
|
}, contextUser?.ID);
|
|
500
|
-
//
|
|
501
|
-
|
|
502
|
-
//
|
|
503
|
-
if (
|
|
504
|
-
|
|
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
|
+
}
|
|
505
710
|
}
|
|
506
711
|
// Traditional caching flow
|
|
507
712
|
const cacheStatusMap = new Map();
|
|
@@ -520,8 +725,8 @@ export class ProviderBase {
|
|
|
520
725
|
}
|
|
521
726
|
param.Fields = entity.Fields.map(f => f.Name);
|
|
522
727
|
}
|
|
523
|
-
// Check local cache if enabled
|
|
524
|
-
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) {
|
|
525
730
|
const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
|
|
526
731
|
const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
|
|
527
732
|
if (cached) {
|
|
@@ -537,10 +742,15 @@ export class ProviderBase {
|
|
|
537
742
|
};
|
|
538
743
|
// if needed this will transform each result into an entity object
|
|
539
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 });
|
|
540
749
|
cacheStatusMap.set(i, { status: 'hit', result: cachedViewResult });
|
|
541
750
|
cachedResults.push(cachedViewResult);
|
|
542
751
|
continue;
|
|
543
752
|
}
|
|
753
|
+
LogStatusEx({ message: ` 🔍 [Cache MISS] "${param.EntityName || param.ViewName || 'unknown'}" — will query database`, verboseOnly: true });
|
|
544
754
|
cacheStatusMap.set(i, { status: 'miss' });
|
|
545
755
|
}
|
|
546
756
|
else {
|
|
@@ -550,10 +760,13 @@ export class ProviderBase {
|
|
|
550
760
|
uncachedParams.push(param);
|
|
551
761
|
cachedResults.push(null); // Placeholder for uncached
|
|
552
762
|
}
|
|
763
|
+
const hasCacheHits = cacheStatusMap.size > 0 && [...cacheStatusMap.values()].some(v => v.status === 'hit');
|
|
553
764
|
return {
|
|
554
765
|
telemetryEventId,
|
|
555
766
|
allCached,
|
|
556
|
-
cachedResults: allCached
|
|
767
|
+
cachedResults: allCached
|
|
768
|
+
? cachedResults.filter(r => r !== null)
|
|
769
|
+
: (hasCacheHits ? cachedResults : undefined),
|
|
557
770
|
uncachedParams: allCached ? undefined : uncachedParams,
|
|
558
771
|
cacheStatusMap
|
|
559
772
|
};
|
|
@@ -847,17 +1060,28 @@ export class ProviderBase {
|
|
|
847
1060
|
* @param contextUser - Optional user context
|
|
848
1061
|
*/
|
|
849
1062
|
async PostRunView(result, params, preResult, contextUser) {
|
|
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'.
|
|
1068
|
+
if (params.CacheLocal && result.Success && preResult.fingerprint && LocalCacheManager.Instance.IsInitialized) {
|
|
1069
|
+
const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
|
|
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
|
+
}
|
|
850
1081
|
// Transform the result set into BaseEntity-derived objects, if needed
|
|
851
1082
|
await this.TransformSimpleObjectToEntityObject(params, result, contextUser);
|
|
852
1083
|
// Run registered PostRunView hooks (e.g., data masking, audit logging)
|
|
853
1084
|
result = await this.RunPostRunViewHooks(params, result, contextUser);
|
|
854
|
-
// Store in local cache if enabled and we have a successful result
|
|
855
|
-
if (params.CacheLocal && result.Success && preResult.fingerprint && LocalCacheManager.Instance.IsInitialized) {
|
|
856
|
-
// Extract maxUpdatedAt from results if available
|
|
857
|
-
const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
|
|
858
|
-
await LocalCacheManager.Instance.SetRunViewResult(preResult.fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults // Include aggregate results in cache
|
|
859
|
-
);
|
|
860
|
-
}
|
|
861
1085
|
// Register OnDataChanged callback if provided and we have a fingerprint
|
|
862
1086
|
if (params.OnDataChanged && preResult.fingerprint) {
|
|
863
1087
|
result.Unsubscribe = LocalCacheManager.Instance.RegisterChangeCallback(preResult.fingerprint, params.OnDataChanged);
|
|
@@ -881,25 +1105,26 @@ export class ProviderBase {
|
|
|
881
1105
|
* @param contextUser - Optional user context
|
|
882
1106
|
*/
|
|
883
1107
|
async PostRunViews(results, params, preResult, contextUser) {
|
|
884
|
-
//
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
transformPromises.push(this.TransformSimpleObjectToEntityObject(params[i], results[i], contextUser));
|
|
888
|
-
}
|
|
889
|
-
await Promise.all(transformPromises);
|
|
890
|
-
// Run registered PostRunView hooks on each result in the batch
|
|
891
|
-
for (let i = 0; i < results.length; i++) {
|
|
892
|
-
results[i] = await this.RunPostRunViewHooks(params[i], results[i], contextUser);
|
|
893
|
-
}
|
|
894
|
-
// Store in local cache if enabled
|
|
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.
|
|
895
1111
|
const cachePromises = [];
|
|
896
1112
|
for (let i = 0; i < results.length; i++) {
|
|
897
|
-
//
|
|
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
|
+
}
|
|
898
1119
|
const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params[i], this.InstanceConnectionString);
|
|
899
1120
|
if (params[i].CacheLocal && results[i].Success && LocalCacheManager.Instance.IsInitialized) {
|
|
900
1121
|
const maxUpdatedAt = this.extractMaxUpdatedAt(results[i].Results);
|
|
901
|
-
cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults
|
|
902
|
-
|
|
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 });
|
|
903
1128
|
}
|
|
904
1129
|
// Register OnDataChanged callback if provided
|
|
905
1130
|
if (params[i].OnDataChanged && fingerprint) {
|
|
@@ -907,6 +1132,21 @@ export class ProviderBase {
|
|
|
907
1132
|
}
|
|
908
1133
|
}
|
|
909
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
|
+
}
|
|
910
1150
|
// End telemetry tracking with batch info
|
|
911
1151
|
if (preResult.telemetryEventId) {
|
|
912
1152
|
const totalResults = results.reduce((sum, r) => sum + (r.Results?.length ?? 0), 0);
|
|
@@ -1000,6 +1240,42 @@ export class ProviderBase {
|
|
|
1000
1240
|
* @param results - Array of result objects that may contain __mj_UpdatedAt
|
|
1001
1241
|
* @returns ISO string of the max timestamp, or current time if none found
|
|
1002
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
|
+
}
|
|
1003
1279
|
extractMaxUpdatedAt(results) {
|
|
1004
1280
|
let maxDate = null;
|
|
1005
1281
|
for (const item of results) {
|
|
@@ -1213,6 +1489,12 @@ export class ProviderBase {
|
|
|
1213
1489
|
*/
|
|
1214
1490
|
async Config(data, providerToUse) {
|
|
1215
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
|
+
}
|
|
1216
1498
|
// first, let's check to see if we have an existing Metadata.Provider registered, if so
|
|
1217
1499
|
// unless our data.IgnoreExistingMetadata is set to true, we will not refresh the metadata
|
|
1218
1500
|
if (Metadata.Provider && !data.IgnoreExistingMetadata) {
|
|
@@ -1318,13 +1600,13 @@ export class ProviderBase {
|
|
|
1318
1600
|
async GetAllMetadata(providerToUse) {
|
|
1319
1601
|
try {
|
|
1320
1602
|
// we are now using datasets instead of the custom metadata to GraphQL to simplify GraphQL's work as it was very slow preivously
|
|
1321
|
-
//
|
|
1322
|
-
|
|
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.
|
|
1323
1605
|
// Get the dataset and cache it for anyone else who wants to use it
|
|
1324
|
-
const d = await this.GetDatasetByName(ProviderBase._mjMetadataDatasetName,
|
|
1606
|
+
const d = await this.GetDatasetByName(ProviderBase._mjMetadataDatasetName, null, this.CurrentUser, providerToUse);
|
|
1325
1607
|
if (d && d.Success) {
|
|
1326
1608
|
// cache the dataset for anyone who wants to use it
|
|
1327
|
-
await this.CacheDataset(ProviderBase._mjMetadataDatasetName,
|
|
1609
|
+
await this.CacheDataset(ProviderBase._mjMetadataDatasetName, null, d);
|
|
1328
1610
|
// got the results, let's build our response in the format we need
|
|
1329
1611
|
const simpleMetadata = {};
|
|
1330
1612
|
for (let r of d.Results) {
|
|
@@ -1552,13 +1834,20 @@ export class ProviderBase {
|
|
|
1552
1834
|
* @returns True if refresh is needed, false otherwise
|
|
1553
1835
|
*/
|
|
1554
1836
|
async CheckToSeeIfRefreshNeeded(providerToUse) {
|
|
1555
|
-
if (this.AllowRefresh)
|
|
1556
|
-
await this.RefreshRemoteMetadataTimestamps(providerToUse); // get the latest timestamps from the server first
|
|
1557
|
-
await this.LoadLocalMetadataFromStorage(); // then, attempt to load before we check to see if it is obsolete
|
|
1558
|
-
return this.LocalMetadataObsolete();
|
|
1559
|
-
}
|
|
1560
|
-
else //subclass is telling us not to do any refresh ops right now
|
|
1837
|
+
if (!this.AllowRefresh)
|
|
1561
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();
|
|
1562
1851
|
}
|
|
1563
1852
|
/**
|
|
1564
1853
|
* Refreshes metadata only if needed based on timestamp comparison.
|
|
@@ -1852,8 +2141,8 @@ export class ProviderBase {
|
|
|
1852
2141
|
* @returns Array of metadata update information
|
|
1853
2142
|
*/
|
|
1854
2143
|
async GetLatestMetadataUpdates(providerToUse) {
|
|
1855
|
-
|
|
1856
|
-
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);
|
|
1857
2146
|
if (d && d.Success) {
|
|
1858
2147
|
const ret = d.EntityUpdateDates.map(e => {
|
|
1859
2148
|
return {
|