@memberjunction/core 5.29.0 → 5.30.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.
@@ -26,11 +26,16 @@ import { TransformSimpleObjectToEntityObject } from "./util.js";
26
26
  export function MetadataFromSimpleObject(data, md) {
27
27
  try {
28
28
  const newObject = MetadataFromSimpleObjectWithoutUser(data, md);
29
+ if (!newObject) {
30
+ LogError('MetadataFromSimpleObject: MetadataFromSimpleObjectWithoutUser returned undefined');
31
+ return undefined;
32
+ }
29
33
  newObject.CurrentUser = data.CurrentUser ? new UserInfo(md, data.CurrentUser) : null;
30
34
  return newObject;
31
35
  }
32
36
  catch (e) {
33
- LogError(e);
37
+ LogError(`MetadataFromSimpleObject failed: ${e instanceof Error ? e.message : String(e)}`);
38
+ return undefined;
34
39
  }
35
40
  }
36
41
  /**
@@ -53,14 +58,26 @@ export function MetadataFromSimpleObjectWithoutUser(data, md) {
53
58
  // at this point, only do this particular property if we have a match, it is either prefixed with All or not
54
59
  // for example in our strongly typed AllMetadata class we have AllQueryCategories, but in the simple allMetadata object we have QueryCategories
55
60
  // so we need to check for both which is what the above is doing.
56
- // Build the array of the correct type and initialize with the simple object
57
- returnMetadata[m.key] = data[simpleKey].map((d) => new m.class(d, md));
61
+ // Build the array of the correct type and initialize with the simple object.
62
+ // Individual item failures are logged but do not abort the entire deserialization —
63
+ // a cache with 514 of 515 entities is far better than no cache at all.
64
+ const items = [];
65
+ for (const d of data[simpleKey]) {
66
+ try {
67
+ items.push(new m.class(d, md));
68
+ }
69
+ catch (itemErr) {
70
+ LogError(`MetadataFromSimpleObject: failed to construct ${m.class?.name || m.key} item: ${itemErr instanceof Error ? itemErr.message : String(itemErr)}`);
71
+ }
72
+ }
73
+ returnMetadata[m.key] = items;
58
74
  }
59
75
  }
60
76
  return returnMetadata;
61
77
  }
62
78
  catch (e) {
63
- LogError(e);
79
+ LogError(`MetadataFromSimpleObjectWithoutUser failed: ${e instanceof Error ? e.message : String(e)}`);
80
+ return undefined;
64
81
  }
65
82
  }
66
83
  /**
@@ -171,8 +188,20 @@ export class ProviderBase {
171
188
  * Set to false to disable.
172
189
  */
173
190
  static { this.FastStartupMode = true; }
174
- /** Tracks whether fast startup has been consumed (auto-disables after first use) */
191
+ /** Tracks whether fast startup has been consumed (auto-disables after startup completes) */
175
192
  static { this._fastStartupConsumed = false; }
193
+ /**
194
+ * Marks the fast-startup window as closed. After this call, all RunViews
195
+ * requests will use normal server-validated caching instead of trusting
196
+ * local IndexedDB unconditionally. Called by StartupManager after all
197
+ * engines have completed their initial load.
198
+ */
199
+ static ConsumeFastStartupMode() {
200
+ if (!ProviderBase._fastStartupConsumed) {
201
+ ProviderBase._fastStartupConsumed = true;
202
+ LogStatusEx({ message: '⚡ [Fast-Start] Startup complete — fast-start mode disabled, server validation re-enabled', verboseOnly: false });
203
+ }
204
+ }
176
205
  // ── Request Deduplication + Linger Window ──────────────────────────
177
206
  /**
178
207
  * How long (ms) a resolved RunViews result stays available for instant
@@ -949,10 +978,15 @@ export class ProviderBase {
949
978
  const callerRequestedFields = params.Fields && params.Fields.length > 0
950
979
  ? params.Fields.map(f => f.trim().toLowerCase())
951
980
  : null; // null = caller wants all fields
952
- // Always override Fields to all entity fields for the DB query.
953
- // This ensures one cache entry per entity+filter that satisfies all field subsets.
981
+ // Only override Fields to all entity fields when caching will actually happen
982
+ // for this call. For non-cached calls we respect the caller's narrow Fields
983
+ // end-to-end — there's no cache-coherence concern to preserve.
954
984
  const entity = params.EntityName ? this.EntityByName(params.EntityName) : null;
955
- if (entity) {
985
+ const entityCacheAllowed = this.IsServerCacheAllowedForEntity(params);
986
+ const willCache = !params.BypassCache &&
987
+ (params.CacheLocal || this.TrustLocalCacheCompletely) &&
988
+ entityCacheAllowed;
989
+ if (entity && willCache) {
956
990
  params.Fields = entity.Fields.map(f => f.Name);
957
991
  }
958
992
  const entityLookupTime = performance.now() - entityLookupStart;
@@ -961,8 +995,7 @@ export class ProviderBase {
961
995
  let cacheStatus = 'disabled';
962
996
  let cachedResult;
963
997
  let fingerprint;
964
- const entityCacheAllowed = this.IsServerCacheAllowedForEntity(params);
965
- if ((params.CacheLocal || this.TrustLocalCacheCompletely) && entityCacheAllowed && LocalCacheManager.Instance.IsInitialized) {
998
+ if (willCache && LocalCacheManager.Instance.IsInitialized) {
966
999
  fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString);
967
1000
  const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
968
1001
  if (cached) {
@@ -1056,21 +1089,23 @@ export class ProviderBase {
1056
1089
  && LocalCacheManager.Instance.IsInitialized
1057
1090
  && params.some(p => p.CacheLocal);
1058
1091
  if (useFastStartup) {
1059
- // Check if we actually have cached data for ALL params
1060
- let allHaveCachedData = true;
1061
- for (const param of params) {
1062
- if (param.CacheLocal) {
1063
- const fp = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
1064
- const cached = await LocalCacheManager.Instance.GetRunViewResult(fp);
1065
- if (!cached) {
1066
- allHaveCachedData = false;
1067
- break;
1068
- }
1069
- }
1070
- }
1092
+ // Check if we actually have cached data for ALL params.
1093
+ // Use Promise.all to parallelize IndexedDB reads — sequential awaits
1094
+ // cause ~100ms Zone.js scheduling overhead per read, which adds up to
1095
+ // 10+ seconds across 86+ views.
1096
+ const cacheCheckResults = await Promise.all(params.map(async (param) => {
1097
+ if (!param.CacheLocal)
1098
+ return true; // non-cached params don't block
1099
+ const fp = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
1100
+ const cached = await LocalCacheManager.Instance.GetRunViewResult(fp);
1101
+ return cached != null;
1102
+ }));
1103
+ const allHaveCachedData = cacheCheckResults.every(Boolean);
1071
1104
  if (allHaveCachedData) {
1072
- // Mark fast startup as consumed subsequent loads will validate normally
1073
- ProviderBase._fastStartupConsumed = true;
1105
+ // Do NOT consume the fast-start flag here multiple engines fire
1106
+ // RunViews in parallel during StartupManager.Startup(), and each one
1107
+ // should benefit from the local cache. StartupManager.Startup() will
1108
+ // call ConsumeFastStartupMode() after all engines complete.
1074
1109
  const entityNames = params.map(p => p.EntityName || p.ViewName || '?').join(', ');
1075
1110
  LogStatusEx({
1076
1111
  message: `⚡ [Fast-Start] Trusting local cache for ${params.length} views [${entityNames}] — skipping server validation`,
@@ -1080,7 +1115,6 @@ export class ProviderBase {
1080
1115
  }
1081
1116
  else {
1082
1117
  // Not all params have cached data — use normal smart cache check
1083
- ProviderBase._fastStartupConsumed = true; // Still consume the fast-start flag
1084
1118
  const useSmartCacheCheck = params.some(p => p.CacheLocal);
1085
1119
  if (useSmartCacheCheck) {
1086
1120
  return this.prepareSmartCacheCheckParams(params, telemetryEventId, contextUser);
@@ -1108,14 +1142,21 @@ export class ProviderBase {
1108
1142
  const callerFields = param.Fields && param.Fields.length > 0
1109
1143
  ? param.Fields.map(f => f.trim().toLowerCase())
1110
1144
  : null;
1145
+ // Only override Fields to all entity fields when caching will actually happen
1146
+ // for this call. For non-cached calls we respect the caller's narrow Fields
1147
+ // end-to-end — there's no cache-coherence concern to preserve.
1111
1148
  const batchEntity = param.EntityName ? this.EntityByName(param.EntityName) : null;
1112
- if (batchEntity) {
1149
+ const batchEntityCacheAllowed = this.IsServerCacheAllowedForEntity(param);
1150
+ const batchWillCache = !param.BypassCache &&
1151
+ (param.CacheLocal || this.TrustLocalCacheCompletely) &&
1152
+ batchEntityCacheAllowed;
1153
+ if (batchEntity && batchWillCache) {
1113
1154
  param.Fields = batchEntity.Fields.map(f => f.Name);
1114
1155
  }
1115
1156
  // Check local cache if enabled or if server trusts its cache completely
1116
1157
  // BypassCache skips cache entirely — used by maintenance actions querying for
1117
1158
  // records that were inserted via direct SQL (bypassing BaseEntity.Save())
1118
- if (!param.BypassCache && (param.CacheLocal || this.TrustLocalCacheCompletely) && LocalCacheManager.Instance.IsInitialized) {
1159
+ if (batchWillCache && LocalCacheManager.Instance.IsInitialized) {
1119
1160
  const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
1120
1161
  const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
1121
1162
  if (cached) {
@@ -1943,9 +1984,10 @@ export class ProviderBase {
1943
1984
  await this.LoadLocalMetadataFromStorage();
1944
1985
  if (this._localMetadata?.AllEntities?.length) {
1945
1986
  LogStatusEx({ message: `⚡ [Fast-Start] Loaded ${this._localMetadata.AllEntities.length} entities from local cache — deferring server validation`, verboseOnly: false });
1946
- // Kick off background validation — if metadata is stale, it will
1947
- // be atomically swapped when the server response arrives.
1948
- this.backgroundValidateAndRefresh(providerToUse);
1987
+ // Do NOT kick off background validation here it writes to IndexedDB
1988
+ // which contends with engine RunView cache reads during StartupManager.
1989
+ // Instead, the caller (setupGraphQLClient) should call
1990
+ // BackgroundValidateAndRefresh() after StartupManager completes.
1949
1991
  return true; // App can proceed immediately with cached metadata
1950
1992
  }
1951
1993
  }
@@ -2006,6 +2048,60 @@ export class ProviderBase {
2006
2048
  // Not critical — app continues with cached metadata
2007
2049
  }
2008
2050
  }
2051
+ /**
2052
+ * Synchronous pre-validation of cached metadata before engine startup.
2053
+ *
2054
+ * This is the deterministic counterpart to {@link backgroundValidateAndRefresh}: instead
2055
+ * of letting engines fast-start against potentially-stale cached data and self-healing
2056
+ * a few seconds later, we make one timestamp round-trip up front. The flow:
2057
+ *
2058
+ * - **Cached metadata is current** → keep `FastStartupMode` enabled. Engines trust
2059
+ * their local IndexedDB caches without per-view smart-cache-check round-trips,
2060
+ * and we have just verified at the framework metadata level that nothing has
2061
+ * drifted since this client last loaded.
2062
+ * - **Cached metadata is stale** → refresh framework metadata in place, then call
2063
+ * {@link ProviderBase.ConsumeFastStartupMode} to disable fast-start. Engines
2064
+ * proceed through the normal smart-cache-check path so each per-view cache is
2065
+ * re-validated against the server.
2066
+ *
2067
+ * Cost on the warm-current path is one batched timestamp fetch (~50–200 ms depending
2068
+ * on RTT). On the warm-stale path we additionally pay the full metadata fetch but
2069
+ * avoid serving stale data to the UI in the first place.
2070
+ *
2071
+ * Caller contract: invoke this before `StartupManager.Startup()` so engines see the
2072
+ * correct fast-start state from their first `RunViews()` call. Failures here are
2073
+ * non-fatal — the engine layer's smart-cache-check + event-based invalidation
2074
+ * remain as a safety net.
2075
+ */
2076
+ async preValidateAndRefresh(providerToUse) {
2077
+ try {
2078
+ const needsRefresh = await this.CheckToSeeIfRefreshNeeded(providerToUse);
2079
+ if (needsRefresh) {
2080
+ LogStatusEx({ message: `⚡ [Fast-Start] Pre-validation: metadata is stale — refreshing before engine startup and disabling fast-start`, verboseOnly: false });
2081
+ const start = Date.now();
2082
+ const res = await this.GetAllMetadata(providerToUse, false);
2083
+ const elapsed = Date.now() - start;
2084
+ if (res) {
2085
+ this.UpdateLocalMetadata(res);
2086
+ this._latestLocalMetadataTimestamps = this._latestRemoteMetadataTimestamps;
2087
+ await this.SaveLocalMetadataToStorage();
2088
+ LogStatusEx({ message: `⚡ [Fast-Start] Pre-validation refresh complete (${elapsed}ms) — engines will smart-cache-check`, verboseOnly: false });
2089
+ }
2090
+ // Force engines through the normal smart-cache-check path. This re-validates
2091
+ // each per-view IndexedDB cache against the server rather than trusting it.
2092
+ ProviderBase.ConsumeFastStartupMode();
2093
+ }
2094
+ else {
2095
+ LogStatusEx({ message: `⚡ [Fast-Start] Pre-validation: metadata is current — fast-start engaged`, verboseOnly: false });
2096
+ }
2097
+ }
2098
+ catch (e) {
2099
+ LogError(`[Fast-Start] Pre-validation failed: ${e instanceof Error ? e.message : String(e)} — falling back to smart-cache-check`);
2100
+ // On failure, disable fast-start so engines validate per-view rather than
2101
+ // trusting potentially-stale cache against a known-unknown server state.
2102
+ ProviderBase.ConsumeFastStartupMode();
2103
+ }
2104
+ }
2009
2105
  CloneAllMetadata(toClone) {
2010
2106
  // we need to create a copy but can't do it the standard way becuase we need object instances
2011
2107
  // for various things like EntityInfo
@@ -2786,28 +2882,90 @@ export class ProviderBase {
2786
2882
  async LoadLocalMetadataFromStorage() {
2787
2883
  try {
2788
2884
  const ls = this.LocalStorageProvider;
2789
- if (ls) {
2790
- // execution environment supports local storage, use it
2791
- this._latestLocalMetadataTimestamps = JSON.parse(await ls.GetItem(this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey));
2792
- const temp = JSON.parse(await ls.GetItem(this.LocalStoragePrefix + ProviderBase.localStorageAllMetadataKey)); // we now have a simple object for all the metadata
2793
- if (temp) {
2794
- // we have local metadata
2795
- const metadata = MetadataFromSimpleObject(temp, this); // create a new object to start this up
2796
- this.UpdateLocalMetadata(metadata);
2797
- }
2885
+ if (!ls)
2886
+ return;
2887
+ const overallStart = Date.now();
2888
+ // Load timestamps
2889
+ this._latestLocalMetadataTimestamps = JSON.parse(await ls.GetItem(this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey));
2890
+ // Read raw data from storage
2891
+ const readStart = Date.now();
2892
+ const raw = await ls.GetItem(this.LocalStoragePrefix + ProviderBase.localStorageAllMetadataKey);
2893
+ const readMs = Date.now() - readStart;
2894
+ if (!raw)
2895
+ return;
2896
+ // Decompress if stored in compressed format, otherwise parse directly
2897
+ const parseStart = Date.now();
2898
+ let temp;
2899
+ const format = await ls.GetItem(this.LocalStoragePrefix + ProviderBase.localStorageFormatKey);
2900
+ if (format === 'gzip' && typeof raw === 'string') {
2901
+ // Compressed path: base64 → binary → gzip decompress → JSON parse
2902
+ const binary = ProviderBase.base64ToArrayBuffer(raw);
2903
+ const blob = new Blob([binary]);
2904
+ const ds = new DecompressionStream('gzip');
2905
+ const decompressedStream = blob.stream().pipeThrough(ds);
2906
+ const jsonString = await new Response(decompressedStream).text();
2907
+ temp = JSON.parse(jsonString);
2908
+ }
2909
+ else {
2910
+ // Legacy uncompressed path
2911
+ temp = typeof raw === 'string' ? JSON.parse(raw) : raw;
2912
+ }
2913
+ const parseMs = Date.now() - parseStart;
2914
+ if (!temp)
2915
+ return;
2916
+ // Reconstruct typed metadata objects from the parsed JSON
2917
+ const deserializeStart = Date.now();
2918
+ const metadata = MetadataFromSimpleObject(temp, this);
2919
+ const deserializeMs = Date.now() - deserializeStart;
2920
+ if (metadata) {
2921
+ this.UpdateLocalMetadata(metadata);
2922
+ const totalMs = Date.now() - overallStart;
2923
+ LogStatusEx({
2924
+ message: `[Fast-Start Cache] Load complete: read=${readMs}ms, parse=${parseMs}ms, deserialize=${deserializeMs}ms, total=${totalMs}ms, entities=${metadata.AllEntities?.length ?? 0}`,
2925
+ verboseOnly: false
2926
+ });
2927
+ }
2928
+ else {
2929
+ LogError('[Fast-Start Cache] MetadataFromSimpleObject returned undefined — cache deserialization failed. Check console for per-item errors above.');
2798
2930
  }
2799
2931
  }
2800
2932
  catch (e) {
2801
- // some enviroments don't support local storage
2933
+ LogError(`[Fast-Start Cache] LoadLocalMetadataFromStorage failed: ${e instanceof Error ? e.message : String(e)}`);
2802
2934
  }
2803
2935
  }
2804
2936
  static { this.localStorageRootKey = '___MJCore_Metadata'; }
2805
2937
  static { this.localStorageTimestampsKey = this.localStorageRootKey + '_Timestamps'; }
2806
2938
  static { this.localStorageAllMetadataKey = this.localStorageRootKey + '_AllMetadata'; }
2939
+ static { this.localStorageFormatKey = this.localStorageRootKey + '_Format'; }
2807
2940
  static { this.localStorageKeys = [
2808
2941
  ProviderBase.localStorageTimestampsKey,
2809
2942
  ProviderBase.localStorageAllMetadataKey,
2943
+ ProviderBase.localStorageFormatKey,
2810
2944
  ]; }
2945
+ /**
2946
+ * Converts a base64-encoded string to an ArrayBuffer.
2947
+ * Used for compressed metadata storage/retrieval.
2948
+ */
2949
+ static base64ToArrayBuffer(base64) {
2950
+ const binaryString = atob(base64);
2951
+ const bytes = new Uint8Array(binaryString.length);
2952
+ for (let i = 0; i < binaryString.length; i++) {
2953
+ bytes[i] = binaryString.charCodeAt(i);
2954
+ }
2955
+ return bytes.buffer;
2956
+ }
2957
+ /**
2958
+ * Converts an ArrayBuffer to a base64-encoded string.
2959
+ * Used for compressed metadata storage/retrieval.
2960
+ */
2961
+ static arrayBufferToBase64(buffer) {
2962
+ const bytes = new Uint8Array(buffer);
2963
+ let binary = '';
2964
+ for (let i = 0; i < bytes.byteLength; i++) {
2965
+ binary += String.fromCharCode(bytes[i]);
2966
+ }
2967
+ return btoa(binary);
2968
+ }
2811
2969
  /**
2812
2970
  * This property will return the prefix to use for local storage keys. This is useful if you have multiple instances of a provider running in the same environment
2813
2971
  * and you want to keep their local storage keys separate. The default implementation returns an empty string, but subclasses can override this to return a unique string
@@ -2823,16 +2981,47 @@ export class ProviderBase {
2823
2981
  async SaveLocalMetadataToStorage() {
2824
2982
  try {
2825
2983
  const ls = this.LocalStorageProvider;
2826
- if (ls) {
2827
- // execution environment supports local storage, use it
2828
- await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey, JSON.stringify(this._latestLocalMetadataTimestamps));
2829
- // now persist the AllMetadata object
2830
- await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageAllMetadataKey, JSON.stringify(this._localMetadata));
2984
+ if (!ls)
2985
+ return;
2986
+ const start = Date.now();
2987
+ // Save timestamps
2988
+ await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey, JSON.stringify(this._latestLocalMetadataTimestamps));
2989
+ // Serialize the AllMetadata object
2990
+ const jsonString = JSON.stringify(this._localMetadata);
2991
+ // Attempt compressed storage using native CompressionStream (available in modern browsers and Node 18+)
2992
+ if (typeof CompressionStream !== 'undefined') {
2993
+ try {
2994
+ const blob = new Blob([jsonString]);
2995
+ const cs = new CompressionStream('gzip');
2996
+ const compressedStream = blob.stream().pipeThrough(cs);
2997
+ const compressedBuffer = await new Response(compressedStream).arrayBuffer();
2998
+ const base64 = ProviderBase.arrayBufferToBase64(compressedBuffer);
2999
+ await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageAllMetadataKey, base64);
3000
+ await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageFormatKey, 'gzip');
3001
+ const elapsed = Date.now() - start;
3002
+ const ratio = jsonString.length > 0 ? (base64.length / jsonString.length * 100).toFixed(1) : '?';
3003
+ LogStatusEx({
3004
+ message: `[Fast-Start Cache] Save complete: ${elapsed}ms, raw=${(jsonString.length / 1024 / 1024).toFixed(1)}MB, compressed=${(base64.length / 1024 / 1024).toFixed(1)}MB (${ratio}%)`,
3005
+ verboseOnly: false
3006
+ });
3007
+ return;
3008
+ }
3009
+ catch (compressErr) {
3010
+ // Compression failed — fall through to uncompressed save
3011
+ LogError(`[Fast-Start Cache] Compression failed, falling back to uncompressed: ${compressErr instanceof Error ? compressErr.message : String(compressErr)}`);
3012
+ }
2831
3013
  }
3014
+ // Fallback: uncompressed save (older environments without CompressionStream)
3015
+ await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageAllMetadataKey, jsonString);
3016
+ await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageFormatKey, 'json');
3017
+ const elapsed = Date.now() - start;
3018
+ LogStatusEx({
3019
+ message: `[Fast-Start Cache] Save complete (uncompressed): ${elapsed}ms, size=${(jsonString.length / 1024 / 1024).toFixed(1)}MB`,
3020
+ verboseOnly: false
3021
+ });
2832
3022
  }
2833
3023
  catch (e) {
2834
- // some enviroments don't support local storage
2835
- LogError(e);
3024
+ LogError(`[Fast-Start Cache] SaveLocalMetadataToStorage failed: ${e instanceof Error ? e.message : String(e)}`);
2836
3025
  }
2837
3026
  }
2838
3027
  /**