@memberjunction/core 5.30.0 → 5.31.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 (69) hide show
  1. package/dist/generic/InMemoryLocalStorageProvider.d.ts +12 -6
  2. package/dist/generic/InMemoryLocalStorageProvider.d.ts.map +1 -1
  3. package/dist/generic/InMemoryLocalStorageProvider.js +25 -5
  4. package/dist/generic/InMemoryLocalStorageProvider.js.map +1 -1
  5. package/dist/generic/QueryCacheManager.js +2 -1
  6. package/dist/generic/QueryCacheManager.js.map +1 -1
  7. package/dist/generic/RegisterForStartup.d.ts +26 -0
  8. package/dist/generic/RegisterForStartup.d.ts.map +1 -1
  9. package/dist/generic/RegisterForStartup.js +47 -7
  10. package/dist/generic/RegisterForStartup.js.map +1 -1
  11. package/dist/generic/authEvaluator.d.ts +115 -11
  12. package/dist/generic/authEvaluator.d.ts.map +1 -1
  13. package/dist/generic/authEvaluator.js +151 -24
  14. package/dist/generic/authEvaluator.js.map +1 -1
  15. package/dist/generic/baseEngine.d.ts +22 -0
  16. package/dist/generic/baseEngine.d.ts.map +1 -1
  17. package/dist/generic/baseEngine.js +30 -2
  18. package/dist/generic/baseEngine.js.map +1 -1
  19. package/dist/generic/baseEntity.d.ts +61 -4
  20. package/dist/generic/baseEntity.d.ts.map +1 -1
  21. package/dist/generic/baseEntity.js +205 -34
  22. package/dist/generic/baseEntity.js.map +1 -1
  23. package/dist/generic/databaseProviderBase.d.ts +8 -0
  24. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  25. package/dist/generic/databaseProviderBase.js +10 -0
  26. package/dist/generic/databaseProviderBase.js.map +1 -1
  27. package/dist/generic/entityInfo.d.ts +24 -0
  28. package/dist/generic/entityInfo.d.ts.map +1 -1
  29. package/dist/generic/entityInfo.js +29 -3
  30. package/dist/generic/entityInfo.js.map +1 -1
  31. package/dist/generic/interfaces.d.ts +59 -5
  32. package/dist/generic/interfaces.d.ts.map +1 -1
  33. package/dist/generic/interfaces.js +6 -0
  34. package/dist/generic/interfaces.js.map +1 -1
  35. package/dist/generic/localCacheManager.d.ts +38 -3
  36. package/dist/generic/localCacheManager.d.ts.map +1 -1
  37. package/dist/generic/localCacheManager.js +156 -46
  38. package/dist/generic/localCacheManager.js.map +1 -1
  39. package/dist/generic/metadata.d.ts +2 -1
  40. package/dist/generic/metadata.d.ts.map +1 -1
  41. package/dist/generic/metadata.js +45 -42
  42. package/dist/generic/metadata.js.map +1 -1
  43. package/dist/generic/metadataUtil.js +1 -1
  44. package/dist/generic/metadataUtil.js.map +1 -1
  45. package/dist/generic/permissionInterfaces.d.ts +19 -14
  46. package/dist/generic/permissionInterfaces.d.ts.map +1 -1
  47. package/dist/generic/permissionInterfaces.js +3 -3
  48. package/dist/generic/permissionInterfaces.js.map +1 -1
  49. package/dist/generic/providerBase.d.ts +26 -39
  50. package/dist/generic/providerBase.d.ts.map +1 -1
  51. package/dist/generic/providerBase.js +202 -201
  52. package/dist/generic/providerBase.js.map +1 -1
  53. package/dist/generic/queryInfo.js +20 -20
  54. package/dist/generic/queryInfo.js.map +1 -1
  55. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  56. package/dist/generic/runQuerySQLFilterImplementations.js +30 -9
  57. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  58. package/dist/generic/securityInfo.d.ts +14 -7
  59. package/dist/generic/securityInfo.d.ts.map +1 -1
  60. package/dist/generic/securityInfo.js +26 -25
  61. package/dist/generic/securityInfo.js.map +1 -1
  62. package/dist/generic/util.d.ts +35 -0
  63. package/dist/generic/util.d.ts.map +1 -1
  64. package/dist/generic/util.js +99 -10
  65. package/dist/generic/util.js.map +1 -1
  66. package/dist/index.js +1 -1
  67. package/dist/index.js.map +1 -1
  68. package/package.json +2 -2
  69. package/readme.md +50 -6
@@ -3,7 +3,7 @@ import { EntityDocumentTypeInfo, EntityInfo } from "./entityInfo.js";
3
3
  import { AllMetadata } from "./interfaces.js";
4
4
  import { LocalCacheManager } from "./localCacheManager.js";
5
5
  import { ApplicationInfo } from "../generic/applicationInfo.js";
6
- import { AuditLogTypeInfo, AuthorizationInfo, RoleInfo, RowLevelSecurityFilterInfo, UserInfo } from "./securityInfo.js";
6
+ import { AuditLogTypeInfo, AuthorizationInfo, AuthorizationRoleInfo, RoleInfo, RowLevelSecurityFilterInfo, UserInfo } from "./securityInfo.js";
7
7
  import { MJGlobal, NormalizeUUID, UUIDsEqual } from "@memberjunction/global";
8
8
  import { TelemetryManager } from "./telemetryManager.js";
9
9
  import { LogError, LogStatus, LogStatusEx } from "./logging.js";
@@ -93,6 +93,12 @@ export const AllMetadataArrays = [
93
93
  { key: 'AllRowLevelSecurityFilters', class: RowLevelSecurityFilterInfo },
94
94
  { key: 'AllAuditLogTypes', class: AuditLogTypeInfo },
95
95
  { key: 'AllAuthorizations', class: AuthorizationInfo },
96
+ /**
97
+ * Flat join-table records linking authorizations to roles.
98
+ * Consumed lazily by `AuthorizationInfo.Roles` — no post-processing
99
+ * in `GetAllMetadata` is required. Mirrors the QueryFields pattern.
100
+ */
101
+ { key: 'AllAuthorizationRoles', class: AuthorizationRoleInfo },
96
102
  { key: 'AllQueryCategories', class: QueryCategoryInfo },
97
103
  { key: 'AllQueries', class: QueryInfo },
98
104
  { key: 'AllQueryFields', class: QueryFieldInfo },
@@ -173,35 +179,6 @@ export class ProviderBase {
173
179
  * all engines that fire in the same tick, without adding perceptible delay.
174
180
  */
175
181
  static { this.CoalesceWindowMs = 10; }
176
- // ── Fast Startup Mode ─────────────────────────────────────────────
177
- /**
178
- * When enabled, the first round of RunViews calls after startup will
179
- * trust the local IndexedDB/localStorage cache without server validation.
180
- * This eliminates the smart cache check round-trips during initial load,
181
- * making warm page refreshes near-instant.
182
- *
183
- * After the first round of engine loads completes, FastStartupMode
184
- * automatically disables itself so subsequent data access uses normal
185
- * server validation.
186
- *
187
- * Only applies to client-side providers (TrustLocalCacheCompletely=false).
188
- * Set to false to disable.
189
- */
190
- static { this.FastStartupMode = true; }
191
- /** Tracks whether fast startup has been consumed (auto-disables after startup completes) */
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
- }
205
182
  // ── Request Deduplication + Linger Window ──────────────────────────
206
183
  /**
207
184
  * How long (ms) a resolved RunViews result stays available for instant
@@ -628,10 +605,12 @@ export class ProviderBase {
628
605
  async FullTextSearch(params, contextUser) {
629
606
  const startTime = Date.now();
630
607
  try {
631
- const md = new Metadata();
632
608
  const maxRows = params.MaxRowsPerEntity ?? 10;
633
- // Get all FTS-enabled entities
634
- const ftsEntities = this.resolveFTSEntities(md, params.EntityNames);
609
+ // Use this provider's metadata directly — `this` implements IMetadataProvider, so
610
+ // its Entities reflect the schema for the database this provider is connected to.
611
+ // (The previous `new Metadata()` reached for the global default provider, which is
612
+ // wrong in multi-provider client setups.)
613
+ const ftsEntities = this.resolveFTSEntities(this, params.EntityNames);
635
614
  if (ftsEntities.length === 0) {
636
615
  return {
637
616
  Success: true,
@@ -1075,57 +1054,15 @@ export class ProviderBase {
1075
1054
  Entities: params.map(p => p.EntityName || p.ViewName || p.ViewID).filter(Boolean),
1076
1055
  _fromEngine: fromEngine
1077
1056
  }, contextUser?.ID);
1078
- // Client-side providers use smart cache check (lightweight server validation)
1079
- // Server-side providers trust the cache completely and fall through to
1080
- // the traditional flow which returns cached data immediately on hit.
1057
+ // Client-side providers route any CacheLocal params through smart-cache-check:
1058
+ // a single batched GraphQL call sends fingerprints (hashes + counts) per view,
1059
+ // the server confirms current vs stale, and only stale views return data.
1060
+ // Server-side providers trust the cache completely and fall through to the
1061
+ // traditional flow which returns cached data immediately on hit.
1081
1062
  if (!this.TrustLocalCacheCompletely) {
1082
- // FastStartupMode: on the first engine load after a page refresh, trust
1083
- // the local IndexedDB cache without server validation. This eliminates
1084
- // the RunViewsWithCacheCheck round-trips that dominate warm-load TTI.
1085
- // After the first batch of engine loads, auto-disable so subsequent
1086
- // requests use normal server validation for data freshness.
1087
- const useFastStartup = ProviderBase.FastStartupMode
1088
- && !ProviderBase._fastStartupConsumed
1089
- && LocalCacheManager.Instance.IsInitialized
1090
- && params.some(p => p.CacheLocal);
1091
- if (useFastStartup) {
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);
1104
- if (allHaveCachedData) {
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.
1109
- const entityNames = params.map(p => p.EntityName || p.ViewName || '?').join(', ');
1110
- LogStatusEx({
1111
- message: `⚡ [Fast-Start] Trusting local cache for ${params.length} views [${entityNames}] — skipping server validation`,
1112
- verboseOnly: false
1113
- });
1114
- // Fall through to the traditional flow below which will return cached data
1115
- }
1116
- else {
1117
- // Not all params have cached data — use normal smart cache check
1118
- const useSmartCacheCheck = params.some(p => p.CacheLocal);
1119
- if (useSmartCacheCheck) {
1120
- return this.prepareSmartCacheCheckParams(params, telemetryEventId, contextUser);
1121
- }
1122
- }
1123
- }
1124
- else if (!useFastStartup) {
1125
- const useSmartCacheCheck = params.some(p => p.CacheLocal);
1126
- if (useSmartCacheCheck && LocalCacheManager.Instance.IsInitialized) {
1127
- return this.prepareSmartCacheCheckParams(params, telemetryEventId, contextUser);
1128
- }
1063
+ const useSmartCacheCheck = params.some(p => p.CacheLocal);
1064
+ if (useSmartCacheCheck && LocalCacheManager.Instance.IsInitialized) {
1065
+ return this.prepareSmartCacheCheckParams(params, telemetryEventId, contextUser);
1129
1066
  }
1130
1067
  }
1131
1068
  // Traditional caching flow
@@ -1229,11 +1166,13 @@ export class ProviderBase {
1229
1166
  * which will validate if the cache is current or return fresh data.
1230
1167
  */
1231
1168
  async prepareSmartCacheCheckParams(params, telemetryEventId, contextUser) {
1232
- const smartCacheCheckParams = [];
1233
- for (const param of params) {
1234
- // Entity status check
1169
+ // Phase 1 — sync setup: entity status checks, entity_object Fields population,
1170
+ // and fingerprint computation. None of this touches storage; do it serially
1171
+ // so we have the full fingerprint list ready to issue ONE batched IDB read.
1172
+ const cacheable = [];
1173
+ for (let i = 0; i < params.length; i++) {
1174
+ const param = params[i];
1235
1175
  await this.EntityStatusCheck(param, 'PreRunViews');
1236
- // Handle entity_object result type - need all fields
1237
1176
  if (param.ResultType === 'entity_object') {
1238
1177
  const entity = this.EntityByName(param.EntityName);
1239
1178
  if (!entity) {
@@ -1241,22 +1180,28 @@ export class ProviderBase {
1241
1180
  }
1242
1181
  param.Fields = entity.Fields.map(f => f.Name);
1243
1182
  }
1244
- // Build the cache check param with optional cache status
1245
- let cacheStatus;
1246
1183
  if (param.CacheLocal && LocalCacheManager.Instance.IsInitialized) {
1247
1184
  const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
1248
- const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
1249
- if (cached) {
1250
- cacheStatus = {
1251
- maxUpdatedAt: cached.maxUpdatedAt,
1252
- rowCount: cached.rowCount
1253
- };
1254
- }
1185
+ cacheable.push({ paramIndex: i, fingerprint });
1186
+ }
1187
+ }
1188
+ // Phase 2 — batched read: one IDB transaction (or one Redis MGET) returns
1189
+ // every cached entry's status (maxUpdatedAt + rowCount). Was previously N
1190
+ // serialized GetItem calls — for the 8-engine warm-load case this drops
1191
+ // from ~85 transactions to 1.
1192
+ const cacheMap = cacheable.length > 0
1193
+ ? await LocalCacheManager.Instance.GetRunViewResults(cacheable.map(c => c.fingerprint))
1194
+ : new Map();
1195
+ // Phase 3 — assemble per-param cache check entries from the resolved map.
1196
+ const smartCacheCheckParams = params.map(p => ({ params: p }));
1197
+ for (const { paramIndex, fingerprint } of cacheable) {
1198
+ const cached = cacheMap.get(fingerprint);
1199
+ if (cached) {
1200
+ smartCacheCheckParams[paramIndex].cacheStatus = {
1201
+ maxUpdatedAt: cached.maxUpdatedAt,
1202
+ rowCount: cached.rowCount,
1203
+ };
1255
1204
  }
1256
- smartCacheCheckParams.push({
1257
- params: param,
1258
- cacheStatus
1259
- });
1260
1205
  }
1261
1206
  return {
1262
1207
  telemetryEventId,
@@ -1297,8 +1242,23 @@ export class ProviderBase {
1297
1242
  UserViewRunID: ''
1298
1243
  }));
1299
1244
  }
1300
- // Process all results in parallel
1301
- const processingPromises = params.map((param, i) => this.processSingleSmartCacheResult(param, i, response.results, contextUser));
1245
+ // ── Batched cache pre-read for 'current' entries ──────────────────
1246
+ // Each 'current' result needs its cached row data to materialize the response.
1247
+ // Collect every fingerprint up front and issue ONE batched read instead of
1248
+ // letting each per-param processor do its own GetRunViewResult call (~85
1249
+ // serialized IDB transactions in the 8-engine warm-load case).
1250
+ const currentFingerprints = [];
1251
+ for (const sr of response.results) {
1252
+ if (sr.status === 'current' && params[sr.viewIndex]) {
1253
+ currentFingerprints.push(LocalCacheManager.Instance.GenerateRunViewFingerprint(params[sr.viewIndex], this.InstanceConnectionString));
1254
+ }
1255
+ }
1256
+ const preResolvedCache = currentFingerprints.length > 0
1257
+ ? await LocalCacheManager.Instance.GetRunViewResults(currentFingerprints)
1258
+ : new Map();
1259
+ // Process all results in parallel — 'current' entries now read from the
1260
+ // pre-resolved map instead of issuing their own GetRunViewResult calls.
1261
+ const processingPromises = params.map((param, i) => this.processSingleSmartCacheResult(param, i, response.results, preResolvedCache, contextUser));
1302
1262
  const processedResults = await Promise.all(processingPromises);
1303
1263
  // Aggregate telemetry stats
1304
1264
  let cacheHits = 0;
@@ -1322,8 +1282,13 @@ export class ProviderBase {
1322
1282
  /**
1323
1283
  * Processes a single smart cache check result.
1324
1284
  * Handles cache lookup for 'current' items and cache update for 'stale' items.
1285
+ *
1286
+ * @param preResolvedCache - Pre-resolved cache map produced by the batched
1287
+ * `GetRunViewResults` call in `executeSmartCacheCheck`. Lookups for
1288
+ * 'current' items hit this map instead of issuing per-param IDB reads,
1289
+ * amortizing IndexedDB transaction overhead across the whole batch.
1325
1290
  */
1326
- async processSingleSmartCacheResult(param, index, serverResults, contextUser) {
1291
+ async processSingleSmartCacheResult(param, index, serverResults, preResolvedCache, contextUser) {
1327
1292
  const checkResult = serverResults.find(r => r.viewIndex === index);
1328
1293
  if (!checkResult) {
1329
1294
  return {
@@ -1341,9 +1306,11 @@ export class ProviderBase {
1341
1306
  };
1342
1307
  }
1343
1308
  if (checkResult.status === 'current') {
1344
- // Cache is current - use cached data
1309
+ // Cache is current - use the pre-resolved cache entry from the batched read
1310
+ // (executeSmartCacheCheck reads all 'current' fingerprints in one IDB
1311
+ // transaction up front, so we don't pay per-param transaction overhead here).
1345
1312
  const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
1346
- const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
1313
+ const cached = preResolvedCache.get(fingerprint) ?? null;
1347
1314
  if (cached) {
1348
1315
  const cachedResult = {
1349
1316
  Success: true,
@@ -1384,8 +1351,8 @@ export class ProviderBase {
1384
1351
  const primaryKeyFieldName = entity?.FirstPrimaryKey?.Name || 'ID';
1385
1352
  // Apply differential update to cache
1386
1353
  if (param.CacheLocal && checkResult.differentialData && LocalCacheManager.Instance.IsInitialized) {
1387
- const merged = await LocalCacheManager.Instance.ApplyDifferentialUpdate(fingerprint, param, checkResult.differentialData.updatedRows, checkResult.differentialData.deletedRecordIDs, primaryKeyFieldName, checkResult.maxUpdatedAt || new Date().toISOString(), checkResult.rowCount || 0, checkResult.aggregateResults // Pass fresh aggregate results (can't be differentially computed)
1388
- );
1354
+ const merged = await LocalCacheManager.Instance.ApplyDifferentialUpdate(fingerprint, param, checkResult.differentialData.updatedRows, checkResult.differentialData.deletedRecordIDs, primaryKeyFieldName, checkResult.maxUpdatedAt || new Date().toISOString(), checkResult.rowCount || 0, checkResult.aggregateResults, // Pass fresh aggregate results (can't be differentially computed)
1355
+ this);
1389
1356
  if (merged) {
1390
1357
  const mergedResult = {
1391
1358
  Success: true,
@@ -1429,7 +1396,7 @@ export class ProviderBase {
1429
1396
  // Note: We don't await here to avoid blocking the response
1430
1397
  // Cache update happens in background
1431
1398
  LocalCacheManager.Instance.SetRunViewResult(fingerprint, param, checkResult.results || [], checkResult.maxUpdatedAt, checkResult.aggregateResults, // Include aggregate results in cache
1432
- checkResult.rowCount).catch(e => LogError(`Failed to update cache: ${e}`));
1399
+ checkResult.rowCount, this).catch(e => LogError(`Failed to update cache: ${e}`));
1433
1400
  }
1434
1401
  // Transform to entity objects if needed
1435
1402
  await this.TransformSimpleObjectToEntityObject(param, freshResult, contextUser);
@@ -1521,7 +1488,7 @@ export class ProviderBase {
1521
1488
  const postEntityCacheAllowed = this.IsServerCacheAllowedForEntity(params);
1522
1489
  if ((params.CacheLocal || this.TrustLocalCacheCompletely) && postEntityCacheAllowed && result.Success && preResult.fingerprint && LocalCacheManager.Instance.IsInitialized) {
1523
1490
  const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
1524
- await LocalCacheManager.Instance.SetRunViewResult(preResult.fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount);
1491
+ await LocalCacheManager.Instance.SetRunViewResult(preResult.fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
1525
1492
  }
1526
1493
  else if (this.shouldAutoCache(params, result)) {
1527
1494
  // Server-side auto-cache: small, unfiltered, unsorted results are
@@ -1529,7 +1496,7 @@ export class ProviderBase {
1529
1496
  // safe for in-place upsert on entity changes (no filter to evaluate).
1530
1497
  const fingerprint = preResult.fingerprint || LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString);
1531
1498
  const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
1532
- await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount);
1499
+ await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
1533
1500
  LogStatusEx({ message: ` 📦 [Auto-Cache] RunView "${params.EntityName || params.ViewName || 'unknown'}" — ${result.Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
1534
1501
  }
1535
1502
  // Transform the result set into BaseEntity-derived objects, if needed
@@ -1574,11 +1541,11 @@ export class ProviderBase {
1574
1541
  const batchEntityCacheAllowed = this.IsServerCacheAllowedForEntity(params[i]);
1575
1542
  if ((params[i].CacheLocal || this.TrustLocalCacheCompletely) && batchEntityCacheAllowed && results[i].Success && LocalCacheManager.Instance.IsInitialized) {
1576
1543
  const maxUpdatedAt = this.extractMaxUpdatedAt(results[i].Results);
1577
- cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount));
1544
+ cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount, this));
1578
1545
  }
1579
1546
  else if (this.shouldAutoCache(params[i], results[i])) {
1580
1547
  const maxUpdatedAt = this.extractMaxUpdatedAt(results[i].Results);
1581
- cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount));
1548
+ cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount, this));
1582
1549
  LogStatusEx({ message: ` 📦 [Auto-Cache] RunViews "${params[i].EntityName || params[i].ViewName || 'unknown'}" — ${results[i].Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
1583
1550
  }
1584
1551
  // Register OnDataChanged callback if provided
@@ -1963,8 +1930,18 @@ export class ProviderBase {
1963
1930
  await LocalCacheManager.Instance.Initialize(storageProvider);
1964
1931
  }
1965
1932
  // first, let's check to see if we have an existing Metadata.Provider registered, if so
1966
- // unless our data.IgnoreExistingMetadata is set to true, we will not refresh the metadata
1967
- if (Metadata.Provider && !data.IgnoreExistingMetadata) {
1933
+ // unless our data.IgnoreExistingMetadata is set to true, we will not refresh the metadata.
1934
+ //
1935
+ // ALSO bypass this fast-path when `this._refresh` is set — that flag means the caller
1936
+ // explicitly invoked Refresh() to force a re-fetch (e.g. CodeGen runs Refresh after
1937
+ // manageMetadata mutates the EntityField rows in the DB). Without this guard, Refresh()
1938
+ // calls on the *same instance that's also Metadata.Provider* (the common case after
1939
+ // SetProvider has been called) would short-circuit here and never re-read from the
1940
+ // server, leaving in-memory metadata permanently stale relative to subsequent DB writes.
1941
+ // This was Bug 1 of the 3-bug chain that caused first-run CodeGen on PG to silently
1942
+ // drop CRUD for newly-created entities like SystemEvent — its EntityField rows existed
1943
+ // in the DB but the in-memory snapshot used by the entity-loop never picked them up.
1944
+ if (Metadata.Provider && !data.IgnoreExistingMetadata && !this._refresh) { // global-provider-ok: bootstrap checks for an existing global registration
1968
1945
  // we have an existing globally registered provider AND we are not
1969
1946
  // requested to ignore the existing metadata, so we will not refresh it
1970
1947
  if (this.CopyMetadataFromGlobalProvider()) {
@@ -1983,7 +1960,7 @@ export class ProviderBase {
1983
1960
  if (!this.TrustLocalCacheCompletely && !hardRefresh && !this._localMetadata?.AllEntities?.length) {
1984
1961
  await this.LoadLocalMetadataFromStorage();
1985
1962
  if (this._localMetadata?.AllEntities?.length) {
1986
- LogStatusEx({ message: `⚡ [Fast-Start] Loaded ${this._localMetadata.AllEntities.length} entities from local cache — deferring server validation`, verboseOnly: false });
1963
+ LogStatusEx({ message: `⚡ [Metadata Cache] Loaded ${this._localMetadata.AllEntities.length} entities from local cache — pre-validating before engine startup`, verboseOnly: false });
1987
1964
  // Do NOT kick off background validation here — it writes to IndexedDB
1988
1965
  // which contends with engine RunView cache reads during StartupManager.
1989
1966
  // Instead, the caller (setupGraphQLClient) should call
@@ -2028,7 +2005,7 @@ export class ProviderBase {
2028
2005
  try {
2029
2006
  const needsRefresh = await this.CheckToSeeIfRefreshNeeded(providerToUse);
2030
2007
  if (needsRefresh) {
2031
- LogStatusEx({ message: `⚡ [Fast-Start] Background check: metadata is stale — refreshing...`, verboseOnly: false });
2008
+ LogStatusEx({ message: `⚡ [Metadata Cache] Background check: metadata is stale — refreshing...`, verboseOnly: false });
2032
2009
  const start = Date.now();
2033
2010
  const res = await this.GetAllMetadata(providerToUse, false);
2034
2011
  const elapsed = Date.now() - start;
@@ -2036,48 +2013,42 @@ export class ProviderBase {
2036
2013
  this.UpdateLocalMetadata(res);
2037
2014
  this._latestLocalMetadataTimestamps = this._latestRemoteMetadataTimestamps;
2038
2015
  await this.SaveLocalMetadataToStorage();
2039
- LogStatusEx({ message: `⚡ [Fast-Start] Background refresh complete (${elapsed}ms) — metadata updated in place`, verboseOnly: false });
2016
+ LogStatusEx({ message: `⚡ [Metadata Cache] Background refresh complete (${elapsed}ms) — metadata updated in place`, verboseOnly: false });
2040
2017
  }
2041
2018
  }
2042
2019
  else {
2043
- LogStatusEx({ message: `⚡ [Fast-Start] Background check: metadata is current — no refresh needed`, verboseOnly: false });
2020
+ LogStatusEx({ message: `⚡ [Metadata Cache] Background check: metadata is current — no refresh needed`, verboseOnly: false });
2044
2021
  }
2045
2022
  }
2046
2023
  catch (e) {
2047
- LogError(`[Fast-Start] Background validation failed: ${e}`);
2024
+ LogError(`[Metadata Cache] Background validation failed: ${e}`);
2048
2025
  // Not critical — app continues with cached metadata
2049
2026
  }
2050
2027
  }
2051
2028
  /**
2052
2029
  * Synchronous pre-validation of cached metadata before engine startup.
2053
2030
  *
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:
2031
+ * On a warm load we serve the metadata graph from IndexedDB so the app can boot
2032
+ * without pulling MBs of metadata from the server. Before engines run, this method
2033
+ * makes one batched timestamp round-trip to confirm the snapshot is still current:
2057
2034
  *
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.
2035
+ * - **Cached metadata is current** → engines proceed against the cached snapshot.
2036
+ * Their RunViews calls go through the normal smart-cache-check path which
2037
+ * batches per-view fingerprints to the server efficient and authoritative.
2038
+ * - **Cached metadata is stale** → refresh framework metadata in place before
2039
+ * engines start, then proceed normally.
2066
2040
  *
2067
2041
  * Cost on the warm-current path is one batched timestamp fetch (~50–200 ms depending
2068
2042
  * on RTT). On the warm-stale path we additionally pay the full metadata fetch but
2069
2043
  * avoid serving stale data to the UI in the first place.
2070
2044
  *
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.
2045
+ * Caller contract: invoke this before `StartupManager.Startup()`.
2075
2046
  */
2076
2047
  async preValidateAndRefresh(providerToUse) {
2077
2048
  try {
2078
2049
  const needsRefresh = await this.CheckToSeeIfRefreshNeeded(providerToUse);
2079
2050
  if (needsRefresh) {
2080
- LogStatusEx({ message: `⚡ [Fast-Start] Pre-validation: metadata is stale — refreshing before engine startup and disabling fast-start`, verboseOnly: false });
2051
+ LogStatusEx({ message: `⚡ [Metadata Cache] Pre-validation: metadata is stale — refreshing before engine startup`, verboseOnly: false });
2081
2052
  const start = Date.now();
2082
2053
  const res = await this.GetAllMetadata(providerToUse, false);
2083
2054
  const elapsed = Date.now() - start;
@@ -2085,21 +2056,15 @@ export class ProviderBase {
2085
2056
  this.UpdateLocalMetadata(res);
2086
2057
  this._latestLocalMetadataTimestamps = this._latestRemoteMetadataTimestamps;
2087
2058
  await this.SaveLocalMetadataToStorage();
2088
- LogStatusEx({ message: `⚡ [Fast-Start] Pre-validation refresh complete (${elapsed}ms) — engines will smart-cache-check`, verboseOnly: false });
2059
+ LogStatusEx({ message: `⚡ [Metadata Cache] Pre-validation refresh complete (${elapsed}ms)`, verboseOnly: false });
2089
2060
  }
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
2061
  }
2094
2062
  else {
2095
- LogStatusEx({ message: `⚡ [Fast-Start] Pre-validation: metadata is current — fast-start engaged`, verboseOnly: false });
2063
+ LogStatusEx({ message: `⚡ [Metadata Cache] Pre-validation: metadata is current`, verboseOnly: false });
2096
2064
  }
2097
2065
  }
2098
2066
  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();
2067
+ LogError(`[Metadata Cache] Pre-validation failed: ${e instanceof Error ? e.message : String(e)} — engines will smart-cache-check`);
2103
2068
  }
2104
2069
  }
2105
2070
  CloneAllMetadata(toClone) {
@@ -2116,8 +2081,8 @@ export class ProviderBase {
2116
2081
  */
2117
2082
  CopyMetadataFromGlobalProvider() {
2118
2083
  try {
2119
- if (Metadata.Provider && Metadata.Provider !== this && Metadata.Provider.AllMetadata) {
2120
- this._localMetadata = this.CloneAllMetadata(Metadata.Provider.AllMetadata);
2084
+ if (Metadata.Provider && Metadata.Provider !== this && Metadata.Provider.AllMetadata) { // global-provider-ok: this method literally clones FROM the global provider on bootstrap
2085
+ this._localMetadata = this.CloneAllMetadata(Metadata.Provider.AllMetadata); // global-provider-ok: bootstrap clone path
2121
2086
  return true;
2122
2087
  }
2123
2088
  return false;
@@ -2335,6 +2300,16 @@ export class ProviderBase {
2335
2300
  get Authorizations() {
2336
2301
  return this._localMetadata.AllAuthorizations;
2337
2302
  }
2303
+ /**
2304
+ * Gets the flat collection of authorization-role assignments.
2305
+ * Consumed lazily by {@link AuthorizationInfo.Roles} — consumers should
2306
+ * prefer accessing roles through `AuthorizationInfo.Roles` rather than
2307
+ * filtering this array directly.
2308
+ * @returns Array of AuthorizationRoleInfo join-table objects
2309
+ */
2310
+ get AuthorizationRoles() {
2311
+ return this._localMetadata.AllAuthorizationRoles;
2312
+ }
2338
2313
  /**
2339
2314
  * Gets all saved queries in the system.
2340
2315
  * @returns Array of QueryInfo objects representing stored queries
@@ -2488,7 +2463,7 @@ export class ProviderBase {
2488
2463
  // First overload: entityName, contextUser
2489
2464
  actualContextUser = loadKeyOrContextUser;
2490
2465
  }
2491
- const entity = this.Metadata.Entities.find(e => e.Name == entityName);
2466
+ const entity = this.EntityByName(entityName);
2492
2467
  if (entity) {
2493
2468
  // Use the MJGlobal Class Factory to do our object instantiation - we do NOT use metadata for this anymore, doesn't work well to have file paths with node dynamically at runtime
2494
2469
  // type reference registration by any module via MJ Global is the way to go as it is reliable across all platforms.
@@ -2560,37 +2535,50 @@ export class ProviderBase {
2560
2535
  * @param itemFilters
2561
2536
  */
2562
2537
  async GetAndCacheDatasetByName(datasetName, itemFilters, contextUser, providerToUse) {
2563
- // first see if we have anything in cache at all, no reason to check server dates if we dont
2564
- if (await this.IsDatasetCached(datasetName, itemFilters)) {
2565
- // FastStartupMode: on warm loads, trust the cached dataset without server validation.
2566
- // Same pattern as FastStartupMode for RunViews serve from IndexedDB immediately.
2567
- if (ProviderBase.FastStartupMode && !this.TrustLocalCacheCompletely) {
2568
- LogStatusEx({
2569
- message: `⚡ [Fast-Start] Serving cached dataset "${datasetName}" from local cache skipping server validation`,
2570
- verboseOnly: false
2571
- });
2572
- return this.GetCachedDataset(datasetName, itemFilters);
2573
- }
2574
- // compare the local version, if exists to the server version dates
2575
- if (await this.IsDatasetCacheUpToDate(datasetName, itemFilters)) {
2576
- // we're up to date, all we need to do is get the local cache and return it
2577
- return this.GetCachedDataset(datasetName, itemFilters);
2578
- }
2579
- else {
2580
- // we're out of date, so get the dataset from the server
2581
- const dataset = await this.GetDatasetByName(datasetName, itemFilters, contextUser, providerToUse);
2582
- // cache it
2583
- await this.CacheDataset(datasetName, itemFilters, dataset);
2584
- return dataset;
2538
+ const ls = this.LocalStorageProvider;
2539
+ const key = ls ? this.GetDatasetCacheKey(datasetName, itemFilters) : null;
2540
+ const dateKey = key ? key + '_date' : null;
2541
+ // ── Single batched read: data + date in one IDB transaction ────────────
2542
+ // Previously this path issued 3 sequential reads of the data key (existence
2543
+ // check, staleness check, materialize) plus 1 read of the date key. Now we
2544
+ // pull both keys in one transaction up front and reuse the in-memory data
2545
+ // for staleness checking and the return value.
2546
+ let cachedDataset = null;
2547
+ let cachedDateStr = null;
2548
+ if (ls && key && dateKey) {
2549
+ const both = await ls.GetItems([key, dateKey], 'DatasetCache');
2550
+ const rawData = both.get(key);
2551
+ const rawDate = both.get(dateKey);
2552
+ // Type guards — entries can be either depending on which key matched.
2553
+ // (The cache writes data under `key` and the ISO date string under `dateKey`,
2554
+ // so this is safe so long as the cache hasn't been corrupted.)
2555
+ cachedDataset = (rawData && typeof rawData !== 'string') ? rawData : null;
2556
+ cachedDateStr = (typeof rawDate === 'string') ? rawDate : null;
2557
+ }
2558
+ if (cachedDataset && cachedDateStr) {
2559
+ // We have a candidate cache entry — confirm freshness with the server.
2560
+ const localDate = new Date(cachedDateStr);
2561
+ const status = await this.GetDatasetStatusByName(datasetName, itemFilters);
2562
+ if (status && localDate.getTime() >= status.LatestUpdateDate.getTime()) {
2563
+ // Timestamps suggest cache is fresh; verify per-entity row counts to
2564
+ // catch deleted rows (timestamp comparison alone misses pure deletes).
2565
+ let allCountsMatch = true;
2566
+ for (const eu of status.EntityUpdateDates) {
2567
+ const localEntity = cachedDataset.Results.find(e => UUIDsEqual(e.EntityID, eu.EntityID));
2568
+ if (!localEntity || localEntity.Results.length !== eu.RowCount) {
2569
+ allCountsMatch = false;
2570
+ break;
2571
+ }
2572
+ }
2573
+ if (allCountsMatch) {
2574
+ return cachedDataset;
2575
+ }
2585
2576
  }
2586
2577
  }
2587
- else {
2588
- // get the dataset from the server
2589
- const dataset = await this.GetDatasetByName(datasetName, itemFilters, contextUser, providerToUse);
2590
- // cache it
2591
- await this.CacheDataset(datasetName, itemFilters, dataset);
2592
- return dataset;
2593
- }
2578
+ // Cold path or stale: fetch from server and cache.
2579
+ const dataset = await this.GetDatasetByName(datasetName, itemFilters, contextUser, providerToUse);
2580
+ await this.CacheDataset(datasetName, itemFilters, dataset);
2581
+ return dataset;
2594
2582
  }
2595
2583
  /**
2596
2584
  * Returns the timestamp of the local cached version of a given datasetName or null if there is no local cache for the
@@ -2664,9 +2652,9 @@ export class ProviderBase {
2664
2652
  const ls = this.LocalStorageProvider;
2665
2653
  if (ls) {
2666
2654
  const key = this.GetDatasetCacheKey(datasetName, itemFilters);
2667
- const val = await ls.GetItem(key);
2668
- if (val) {
2669
- const dataset = JSON.parse(val);
2655
+ // Native object read — IDB structured-clones, localStorage / Redis JSON-decode internally.
2656
+ const dataset = await ls.GetItem(key);
2657
+ if (dataset) {
2670
2658
  return dataset;
2671
2659
  }
2672
2660
  }
@@ -2681,11 +2669,11 @@ export class ProviderBase {
2681
2669
  const ls = this.LocalStorageProvider;
2682
2670
  if (ls) {
2683
2671
  const key = this.GetDatasetCacheKey(datasetName, itemFilters);
2684
- const val = JSON.stringify(dataset);
2685
- await ls.SetItem(key, val);
2686
- const dateKey = key + '_date';
2687
- const dateVal = dataset.LatestUpdateDate.toISOString();
2688
- await ls.SetItem(dateKey, dateVal);
2672
+ // Native object storage — no JSON.stringify on the hot path.
2673
+ await ls.SetItem(key, dataset);
2674
+ // Date is stored as ISO string for forward-compatibility across providers
2675
+ // (Redis can't natively round-trip Date; localStorage requires string).
2676
+ await ls.SetItem(key + '_date', dataset.LatestUpdateDate.toISOString());
2689
2677
  }
2690
2678
  }
2691
2679
  /**
@@ -2697,8 +2685,12 @@ export class ProviderBase {
2697
2685
  async IsDatasetCached(datasetName, itemFilters) {
2698
2686
  const ls = this.LocalStorageProvider;
2699
2687
  if (ls) {
2688
+ // Probe via the tiny `_date` ISO-string key instead of pulling the
2689
+ // full multi-MB dataset blob. The two are written together by
2690
+ // CacheDataset, so the date's presence is a faithful proxy for the
2691
+ // data's presence.
2700
2692
  const key = this.GetDatasetCacheKey(datasetName, itemFilters);
2701
- const val = await ls.GetItem(key);
2693
+ const val = await ls.GetItem(key + '_date');
2702
2694
  return val !== null && val !== undefined;
2703
2695
  }
2704
2696
  }
@@ -2885,18 +2877,25 @@ export class ProviderBase {
2885
2877
  if (!ls)
2886
2878
  return;
2887
2879
  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
2880
+ // The metadata snapshot uses three keys (timestamps, format, AllMetadata).
2881
+ // Pull them in a single batched read — saves two IDB transaction round-trips
2882
+ // on warm boot. The values are still strings (string storage is intentional
2883
+ // here so the gzip+base64 compression path round-trips cleanly).
2884
+ const tsKey = this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey;
2885
+ const fmtKey = this.LocalStoragePrefix + ProviderBase.localStorageFormatKey;
2886
+ const dataKey = this.LocalStoragePrefix + ProviderBase.localStorageAllMetadataKey;
2891
2887
  const readStart = Date.now();
2892
- const raw = await ls.GetItem(this.LocalStoragePrefix + ProviderBase.localStorageAllMetadataKey);
2888
+ const all = await ls.GetItems([tsKey, fmtKey, dataKey]);
2893
2889
  const readMs = Date.now() - readStart;
2890
+ const tsRaw = all.get(tsKey) ?? null;
2891
+ const format = all.get(fmtKey) ?? null;
2892
+ const raw = all.get(dataKey) ?? null;
2893
+ this._latestLocalMetadataTimestamps = tsRaw ? JSON.parse(tsRaw) : null;
2894
2894
  if (!raw)
2895
2895
  return;
2896
2896
  // Decompress if stored in compressed format, otherwise parse directly
2897
2897
  const parseStart = Date.now();
2898
2898
  let temp;
2899
- const format = await ls.GetItem(this.LocalStoragePrefix + ProviderBase.localStorageFormatKey);
2900
2899
  if (format === 'gzip' && typeof raw === 'string') {
2901
2900
  // Compressed path: base64 → binary → gzip decompress → JSON parse
2902
2901
  const binary = ProviderBase.base64ToArrayBuffer(raw);
@@ -2921,16 +2920,16 @@ export class ProviderBase {
2921
2920
  this.UpdateLocalMetadata(metadata);
2922
2921
  const totalMs = Date.now() - overallStart;
2923
2922
  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
2923
+ message: `[Metadata Cache] Load complete: read=${readMs}ms, parse=${parseMs}ms, deserialize=${deserializeMs}ms, total=${totalMs}ms, entities=${metadata.AllEntities?.length ?? 0}`,
2924
+ verboseOnly: true
2926
2925
  });
2927
2926
  }
2928
2927
  else {
2929
- LogError('[Fast-Start Cache] MetadataFromSimpleObject returned undefined — cache deserialization failed. Check console for per-item errors above.');
2928
+ LogError('[Metadata Cache] MetadataFromSimpleObject returned undefined — cache deserialization failed. Check console for per-item errors above.');
2930
2929
  }
2931
2930
  }
2932
2931
  catch (e) {
2933
- LogError(`[Fast-Start Cache] LoadLocalMetadataFromStorage failed: ${e instanceof Error ? e.message : String(e)}`);
2932
+ LogError(`[Metadata Cache] LoadLocalMetadataFromStorage failed: ${e instanceof Error ? e.message : String(e)}`);
2934
2933
  }
2935
2934
  }
2936
2935
  static { this.localStorageRootKey = '___MJCore_Metadata'; }
@@ -2984,7 +2983,9 @@ export class ProviderBase {
2984
2983
  if (!ls)
2985
2984
  return;
2986
2985
  const start = Date.now();
2987
- // Save timestamps
2986
+ // Save timestamps as a JSON string. The metadata snapshot path intentionally uses
2987
+ // string storage so the compressed (gzip+base64) format below can round-trip cleanly
2988
+ // through providers that don't support binary natively.
2988
2989
  await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey, JSON.stringify(this._latestLocalMetadataTimestamps));
2989
2990
  // Serialize the AllMetadata object
2990
2991
  const jsonString = JSON.stringify(this._localMetadata);
@@ -3001,14 +3002,14 @@ export class ProviderBase {
3001
3002
  const elapsed = Date.now() - start;
3002
3003
  const ratio = jsonString.length > 0 ? (base64.length / jsonString.length * 100).toFixed(1) : '?';
3003
3004
  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
3005
+ message: `[Metadata Cache] Save complete: ${elapsed}ms, raw=${(jsonString.length / 1024 / 1024).toFixed(1)}MB, compressed=${(base64.length / 1024 / 1024).toFixed(1)}MB (${ratio}%)`,
3006
+ verboseOnly: true
3006
3007
  });
3007
3008
  return;
3008
3009
  }
3009
3010
  catch (compressErr) {
3010
3011
  // 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
+ LogError(`[Metadata Cache] Compression failed, falling back to uncompressed: ${compressErr instanceof Error ? compressErr.message : String(compressErr)}`);
3012
3013
  }
3013
3014
  }
3014
3015
  // Fallback: uncompressed save (older environments without CompressionStream)
@@ -3016,12 +3017,12 @@ export class ProviderBase {
3016
3017
  await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageFormatKey, 'json');
3017
3018
  const elapsed = Date.now() - start;
3018
3019
  LogStatusEx({
3019
- message: `[Fast-Start Cache] Save complete (uncompressed): ${elapsed}ms, size=${(jsonString.length / 1024 / 1024).toFixed(1)}MB`,
3020
- verboseOnly: false
3020
+ message: `[Metadata Cache] Save complete (uncompressed): ${elapsed}ms, size=${(jsonString.length / 1024 / 1024).toFixed(1)}MB`,
3021
+ verboseOnly: true
3021
3022
  });
3022
3023
  }
3023
3024
  catch (e) {
3024
- LogError(`[Fast-Start Cache] SaveLocalMetadataToStorage failed: ${e instanceof Error ? e.message : String(e)}`);
3025
+ LogError(`[Metadata Cache] SaveLocalMetadataToStorage failed: ${e instanceof Error ? e.message : String(e)}`);
3025
3026
  }
3026
3027
  }
3027
3028
  /**