@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.
- package/dist/generic/InMemoryLocalStorageProvider.d.ts +12 -6
- package/dist/generic/InMemoryLocalStorageProvider.d.ts.map +1 -1
- package/dist/generic/InMemoryLocalStorageProvider.js +25 -5
- package/dist/generic/InMemoryLocalStorageProvider.js.map +1 -1
- package/dist/generic/QueryCacheManager.js +2 -1
- package/dist/generic/QueryCacheManager.js.map +1 -1
- package/dist/generic/RegisterForStartup.d.ts +26 -0
- package/dist/generic/RegisterForStartup.d.ts.map +1 -1
- package/dist/generic/RegisterForStartup.js +47 -7
- package/dist/generic/RegisterForStartup.js.map +1 -1
- package/dist/generic/authEvaluator.d.ts +115 -11
- package/dist/generic/authEvaluator.d.ts.map +1 -1
- package/dist/generic/authEvaluator.js +151 -24
- package/dist/generic/authEvaluator.js.map +1 -1
- package/dist/generic/baseEngine.d.ts +22 -0
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +30 -2
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +61 -4
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +205 -34
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts +8 -0
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +10 -0
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +24 -0
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +29 -3
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/interfaces.d.ts +59 -5
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js +6 -0
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +38 -3
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +156 -46
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/metadata.d.ts +2 -1
- package/dist/generic/metadata.d.ts.map +1 -1
- package/dist/generic/metadata.js +45 -42
- package/dist/generic/metadata.js.map +1 -1
- package/dist/generic/metadataUtil.js +1 -1
- package/dist/generic/metadataUtil.js.map +1 -1
- package/dist/generic/permissionInterfaces.d.ts +19 -14
- package/dist/generic/permissionInterfaces.d.ts.map +1 -1
- package/dist/generic/permissionInterfaces.js +3 -3
- package/dist/generic/permissionInterfaces.js.map +1 -1
- package/dist/generic/providerBase.d.ts +26 -39
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +202 -201
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/queryInfo.js +20 -20
- package/dist/generic/queryInfo.js.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.js +30 -9
- package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
- package/dist/generic/securityInfo.d.ts +14 -7
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +26 -25
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/generic/util.d.ts +35 -0
- package/dist/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +99 -10
- package/dist/generic/util.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- 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
|
-
//
|
|
634
|
-
|
|
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
|
|
1079
|
-
//
|
|
1080
|
-
// the
|
|
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
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
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
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
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
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
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
|
-
//
|
|
1301
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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: `⚡ [
|
|
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: `⚡ [
|
|
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: `⚡ [
|
|
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: `⚡ [
|
|
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(`[
|
|
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
|
-
*
|
|
2055
|
-
* of
|
|
2056
|
-
*
|
|
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** →
|
|
2059
|
-
*
|
|
2060
|
-
*
|
|
2061
|
-
*
|
|
2062
|
-
*
|
|
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()
|
|
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: `⚡ [
|
|
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: `⚡ [
|
|
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: `⚡ [
|
|
2063
|
+
LogStatusEx({ message: `⚡ [Metadata Cache] Pre-validation: metadata is current`, verboseOnly: false });
|
|
2096
2064
|
}
|
|
2097
2065
|
}
|
|
2098
2066
|
catch (e) {
|
|
2099
|
-
LogError(`[
|
|
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.
|
|
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
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
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
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
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
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
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
|
-
|
|
2685
|
-
await ls.SetItem(key,
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
await ls.SetItem(
|
|
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
|
-
//
|
|
2889
|
-
|
|
2890
|
-
//
|
|
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
|
|
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: `[
|
|
2925
|
-
verboseOnly:
|
|
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('[
|
|
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(`[
|
|
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: `[
|
|
3005
|
-
verboseOnly:
|
|
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(`[
|
|
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: `[
|
|
3020
|
-
verboseOnly:
|
|
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(`[
|
|
3025
|
+
LogError(`[Metadata Cache] SaveLocalMetadataToStorage failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
3025
3026
|
}
|
|
3026
3027
|
}
|
|
3027
3028
|
/**
|