@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.
Files changed (48) hide show
  1. package/dist/generic/RegisterForStartup.js +1 -1
  2. package/dist/generic/RegisterForStartup.js.map +1 -1
  3. package/dist/generic/baseEngine.d.ts +64 -1
  4. package/dist/generic/baseEngine.d.ts.map +1 -1
  5. package/dist/generic/baseEngine.js +229 -22
  6. package/dist/generic/baseEngine.js.map +1 -1
  7. package/dist/generic/baseEntity.d.ts +31 -3
  8. package/dist/generic/baseEntity.d.ts.map +1 -1
  9. package/dist/generic/baseEntity.js +46 -0
  10. package/dist/generic/baseEntity.js.map +1 -1
  11. package/dist/generic/databaseProviderBase.d.ts +19 -1
  12. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  13. package/dist/generic/databaseProviderBase.js +44 -1
  14. package/dist/generic/databaseProviderBase.js.map +1 -1
  15. package/dist/generic/hookRegistry.d.ts +83 -0
  16. package/dist/generic/hookRegistry.d.ts.map +1 -0
  17. package/dist/generic/hookRegistry.js +87 -0
  18. package/dist/generic/hookRegistry.js.map +1 -0
  19. package/dist/generic/interfaces.d.ts +16 -0
  20. package/dist/generic/interfaces.d.ts.map +1 -1
  21. package/dist/generic/interfaces.js.map +1 -1
  22. package/dist/generic/localCacheManager.d.ts +200 -19
  23. package/dist/generic/localCacheManager.d.ts.map +1 -1
  24. package/dist/generic/localCacheManager.js +504 -135
  25. package/dist/generic/localCacheManager.js.map +1 -1
  26. package/dist/generic/providerBase.d.ts +106 -2
  27. package/dist/generic/providerBase.d.ts.map +1 -1
  28. package/dist/generic/providerBase.js +379 -40
  29. package/dist/generic/providerBase.js.map +1 -1
  30. package/dist/generic/queryInfo.d.ts +14 -6
  31. package/dist/generic/queryInfo.d.ts.map +1 -1
  32. package/dist/generic/queryInfo.js +15 -10
  33. package/dist/generic/queryInfo.js.map +1 -1
  34. package/dist/generic/queryInfoInterfaces.d.ts +6 -1
  35. package/dist/generic/queryInfoInterfaces.d.ts.map +1 -1
  36. package/dist/generic/securityInfo.d.ts +41 -0
  37. package/dist/generic/securityInfo.d.ts.map +1 -1
  38. package/dist/generic/securityInfo.js +23 -2
  39. package/dist/generic/securityInfo.js.map +1 -1
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +1 -0
  43. package/dist/index.js.map +1 -1
  44. package/dist/views/runView.d.ts +33 -0
  45. package/dist/views/runView.d.ts.map +1 -1
  46. package/dist/views/runView.js.map +1 -1
  47. package/package.json +2 -2
  48. 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
- // Delegate to RunViews with a single-element array to ensure smart cache check is used
234
- // This guarantees that CacheLocal uses server-side validation (maxUpdatedAt + rowCount check)
235
- // rather than blindly accepting stale local cache
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
- * This method orchestrates the full execution flow for batch operations.
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
- // Check if any params have CacheLocal enabled - smart caching is always used when caching locally
492
- const useSmartCacheCheck = params.some(p => p.CacheLocal);
493
- // If local caching is enabled, use smart cache check flow
494
- if (useSmartCacheCheck && LocalCacheManager.Instance.IsInitialized) {
495
- return this.prepareSmartCacheCheckParams(params, telemetryEventId, contextUser);
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 ? cachedResults.filter(r => r !== null) : undefined,
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
- // Transform the result set into BaseEntity-derived objects, if needed
842
- await this.TransformSimpleObjectToEntityObject(params, result, contextUser);
843
- // Store in local cache if enabled and we have a successful result
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 // Include aggregate results in cache
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
- // Transform results in parallel
870
- const promises = [];
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
- promises.push(this.TransformSimpleObjectToEntityObject(params[i], results[i], contextUser));
873
- // Store in local cache if enabled
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
- promises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults // Include aggregate results in cache
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(promises);
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
- //const start1 = new Date().getTime();
1272
- const f = this.BuildDatasetFilterFromConfig();
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, f.length > 0 ? f : null, this.CurrentUser, providerToUse);
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, f.length > 0 ? f : null, d);
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
- const f = this.BuildDatasetFilterFromConfig();
1806
- const d = await this.GetDatasetStatusByName(ProviderBase._mjMetadataDatasetName, f.length > 0 ? f : null, this.CurrentUser, providerToUse);
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 {