@memberjunction/core 5.15.0 → 5.17.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 (43) hide show
  1. package/dist/generic/RegisterForStartup.d.ts +11 -5
  2. package/dist/generic/RegisterForStartup.d.ts.map +1 -1
  3. package/dist/generic/RegisterForStartup.js +12 -6
  4. package/dist/generic/RegisterForStartup.js.map +1 -1
  5. package/dist/generic/applicationInfo.d.ts.map +1 -1
  6. package/dist/generic/applicationInfo.js +1 -4
  7. package/dist/generic/applicationInfo.js.map +1 -1
  8. package/dist/generic/baseEngine.js +1 -1
  9. package/dist/generic/baseEngine.js.map +1 -1
  10. package/dist/generic/baseEntity.js +1 -1
  11. package/dist/generic/baseEntity.js.map +1 -1
  12. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  13. package/dist/generic/databaseProviderBase.js +6 -6
  14. package/dist/generic/databaseProviderBase.js.map +1 -1
  15. package/dist/generic/entityInfo.d.ts +2 -0
  16. package/dist/generic/entityInfo.d.ts.map +1 -1
  17. package/dist/generic/entityInfo.js +23 -8
  18. package/dist/generic/entityInfo.js.map +1 -1
  19. package/dist/generic/interfaces.d.ts +10 -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.js +1 -1
  23. package/dist/generic/localCacheManager.js.map +1 -1
  24. package/dist/generic/metadata.d.ts.map +1 -1
  25. package/dist/generic/metadata.js +19 -4
  26. package/dist/generic/metadata.js.map +1 -1
  27. package/dist/generic/providerBase.d.ts +67 -0
  28. package/dist/generic/providerBase.d.ts.map +1 -1
  29. package/dist/generic/providerBase.js +312 -11
  30. package/dist/generic/providerBase.js.map +1 -1
  31. package/dist/generic/queryInfo.d.ts.map +1 -1
  32. package/dist/generic/queryInfo.js +10 -2
  33. package/dist/generic/queryInfo.js.map +1 -1
  34. package/dist/generic/runQuerySQLFilterImplementations.d.ts +9 -3
  35. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  36. package/dist/generic/runQuerySQLFilterImplementations.js +11 -6
  37. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  38. package/dist/views/runView.js +2 -2
  39. package/dist/views/runView.js.map +1 -1
  40. package/dist/views/viewInfo.d.ts.map +1 -1
  41. package/dist/views/viewInfo.js +1 -3
  42. package/dist/views/viewInfo.js.map +1 -1
  43. package/package.json +2 -2
@@ -4,7 +4,7 @@ import { AllMetadata } from "./interfaces.js";
4
4
  import { LocalCacheManager } from "./localCacheManager.js";
5
5
  import { ApplicationInfo } from "../generic/applicationInfo.js";
6
6
  import { AuditLogTypeInfo, AuthorizationInfo, RoleInfo, RowLevelSecurityFilterInfo, UserInfo } from "./securityInfo.js";
7
- import { MJGlobal, UUIDsEqual } from "@memberjunction/global";
7
+ import { MJGlobal, NormalizeUUID, UUIDsEqual } from "@memberjunction/global";
8
8
  import { TelemetryManager } from "./telemetryManager.js";
9
9
  import { LogError, LogStatus, LogStatusEx } from "./logging.js";
10
10
  import { QueryCategoryInfo, QueryFieldInfo, QueryInfo, QueryPermissionInfo, QueryEntityInfo, QueryParameterInfo, QueryDependencyInfo, SQLDialectInfo, QuerySQLInfo } from "./queryInfo.js";
@@ -96,9 +96,19 @@ export const AllMetadataArrays = [
96
96
  export class ProviderBase {
97
97
  constructor() {
98
98
  this._localMetadata = new AllMetadata();
99
+ this._entityMapByName = new Map();
100
+ this._entityMapByID = new Map();
99
101
  this._entityRecordNameCache = new Map();
100
102
  this._refresh = false;
101
103
  this._lastRefreshCheckAt = 0;
104
+ /**
105
+ * Pending coalesced requests waiting to be flushed.
106
+ * Each entry tracks the original params, contextUser, and a resolver
107
+ * so the caller's promise resolves with only their slice of results.
108
+ */
109
+ this._coalesceQueue = [];
110
+ /** Timer handle for the coalesce flush */
111
+ this._coalesceTimer = null;
102
112
  /**
103
113
  * In-flight + linger cache keyed by a deterministic fingerprint of
104
114
  * the RunViewParams batch. While a request is in-flight, concurrent
@@ -133,6 +143,35 @@ export class ProviderBase {
133
143
  * Set to 0 to disable auto-caching. Default 250.
134
144
  */
135
145
  static { this.ServerAutoCacheMaxRows = 250; }
146
+ // ── Request Coalescing ─────────────────────────────────────────────
147
+ /**
148
+ * When enabled, concurrent RunViews calls arriving within the same
149
+ * microtask (or within CoalesceWindowMs) are merged into a single
150
+ * mega-batch before hitting the network. This dramatically reduces
151
+ * the number of HTTP round-trips during startup when multiple engines
152
+ * independently call RunViews in parallel.
153
+ *
154
+ * Set to 0 to disable coalescing. Default 10ms — enough to capture
155
+ * all engines that fire in the same tick, without adding perceptible delay.
156
+ */
157
+ static { this.CoalesceWindowMs = 10; }
158
+ // ── Fast Startup Mode ─────────────────────────────────────────────
159
+ /**
160
+ * When enabled, the first round of RunViews calls after startup will
161
+ * trust the local IndexedDB/localStorage cache without server validation.
162
+ * This eliminates the smart cache check round-trips during initial load,
163
+ * making warm page refreshes near-instant.
164
+ *
165
+ * After the first round of engine loads completes, FastStartupMode
166
+ * automatically disables itself so subsequent data access uses normal
167
+ * server validation.
168
+ *
169
+ * Only applies to client-side providers (TrustLocalCacheCompletely=false).
170
+ * Set to false to disable.
171
+ */
172
+ static { this.FastStartupMode = true; }
173
+ /** Tracks whether fast startup has been consumed (auto-disables after first use) */
174
+ static { this._fastStartupConsumed = false; }
136
175
  // ── Request Deduplication + Linger Window ──────────────────────────
137
176
  /**
138
177
  * How long (ms) a resolved RunViews result stays available for instant
@@ -335,6 +374,12 @@ export class ProviderBase {
335
374
  if (this.ShouldBypassDedup(params)) {
336
375
  return this.ExecuteRunViewsPipeline(params, contextUser);
337
376
  }
377
+ // ── Coalescing: merge concurrent RunViews into one mega-batch ──
378
+ if (ProviderBase.CoalesceWindowMs > 0 && !this.TrustLocalCacheCompletely) {
379
+ // Only coalesce on the client side where cache validation round-trips are expensive.
380
+ // Server-side trusts its cache (no network calls), so coalescing adds no benefit.
381
+ return this.enqueueCoalescedRunViews(params, contextUser);
382
+ }
338
383
  const key = this.GenerateDedupKey(params, contextUser);
339
384
  const existing = this._inflightViews.get(key);
340
385
  // ── Linger hit: resolved result still within the linger window ──
@@ -433,6 +478,128 @@ export class ProviderBase {
433
478
  await this.PostRunViews(finalResults, params, preResult, contextUser);
434
479
  return finalResults;
435
480
  }
481
+ // ── Coalescing implementation ─────────────────────────────────────
482
+ /**
483
+ * Enqueues a RunViews call for coalescing. Multiple calls arriving within
484
+ * CoalesceWindowMs are merged into a single mega-batch, executed once, and
485
+ * each caller receives only their slice of the results.
486
+ */
487
+ enqueueCoalescedRunViews(params, contextUser) {
488
+ return new Promise((resolve, reject) => {
489
+ this._coalesceQueue.push({
490
+ params,
491
+ contextUser,
492
+ resolve: resolve,
493
+ reject
494
+ });
495
+ // Start the coalesce timer if not already running
496
+ if (!this._coalesceTimer) {
497
+ this._coalesceTimer = setTimeout(() => this.flushCoalesceQueue(), ProviderBase.CoalesceWindowMs);
498
+ }
499
+ });
500
+ }
501
+ /**
502
+ * Flushes the coalesce queue: merges all pending RunViews params into one
503
+ * mega-batch, executes it, then splits results back to each original caller.
504
+ */
505
+ async flushCoalesceQueue() {
506
+ this._coalesceTimer = null;
507
+ // Grab and clear the queue atomically
508
+ const queue = this._coalesceQueue.splice(0);
509
+ if (queue.length === 0)
510
+ return;
511
+ // If only one caller, no merging needed
512
+ if (queue.length === 1) {
513
+ const entry = queue[0];
514
+ try {
515
+ const results = await this.RunViewsUncoalesced(entry.params, entry.contextUser);
516
+ entry.resolve(results);
517
+ }
518
+ catch (err) {
519
+ entry.reject(err);
520
+ }
521
+ return;
522
+ }
523
+ // Merge all params into one mega-batch, tracking boundaries
524
+ const allParams = [];
525
+ const boundaries = [];
526
+ // Use the first caller's contextUser (all should be the same on client-side)
527
+ const contextUser = queue[0].contextUser;
528
+ for (const entry of queue) {
529
+ boundaries.push({ start: allParams.length, count: entry.params.length });
530
+ allParams.push(...entry.params);
531
+ }
532
+ const entityNames = allParams.map(p => p.EntityName || p.ViewName || '?').join(', ');
533
+ LogStatusEx({
534
+ message: `⚡ [Coalesce] Merged ${queue.length} RunViews calls (${allParams.length} total entities) into 1 mega-batch: [${entityNames}]`,
535
+ verboseOnly: false
536
+ });
537
+ try {
538
+ // Execute the mega-batch as a single pipeline call
539
+ const allResults = await this.RunViewsUncoalesced(allParams, contextUser);
540
+ // Split results back to each original caller
541
+ for (let i = 0; i < queue.length; i++) {
542
+ const { start, count } = boundaries[i];
543
+ const callerResults = allResults.slice(start, start + count);
544
+ queue[i].resolve(callerResults);
545
+ }
546
+ }
547
+ catch (err) {
548
+ // If the mega-batch fails, reject all callers
549
+ for (const entry of queue) {
550
+ entry.reject(err);
551
+ }
552
+ }
553
+ }
554
+ /**
555
+ * Runs RunViews without coalescing (used internally after coalescing merge).
556
+ * This goes through dedup + linger + the full pipeline.
557
+ */
558
+ async RunViewsUncoalesced(params, contextUser) {
559
+ const key = this.GenerateDedupKey(params, contextUser);
560
+ const existing = this._inflightViews.get(key);
561
+ // ── Linger hit ──
562
+ if (existing?.resolvedResults && existing.resolvedAt) {
563
+ const age = Date.now() - existing.resolvedAt;
564
+ if (age < ProviderBase.DedupLingerMs) {
565
+ return existing.resolvedResults.map(r => this.ShallowCopyResult(r));
566
+ }
567
+ this._inflightViews.delete(key);
568
+ }
569
+ // ── In-flight hit ──
570
+ if (existing && !existing.resolvedResults) {
571
+ const results = await existing.promise;
572
+ return results.map(r => this.ShallowCopyResult(r));
573
+ }
574
+ // ── Fresh execution ──
575
+ const promise = this.ExecuteRunViewsPipeline(params, contextUser)
576
+ .then(results => {
577
+ const entry = this._inflightViews.get(key);
578
+ if (entry && entry.promise === promise) {
579
+ entry.resolvedResults = results;
580
+ entry.resolvedAt = Date.now();
581
+ if (ProviderBase.DedupLingerMs > 0) {
582
+ setTimeout(() => {
583
+ const current = this._inflightViews.get(key);
584
+ if (current && current.promise === promise) {
585
+ this._inflightViews.delete(key);
586
+ }
587
+ }, ProviderBase.DedupLingerMs);
588
+ }
589
+ else {
590
+ this._inflightViews.delete(key);
591
+ }
592
+ }
593
+ return results;
594
+ })
595
+ .catch(err => {
596
+ this._inflightViews.delete(key);
597
+ throw err;
598
+ });
599
+ this._inflightViews.set(key, { promise });
600
+ const results = await promise;
601
+ return results.map(r => this.ShallowCopyResult(r));
602
+ }
436
603
  /**
437
604
  * Generates a deterministic dedup key for a batch of RunViewParams.
438
605
  * Extends the local-cache fingerprint with additional fields that
@@ -549,7 +716,7 @@ export class ProviderBase {
549
716
  */
550
717
  async EntityStatusCheck(params, callerName) {
551
718
  const entityName = await RunView.GetEntityNameFromRunViewParams(params, this);
552
- const entity = this.Entities.find(e => e.Name.trim().toLowerCase() === entityName?.trim().toLowerCase());
719
+ const entity = entityName ? this.EntityByName(entityName) : undefined;
553
720
  if (!entity) {
554
721
  throw new Error(`Entity ${entityName} not found in metadata`);
555
722
  }
@@ -641,7 +808,7 @@ export class ProviderBase {
641
808
  // Handle entity_object result type - need all fields
642
809
  const entityLookupStart = performance.now();
643
810
  if (params.ResultType === 'entity_object') {
644
- const entity = this.Entities.find(e => e.Name.trim().toLowerCase() === params.EntityName.trim().toLowerCase());
811
+ const entity = this.EntityByName(params.EntityName);
645
812
  if (!entity)
646
813
  throw new Error(`Entity ${params.EntityName} not found in metadata`);
647
814
  params.Fields = entity.Fields.map(f => f.Name);
@@ -715,9 +882,52 @@ export class ProviderBase {
715
882
  // Server-side providers trust the cache completely and fall through to
716
883
  // the traditional flow which returns cached data immediately on hit.
717
884
  if (!this.TrustLocalCacheCompletely) {
718
- const useSmartCacheCheck = params.some(p => p.CacheLocal);
719
- if (useSmartCacheCheck && LocalCacheManager.Instance.IsInitialized) {
720
- return this.prepareSmartCacheCheckParams(params, telemetryEventId, contextUser);
885
+ // FastStartupMode: on the first engine load after a page refresh, trust
886
+ // the local IndexedDB cache without server validation. This eliminates
887
+ // the RunViewsWithCacheCheck round-trips that dominate warm-load TTI.
888
+ // After the first batch of engine loads, auto-disable so subsequent
889
+ // requests use normal server validation for data freshness.
890
+ const useFastStartup = ProviderBase.FastStartupMode
891
+ && !ProviderBase._fastStartupConsumed
892
+ && LocalCacheManager.Instance.IsInitialized
893
+ && params.some(p => p.CacheLocal);
894
+ if (useFastStartup) {
895
+ // Check if we actually have cached data for ALL params
896
+ let allHaveCachedData = true;
897
+ for (const param of params) {
898
+ if (param.CacheLocal) {
899
+ const fp = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
900
+ const cached = await LocalCacheManager.Instance.GetRunViewResult(fp);
901
+ if (!cached) {
902
+ allHaveCachedData = false;
903
+ break;
904
+ }
905
+ }
906
+ }
907
+ if (allHaveCachedData) {
908
+ // Mark fast startup as consumed — subsequent loads will validate normally
909
+ ProviderBase._fastStartupConsumed = true;
910
+ const entityNames = params.map(p => p.EntityName || p.ViewName || '?').join(', ');
911
+ LogStatusEx({
912
+ message: `⚡ [Fast-Start] Trusting local cache for ${params.length} views [${entityNames}] — skipping server validation`,
913
+ verboseOnly: false
914
+ });
915
+ // Fall through to the traditional flow below which will return cached data
916
+ }
917
+ else {
918
+ // Not all params have cached data — use normal smart cache check
919
+ ProviderBase._fastStartupConsumed = true; // Still consume the fast-start flag
920
+ const useSmartCacheCheck = params.some(p => p.CacheLocal);
921
+ if (useSmartCacheCheck) {
922
+ return this.prepareSmartCacheCheckParams(params, telemetryEventId, contextUser);
923
+ }
924
+ }
925
+ }
926
+ else if (!useFastStartup) {
927
+ const useSmartCacheCheck = params.some(p => p.CacheLocal);
928
+ if (useSmartCacheCheck && LocalCacheManager.Instance.IsInitialized) {
929
+ return this.prepareSmartCacheCheckParams(params, telemetryEventId, contextUser);
930
+ }
721
931
  }
722
932
  }
723
933
  // Traditional caching flow
@@ -731,7 +941,7 @@ export class ProviderBase {
731
941
  await this.EntityStatusCheck(param, 'PreRunViews');
732
942
  // Handle entity_object result type - need all fields
733
943
  if (param.ResultType === 'entity_object') {
734
- const entity = this.Entities.find(e => e.Name.trim().toLowerCase() === param.EntityName.trim().toLowerCase());
944
+ const entity = this.EntityByName(param.EntityName);
735
945
  if (!entity) {
736
946
  throw new Error(`Entity ${param.EntityName} not found in metadata`);
737
947
  }
@@ -795,7 +1005,7 @@ export class ProviderBase {
795
1005
  await this.EntityStatusCheck(param, 'PreRunViews');
796
1006
  // Handle entity_object result type - need all fields
797
1007
  if (param.ResultType === 'entity_object') {
798
- const entity = this.Entities.find(e => e.Name.trim().toLowerCase() === param.EntityName?.trim().toLowerCase());
1008
+ const entity = this.EntityByName(param.EntityName);
799
1009
  if (!entity) {
800
1010
  throw new Error(`Entity ${param.EntityName} not found in metadata`);
801
1011
  }
@@ -940,7 +1150,7 @@ export class ProviderBase {
940
1150
  // Cache is stale but we have differential data - merge with cached data
941
1151
  const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
942
1152
  // Get entity info for primary key field name
943
- const entity = this.Entities.find(e => e.Name.trim().toLowerCase() === param.EntityName?.trim().toLowerCase());
1153
+ const entity = this.EntityByName(param.EntityName);
944
1154
  const primaryKeyFieldName = entity?.FirstPrimaryKey?.Name || 'ID';
945
1155
  // Apply differential update to cache
946
1156
  if (param.CacheLocal && checkResult.differentialData && LocalCacheManager.Instance.IsInitialized) {
@@ -1379,7 +1589,7 @@ export class ProviderBase {
1379
1589
  // so that we can get the data to populate the entity object with.
1380
1590
  if (params.ResultType === 'entity_object') {
1381
1591
  // we need to get the entity definition and then get all the fields for it
1382
- const entity = this.Entities.find(e => e.Name.trim().toLowerCase() === params.EntityName.trim().toLowerCase());
1592
+ const entity = this.EntityByName(params.EntityName);
1383
1593
  if (!entity)
1384
1594
  throw new Error(`Entity ${params.EntityName} not found in metadata`);
1385
1595
  params.Fields = entity.Fields.map(f => f.Name); // just override whatever was passed in with all the fields - or if nothing was passed in, we set it. For loading the entity object, we need ALL the fields.
@@ -1427,7 +1637,7 @@ export class ProviderBase {
1427
1637
  // so that we can get the data to populate the entity object with.
1428
1638
  if (param.ResultType === 'entity_object') {
1429
1639
  // we need to get the entity definition and then get all the fields for it
1430
- const entity = this.Entities.find(e => e.Name.trim().toLowerCase() === param.EntityName.trim().toLowerCase());
1640
+ const entity = this.EntityByName(param.EntityName);
1431
1641
  if (!entity) {
1432
1642
  throw new Error(`Entity ${param.EntityName} not found in metadata`);
1433
1643
  }
@@ -1519,6 +1729,22 @@ export class ProviderBase {
1519
1729
  // Capture the hard-refresh flag before resetting it — when true, we must bypass all
1520
1730
  // caching (including LocalCacheManager) so GetDatasetByName hits the actual database.
1521
1731
  const hardRefresh = this._refresh;
1732
+ // ── Fast-start from cached metadata ──────────────────────────────────
1733
+ // On warm loads (page refresh), try loading metadata from IndexedDB/localStorage
1734
+ // BEFORE checking with the server. If cached metadata exists, use it immediately
1735
+ // so the app can start initializing (engines, UI) while we validate in the background.
1736
+ // This is a stale-while-revalidate pattern: serve cached data instantly, then
1737
+ // refresh if the server says it's outdated.
1738
+ if (!this.TrustLocalCacheCompletely && !hardRefresh && !this._localMetadata?.AllEntities?.length) {
1739
+ await this.LoadLocalMetadataFromStorage();
1740
+ if (this._localMetadata?.AllEntities?.length) {
1741
+ LogStatusEx({ message: `⚡ [Fast-Start] Loaded ${this._localMetadata.AllEntities.length} entities from local cache — deferring server validation`, verboseOnly: false });
1742
+ // Kick off background validation — if metadata is stale, it will
1743
+ // be atomically swapped when the server response arrives.
1744
+ this.backgroundValidateAndRefresh(providerToUse);
1745
+ return true; // App can proceed immediately with cached metadata
1746
+ }
1747
+ }
1522
1748
  if (hardRefresh || await this.CheckToSeeIfRefreshNeeded(providerToUse)) {
1523
1749
  // either a hard refresh flag was set within Refresh(), or LocalMetadata is Obsolete
1524
1750
  // first, make sure we reset the flag to false so that if another call to this function happens
@@ -1546,6 +1772,36 @@ export class ProviderBase {
1546
1772
  }
1547
1773
  return true;
1548
1774
  }
1775
+ /**
1776
+ * Background validation for the stale-while-revalidate fast-start pattern.
1777
+ * Checks if local metadata is still current; if stale, fetches fresh metadata
1778
+ * and atomically swaps it in. The app continues operating on cached data
1779
+ * during this process — no blocking.
1780
+ */
1781
+ async backgroundValidateAndRefresh(providerToUse) {
1782
+ try {
1783
+ const needsRefresh = await this.CheckToSeeIfRefreshNeeded(providerToUse);
1784
+ if (needsRefresh) {
1785
+ LogStatusEx({ message: `⚡ [Fast-Start] Background check: metadata is stale — refreshing...`, verboseOnly: false });
1786
+ const start = Date.now();
1787
+ const res = await this.GetAllMetadata(providerToUse, false);
1788
+ const elapsed = Date.now() - start;
1789
+ if (res) {
1790
+ this.UpdateLocalMetadata(res);
1791
+ this._latestLocalMetadataTimestamps = this._latestRemoteMetadataTimestamps;
1792
+ await this.SaveLocalMetadataToStorage();
1793
+ LogStatusEx({ message: `⚡ [Fast-Start] Background refresh complete (${elapsed}ms) — metadata updated in place`, verboseOnly: false });
1794
+ }
1795
+ }
1796
+ else {
1797
+ LogStatusEx({ message: `⚡ [Fast-Start] Background check: metadata is current — no refresh needed`, verboseOnly: false });
1798
+ }
1799
+ }
1800
+ catch (e) {
1801
+ LogError(`[Fast-Start] Background validation failed: ${e}`);
1802
+ // Not critical — app continues with cached metadata
1803
+ }
1804
+ }
1549
1805
  CloneAllMetadata(toClone) {
1550
1806
  // we need to create a copy but can't do it the standard way becuase we need object instances
1551
1807
  // for various things like EntityInfo
@@ -1717,6 +1973,26 @@ export class ProviderBase {
1717
1973
  get Entities() {
1718
1974
  return this._localMetadata.AllEntities;
1719
1975
  }
1976
+ EntityByName(entityName) {
1977
+ if (!entityName)
1978
+ return undefined;
1979
+ const key = entityName.trim().toLowerCase();
1980
+ if (this._entityMapByName.size > 0) {
1981
+ return this._entityMapByName.get(key);
1982
+ }
1983
+ // Fallback to linear search if maps haven't been built yet
1984
+ return this.Entities.find(e => e.Name.trim().toLowerCase() === key);
1985
+ }
1986
+ EntityByID(entityID) {
1987
+ if (!entityID)
1988
+ return undefined;
1989
+ const key = NormalizeUUID(entityID);
1990
+ if (this._entityMapByID.size > 0) {
1991
+ return this._entityMapByID.get(key);
1992
+ }
1993
+ // Fallback to linear search if maps haven't been built yet
1994
+ return this.Entities.find(e => UUIDsEqual(e.ID, entityID));
1995
+ }
1720
1996
  /**
1721
1997
  * Gets all application metadata in the system.
1722
1998
  * @returns Array of ApplicationInfo objects representing all applications
@@ -1986,6 +2262,15 @@ export class ProviderBase {
1986
2262
  async GetAndCacheDatasetByName(datasetName, itemFilters, contextUser, providerToUse) {
1987
2263
  // first see if we have anything in cache at all, no reason to check server dates if we dont
1988
2264
  if (await this.IsDatasetCached(datasetName, itemFilters)) {
2265
+ // FastStartupMode: on warm loads, trust the cached dataset without server validation.
2266
+ // Same pattern as FastStartupMode for RunViews — serve from IndexedDB immediately.
2267
+ if (ProviderBase.FastStartupMode && !this.TrustLocalCacheCompletely) {
2268
+ LogStatusEx({
2269
+ message: `⚡ [Fast-Start] Serving cached dataset "${datasetName}" from local cache — skipping server validation`,
2270
+ verboseOnly: false
2271
+ });
2272
+ return this.GetCachedDataset(datasetName, itemFilters);
2273
+ }
1989
2274
  // compare the local version, if exists to the server version dates
1990
2275
  if (await this.IsDatasetCacheUpToDate(datasetName, itemFilters)) {
1991
2276
  // we're up to date, all we need to do is get the local cache and return it
@@ -2265,6 +2550,22 @@ export class ProviderBase {
2265
2550
  */
2266
2551
  UpdateLocalMetadata(res) {
2267
2552
  this._localMetadata = res;
2553
+ this.RebuildEntityMaps();
2554
+ }
2555
+ /**
2556
+ * Rebuilds the O(1) entity lookup Maps from the current AllEntities array.
2557
+ * Called automatically from UpdateLocalMetadata().
2558
+ */
2559
+ RebuildEntityMaps() {
2560
+ const entities = this._localMetadata?.AllEntities;
2561
+ this._entityMapByName.clear();
2562
+ this._entityMapByID.clear();
2563
+ if (entities) {
2564
+ for (const e of entities) {
2565
+ this._entityMapByName.set(e.Name.trim().toLowerCase(), e);
2566
+ this._entityMapByID.set(NormalizeUUID(e.ID), e);
2567
+ }
2568
+ }
2268
2569
  }
2269
2570
  /**
2270
2571
  * Returns the filesystem provider for the current environment.