@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.
@@ -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
- // Delegate to RunViews with a single-element array to ensure smart cache check is used
235
- // This guarantees that CacheLocal uses server-side validation (maxUpdatedAt + rowCount check)
236
- // 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)
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
- * 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
+ *
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
- // Check if any params have CacheLocal enabled - smart caching is always used when caching locally
501
- const useSmartCacheCheck = params.some(p => p.CacheLocal);
502
- // If local caching is enabled, use smart cache check flow
503
- if (useSmartCacheCheck && LocalCacheManager.Instance.IsInitialized) {
504
- 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
+ }
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 ? cachedResults.filter(r => r !== null) : undefined,
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
- // Transform results in parallel
885
- const transformPromises = [];
886
- for (let i = 0; i < results.length; i++) {
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
- // 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
+ }
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 // Include aggregate results in cache
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
- //const start1 = new Date().getTime();
1322
- 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.
1323
1605
  // Get the dataset and cache it for anyone else who wants to use it
1324
- 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);
1325
1607
  if (d && d.Success) {
1326
1608
  // cache the dataset for anyone who wants to use it
1327
- await this.CacheDataset(ProviderBase._mjMetadataDatasetName, f.length > 0 ? f : null, d);
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
- const f = this.BuildDatasetFilterFromConfig();
1856
- 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);
1857
2146
  if (d && d.Success) {
1858
2147
  const ret = d.EntityUpdateDates.map(e => {
1859
2148
  return {