@memberjunction/core 6.2.0-edge.4 → 6.2.0-edge.5

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 (73) hide show
  1. package/dist/generic/BaseEntitySaveQueue.d.ts +7 -0
  2. package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
  3. package/dist/generic/BaseEntitySaveQueue.js +22 -4
  4. package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
  5. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +18 -0
  6. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
  7. package/dist/generic/baseEngine.d.ts +29 -5
  8. package/dist/generic/baseEngine.d.ts.map +1 -1
  9. package/dist/generic/baseEngine.js +117 -11
  10. package/dist/generic/baseEngine.js.map +1 -1
  11. package/dist/generic/baseEntity.d.ts +45 -1
  12. package/dist/generic/baseEntity.d.ts.map +1 -1
  13. package/dist/generic/baseEntity.js +308 -187
  14. package/dist/generic/baseEntity.js.map +1 -1
  15. package/dist/generic/baseInfo.d.ts +31 -3
  16. package/dist/generic/baseInfo.d.ts.map +1 -1
  17. package/dist/generic/baseInfo.js +138 -40
  18. package/dist/generic/baseInfo.js.map +1 -1
  19. package/dist/generic/databaseProviderBase.d.ts +37 -8
  20. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  21. package/dist/generic/databaseProviderBase.js +162 -65
  22. package/dist/generic/databaseProviderBase.js.map +1 -1
  23. package/dist/generic/entityConfiguration.d.ts +8 -2
  24. package/dist/generic/entityConfiguration.d.ts.map +1 -1
  25. package/dist/generic/entityConfiguration.js +9 -0
  26. package/dist/generic/entityConfiguration.js.map +1 -1
  27. package/dist/generic/entityInfo.d.ts +78 -3
  28. package/dist/generic/entityInfo.d.ts.map +1 -1
  29. package/dist/generic/entityInfo.js +348 -55
  30. package/dist/generic/entityInfo.js.map +1 -1
  31. package/dist/generic/infoProviderResolution.d.ts +16 -0
  32. package/dist/generic/infoProviderResolution.d.ts.map +1 -0
  33. package/dist/generic/infoProviderResolution.js +17 -0
  34. package/dist/generic/infoProviderResolution.js.map +1 -0
  35. package/dist/generic/interfaces.d.ts +25 -1
  36. package/dist/generic/interfaces.d.ts.map +1 -1
  37. package/dist/generic/interfaces.js.map +1 -1
  38. package/dist/generic/localCacheManager.d.ts +238 -40
  39. package/dist/generic/localCacheManager.d.ts.map +1 -1
  40. package/dist/generic/localCacheManager.js +466 -71
  41. package/dist/generic/localCacheManager.js.map +1 -1
  42. package/dist/generic/mjapiUrl.d.ts +21 -0
  43. package/dist/generic/mjapiUrl.d.ts.map +1 -0
  44. package/dist/generic/mjapiUrl.js +25 -0
  45. package/dist/generic/mjapiUrl.js.map +1 -0
  46. package/dist/generic/providerBase.d.ts +118 -3
  47. package/dist/generic/providerBase.d.ts.map +1 -1
  48. package/dist/generic/providerBase.js +398 -100
  49. package/dist/generic/providerBase.js.map +1 -1
  50. package/dist/generic/queryInfo.d.ts +10 -9
  51. package/dist/generic/queryInfo.d.ts.map +1 -1
  52. package/dist/generic/queryInfo.js +60 -33
  53. package/dist/generic/queryInfo.js.map +1 -1
  54. package/dist/generic/scoring/ReciprocalRankFusion.d.ts.map +1 -1
  55. package/dist/generic/scoring/ReciprocalRankFusion.js +10 -3
  56. package/dist/generic/scoring/ReciprocalRankFusion.js.map +1 -1
  57. package/dist/generic/securityInfo.d.ts +6 -6
  58. package/dist/generic/securityInfo.d.ts.map +1 -1
  59. package/dist/generic/securityInfo.js +10 -10
  60. package/dist/generic/securityInfo.js.map +1 -1
  61. package/dist/generic/util.d.ts.map +1 -1
  62. package/dist/generic/util.js +3 -0
  63. package/dist/generic/util.js.map +1 -1
  64. package/dist/generic/wellKnownUserSource.d.ts +3 -2
  65. package/dist/generic/wellKnownUserSource.d.ts.map +1 -1
  66. package/dist/generic/wellKnownUserSource.js +18 -9
  67. package/dist/generic/wellKnownUserSource.js.map +1 -1
  68. package/dist/index.d.ts +1 -0
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +1 -0
  71. package/dist/index.js.map +1 -1
  72. package/package.json +3 -3
  73. package/readme.md +29 -4
@@ -248,6 +248,13 @@ export class ProviderBase {
248
248
  * so a push can see the metadata rows it has not committed yet (MJ#4836).
249
249
  */
250
250
  this._metadataReadsJoinTransaction = false;
251
+ /**
252
+ * The MJ_Metadata dataset items (code + entity name) as of the last full load. The remote
253
+ * timestamps ({@link GetLatestMetadataUpdates}) are keyed by entity name while a dataset
254
+ * read is scoped by item code, so this is the bridge that lets a staleness check be turned
255
+ * into a scoped reload (see {@link PartialRefreshItemCodes}).
256
+ */
257
+ this._metadataDatasetItems = null;
251
258
  /** Debounce timer for {@link scheduleMetadataMemberRefresh}. */
252
259
  this._metadataMemberRefreshTimer = null;
253
260
  // ── Metadata change notices from other servers ─────────────────────
@@ -439,6 +446,34 @@ export class ProviderBase {
439
446
  this._lingerInvalidationWired = true;
440
447
  ProviderBase._invalidationTargets.add(new WeakRef(this));
441
448
  }
449
+ /**
450
+ * Drops this provider's in-flight/lingered RunView dedup entries so the next identical
451
+ * RunView goes to the server instead of replaying a result from the last
452
+ * {@link DedupLingerMs}.
453
+ *
454
+ * Saves and deletes made through a `BaseEntity` in this process already invalidate the entries
455
+ * for their entity automatically. Call this after anything that can write on the SERVER
456
+ * without such an event reaching this process — a Remote Operation, an Action, an agent run,
457
+ * any other mutation. {@link RouteOperation} and the GraphQL client's mutation transport call
458
+ * it for you.
459
+ *
460
+ * Dropping an in-flight entry never affects the callers already waiting on it; it only stops
461
+ * later callers from joining (and stops the result from lingering), since that query may have
462
+ * read pre-write data.
463
+ *
464
+ * @param entityNames - entities known to have been written. Only entries touching them are
465
+ * dropped. Omit (or pass an empty list) when the writes are unknown: every entry is dropped.
466
+ */
467
+ InvalidateRunViewDedupCache(entityNames) {
468
+ const names = (entityNames ?? []).map(n => n?.trim().toLowerCase()).filter((n) => !!n);
469
+ if (names.length === 0) {
470
+ this._inflightViews.clear();
471
+ return;
472
+ }
473
+ for (const n of names) {
474
+ this.invalidateInflightViewsForEntity(n);
475
+ }
476
+ }
442
477
  /**
443
478
  * Drops every in-flight/lingered RunView entry whose params touch the given
444
479
  * entity (lowercased name). Called on BaseEntity save/delete/remote-invalidate.
@@ -476,6 +511,31 @@ export class ProviderBase {
476
511
  * timer, so the refresh runs no earlier than this long after the LAST write.
477
512
  */
478
513
  static { this.MetadataDatasetRefreshDebounceMs = 500; }
514
+ /**
515
+ * MJ_Metadata item codes that may be reloaded on their own, without rebuilding the rest of
516
+ * the metadata graph: the saved-query family. Those rows are written continuously by agents
517
+ * (a query is persisted per analysis step), and every write moves the timestamps of these
518
+ * items. Before this list existed, each such write cost a FULL reload — every EntityInfo and
519
+ * EntityFieldInfo rebuilt (hundreds of MB on a large schema) to pick up a few QueryInfo rows;
520
+ * on a busy server that was a fresh half-gigabyte graph per refresh window until the process
521
+ * hit its memory guard.
522
+ *
523
+ * Membership rule: an item qualifies only when no OTHER metadata class embeds or memoizes its
524
+ * rows at construction. The query classes reference each other lazily through the provider
525
+ * (`QueryInfo.Fields` filters `QueryFields` on first use), so they can be swapped as a set;
526
+ * entity metadata cannot (EntityInfo owns its fields, permissions and relationships).
527
+ * The family is always reloaded together — see {@link PartialRefreshItemCodes}.
528
+ * Intersected with {@link AllMetadataArrays} so the list is valid for whatever this build knows.
529
+ */
530
+ static get PartialRefreshMetadataItemCodes() {
531
+ const known = new Set(AllMetadataArrays.map(m => m.key.substring(3))); // 'AllQueries' → 'Queries'
532
+ return ProviderBase._queryFamilyItemCodes.filter(code => known.has(code));
533
+ }
534
+ static { this._queryFamilyItemCodes = [
535
+ 'QueryCategories', 'Queries', 'QueryFields', 'QueryPermissions', 'QueryEntities', 'QueryParameters', 'QueryDependencies', 'QuerySQLs',
536
+ ]; }
537
+ /** The synthetic roll-up row {@link GetLatestMetadataUpdates} appends to the per-entity timestamps. */
538
+ static { this._allMetadataTypeName = 'All Entity Metadata'; }
479
539
  /**
480
540
  * Records which entities compose this provider's metadata, from the loaded MJ_Metadata
481
541
  * dataset result, and registers this instance with the static event fan-out so writes to any
@@ -494,6 +554,9 @@ export class ProviderBase {
494
554
  return; // a failed/empty load must not erase a previously recorded set
495
555
  }
496
556
  this._metadataDatasetEntityNames = names;
557
+ this._metadataDatasetItems = (dataset.Results ?? [])
558
+ .filter(item => item.Code && item.EntityName)
559
+ .map(item => ({ Code: item.Code, EntityName: item.EntityName }));
497
560
  this.ensureInflightViewInvalidation();
498
561
  }
499
562
  /**
@@ -807,6 +870,21 @@ export class ProviderBase {
807
870
  return true;
808
871
  }
809
872
  this._metadataMemberRefreshWaits = 0;
873
+ // The writer holds positive evidence, so the timestamps are re-read from the authoritative
874
+ // status query. When they attribute the change entirely to the saved-query family (see
875
+ // PartialRefreshMetadataItemCodes) only that family is reloaded, cache layers bypassed as a
876
+ // hard refresh would. Anything that cannot be attributed — another item moved, the status
877
+ // read failed, or it claims nothing changed — keeps the base policy: a HARD Refresh().
878
+ let scoped = false;
879
+ try {
880
+ scoped = this.AllowRefresh && await this.RefreshRemoteMetadataTimestamps() && this.PartialRefreshItemCodes() !== null;
881
+ }
882
+ catch (e) {
883
+ LogError(`Metadata refresh after a member-entity change could not read the timestamps; running a full refresh: ${e instanceof Error ? e.message : String(e)}`);
884
+ }
885
+ if (scoped) {
886
+ return this.ReloadMetadata(true, undefined, true);
887
+ }
810
888
  return this.Refresh();
811
889
  }
812
890
  /**
@@ -934,7 +1012,16 @@ export class ProviderBase {
934
1012
  if (!key) {
935
1013
  return { Success: false, ResultCode: 'INVALID_OPERATION_KEY', ErrorMessage: 'operationKey is required' };
936
1014
  }
937
- return this.InternalRouteOperation(key, input, options ?? {});
1015
+ try {
1016
+ return await this.InternalRouteOperation(key, input, options ?? {});
1017
+ }
1018
+ finally {
1019
+ // A Remote Operation executes server-side and can write any entity — none of which
1020
+ // raises a BaseEntity event in THIS process. Without this, an identical RunView answered
1021
+ // in the last DedupLingerMs would replay pre-operation rows. Runs on failure too: an
1022
+ // operation that failed part-way may still have written.
1023
+ this.InvalidateRunViewDedupCache();
1024
+ }
938
1025
  }
939
1026
  /**
940
1027
  * Provider-specific transport for a Remote Operation. The default implementation reports that
@@ -1398,7 +1485,12 @@ export class ProviderBase {
1398
1485
  return results;
1399
1486
  })
1400
1487
  .catch(err => {
1401
- this._inflightViews.delete(key);
1488
+ // Only our own entry: an invalidation may have dropped it and a newer request for the
1489
+ // same key taken the slot, which this rejection must not evict.
1490
+ const entry = this._inflightViews.get(key);
1491
+ if (entry && entry.promise === promise) {
1492
+ this._inflightViews.delete(key);
1493
+ }
1402
1494
  throw err;
1403
1495
  });
1404
1496
  this.ensureInflightViewInvalidation();
@@ -1626,14 +1718,17 @@ export class ProviderBase {
1626
1718
  // This touches ONLY how the slot is keyed. param.Fields is left untouched, so what the
1627
1719
  // provider FETCHES is unchanged — an earlier attempt cleared param.Fields instead and was
1628
1720
  // reverted precisely because fetch behavior could not be verified.
1629
- const fieldsKey = this.isFullCoverageFieldList(param) ? '*' : ProviderBase.normalizeFieldsKey(param.Fields);
1721
+ const fieldsKey = (param.ResultType === 'entity_object' || this.isFullCoverageFieldList(param))
1722
+ ? '*'
1723
+ : ProviderBase.normalizeFieldsKey(param.Fields);
1630
1724
  const fingerprint = `${base}|f:${fieldsKey}`;
1631
1725
  this._clientFingerprintMemo.set(param, fingerprint);
1632
1726
  return fingerprint;
1633
1727
  }
1634
1728
  /**
1635
- * True when `param.Fields` names EVERY field of the entity — i.e. an explicit list that is
1636
- * semantically "full width". Case-insensitive; order-independent. Returns false on any
1729
+ * True when `param.Fields` names EVERY fetchable field of the entity — i.e. an explicit list
1730
+ * that is semantically "full width" (accounting for binary fields excluded when
1731
+ * `IncludeBinaryFields !== true`). Case-insensitive; order-independent. Returns false on any
1637
1732
  * uncertainty (unknown entity, missing fields) so the fingerprint falls back to the explicit
1638
1733
  * list — the safe direction, since misclassifying narrow-as-full would cross-serve shapes.
1639
1734
  */
@@ -1645,11 +1740,15 @@ export class ProviderBase {
1645
1740
  if (!entity || entity.Fields.length === 0) {
1646
1741
  return false;
1647
1742
  }
1648
- if (param.Fields.length < entity.Fields.length) {
1743
+ const includeBinary = param.IncludeBinaryFields === true;
1744
+ const expectedFields = includeBinary || !entity.HasBinaryFields
1745
+ ? entity.Fields
1746
+ : entity.Fields.filter(f => !f.IsBinaryFieldType);
1747
+ if (param.Fields.length < expectedFields.length) {
1649
1748
  return false; // cheap reject: cannot cover every field with fewer names
1650
1749
  }
1651
1750
  const requested = new Set(param.Fields.map(f => f.trim().toLowerCase()));
1652
- return entity.Fields.every(f => requested.has(f.Name.trim().toLowerCase()));
1751
+ return expectedFields.every(f => requested.has(f.Name.trim().toLowerCase()));
1653
1752
  }
1654
1753
  /**
1655
1754
  * Ranked search over **one** entity's records. See {@link IMetadataProvider.SearchEntity}
@@ -1995,7 +2094,8 @@ export class ProviderBase {
1995
2094
  // the gate only consults `authorized` when a contextUser is present.
1996
2095
  authorized: !user || qInfo.UserCanRun(user),
1997
2096
  categoryPath: qInfo.CategoryPath,
1998
- queryName: qInfo.Name
2097
+ queryName: qInfo.Name,
2098
+ queryId: qInfo.ID
1999
2099
  };
2000
2100
  }
2001
2101
  async RunQuery(params, contextUser) {
@@ -2028,6 +2128,7 @@ export class ProviderBase {
2028
2128
  && !IsMaterializedDataSource(params.DataSource)
2029
2129
  && LocalCacheManager.Instance.IsInitialized;
2030
2130
  let queryFingerprint;
2131
+ let queryDefinition;
2031
2132
  if (queryCacheEngaged) {
2032
2133
  // Resolve + authorize ONCE, BEFORE fingerprinting (B45/B46 seam). The resolved
2033
2134
  // category feeds the fingerprint below, and the same result drives the permission
@@ -2041,13 +2142,19 @@ export class ProviderBase {
2041
2142
  __maxRows: params.MaxRows ?? -1,
2042
2143
  __startRow: params.StartRow ?? 0
2043
2144
  };
2044
- queryFingerprint = LocalCacheManager.Instance.GenerateRunQueryFingerprint(params.QueryID, params.QueryName, fingerprintParams, this.InstanceConnectionString,
2145
+ queryFingerprint = LocalCacheManager.Instance.GenerateRunQueryFingerprint(
2146
+ // The RESOLVED identity when metadata resolves, so a request by ID, by name, or by
2147
+ // both shares one slot (and a query's slots can be found by its ID). The caller's
2148
+ // identity otherwise.
2149
+ queryAuth.resolvable ? queryAuth.queryId : params.QueryID, queryAuth.resolvable ? queryAuth.queryName : params.QueryName, fingerprintParams, this.InstanceConnectionString,
2045
2150
  // B46: the full category path is a DISTINGUISHING element. Canonical resolved
2046
2151
  // path when metadata resolves; the caller-stated path otherwise (runtime-created
2047
2152
  // queries) — either way, same-named queries in different categories get
2048
2153
  // different slots.
2049
2154
  queryAuth.resolvable ? queryAuth.categoryPath : params.CategoryPath);
2050
- const cached = await LocalCacheManager.Instance.GetRunQueryResult(queryFingerprint); // TTL-enforced
2155
+ queryDefinition = queryAuth.definition;
2156
+ // TTL-enforced, and a miss when the slot was written under another query definition.
2157
+ const cached = await LocalCacheManager.Instance.GetRunQueryResult(queryFingerprint, queryDefinition);
2051
2158
  if (cached) {
2052
2159
  const serveFromSlot = () => ({
2053
2160
  QueryID: cached.queryId ?? params.QueryID ?? '',
@@ -2131,7 +2238,9 @@ export class ProviderBase {
2131
2238
  else {
2132
2239
  const response = await checker([{
2133
2240
  params,
2134
- cacheStatus: { maxUpdatedAt: cached.maxUpdatedAt, rowCount: cached.rowCount }
2241
+ // '' for a slot written without a hash: the server answers `stale` and returns
2242
+ // its hash, so the rewrite below stamps the slot.
2243
+ cacheStatus: { maxUpdatedAt: cached.maxUpdatedAt, rowCount: cached.rowCount, definitionHash: cached.definitionHash ?? '' }
2135
2244
  }], contextUser);
2136
2245
  const check = response.results?.[0];
2137
2246
  if (response.success && check) {
@@ -2141,7 +2250,7 @@ export class ProviderBase {
2141
2250
  if ((check.status === 'stale' || check.status === 'no_validation') && check.results) {
2142
2251
  const freshRows = check.results;
2143
2252
  // Fire-and-forget slot rewrite — same pattern as the RunView client path
2144
- LocalCacheManager.Instance.SetRunQueryResult(queryFingerprint, params.QueryName ?? '', freshRows, check.maxUpdatedAt ?? '', check.rowCount, check.queryId, params.CacheLocalTTL, contextUser?.ID).catch(e => LogError(`RunQuery cache rewrite failed: ${e}`));
2253
+ LocalCacheManager.Instance.SetRunQueryResult(queryFingerprint, params.QueryName ?? '', freshRows, check.maxUpdatedAt ?? '', check.rowCount, check.queryId, params.CacheLocalTTL, contextUser?.ID, check.definitionHash ? { QueryID: check.queryId, Hash: check.definitionHash } : undefined).catch(e => LogError(`RunQuery cache rewrite failed: ${e}`));
2145
2254
  return {
2146
2255
  QueryID: check.queryId ?? params.QueryID ?? '',
2147
2256
  QueryName: params.QueryName ?? '',
@@ -2177,7 +2286,7 @@ export class ProviderBase {
2177
2286
  // or the LocalCacheManager default). maxUpdatedAt is unknown for a plain run —
2178
2287
  // the smart-validation path stamps it when the Query has CacheValidationSQL.
2179
2288
  if (queryCacheEngaged && queryFingerprint && result.Success) {
2180
- LocalCacheManager.Instance.SetRunQueryResult(queryFingerprint, result.QueryName, result.Results, '', result.TotalRowCount, result.QueryID, params.CacheLocalTTL, contextUser?.ID).catch(e => LogError(`RunQuery cache write failed: ${e}`));
2289
+ LocalCacheManager.Instance.SetRunQueryResult(queryFingerprint, result.QueryName, result.Results, '', result.TotalRowCount, result.QueryID, params.CacheLocalTTL, contextUser?.ID, queryDefinition).catch(e => LogError(`RunQuery cache write failed: ${e}`));
2181
2290
  }
2182
2291
  return result;
2183
2292
  }
@@ -2383,12 +2492,15 @@ export class ProviderBase {
2383
2492
  * @param fieldNames - Field names as a caller supplied them, e.g. `RunViewParams.Fields`.
2384
2493
  */
2385
2494
  static NamesAnyBinaryField(entity, fieldNames) {
2386
- if (!fieldNames || fieldNames.length === 0)
2387
- return false;
2388
- const binaryNames = new Set(entity.Fields.filter(f => f.IsBinaryFieldType).map(f => f.Name.toLowerCase()));
2389
- if (binaryNames.size === 0)
2495
+ if (!fieldNames || fieldNames.length === 0 || !entity.HasBinaryFields)
2390
2496
  return false;
2391
- return fieldNames.some(name => typeof name === 'string' && binaryNames.has(name.trim().toLowerCase()));
2497
+ const binaryFields = entity.BinaryFields;
2498
+ return fieldNames.some(name => {
2499
+ if (typeof name !== 'string')
2500
+ return false;
2501
+ const lower = name.trim().toLowerCase();
2502
+ return binaryFields.some(bf => bf.Name.toLowerCase() === lower);
2503
+ });
2392
2504
  }
2393
2505
  /**
2394
2506
  * Decides whether a RunView returns the entity's binary fields, and records the decision on
@@ -2953,24 +3065,13 @@ export class ProviderBase {
2953
3065
  for (let i = 0; i < params.length; i++) {
2954
3066
  const param = params[i];
2955
3067
  await this.EntityStatusCheck(param, 'PreRunViews', contextUser);
3068
+ let entity;
2956
3069
  if (param.ResultType === 'entity_object') {
2957
- const entity = this.EntityByName(param.EntityName);
3070
+ entity = this.EntityByName(param.EntityName);
2958
3071
  if (!entity) {
2959
3072
  throw new Error(`Entity ${param.EntityName} not found in metadata`);
2960
3073
  }
2961
- // NOTE (R2-GAP-1, logged as B44): widening to an explicit full field list makes
2962
- // the client fingerprint carry `f:<every field>` rather than `f:*`, so the cache
2963
- // classifier reads the slot as a NARROW projection and stops maintaining it in
2964
- // place. entity_object is the BaseEngine default, so this costs in-place
2965
- // maintenance on the client's most common slot shape. It is a PERF gap only —
2966
- // the slot is invalidated and refetched, never served wrong.
2967
- //
2968
- // Deliberately NOT "fixed" by clearing Fields here: that would change what the
2969
- // provider FETCHES, not just how the slot is keyed, and I could not verify the
2970
- // downstream fetch behavior tonight. The safe fix is to normalize a full-coverage
2971
- // field list to `*` in the FINGERPRINT only, which cannot affect fetching.
2972
3074
  this.ResolveIncludeBinaryFields(param, entity);
2973
- param.Fields = this.ComputeRunViewFetchFields(entity, param);
2974
3075
  }
2975
3076
  // Gate on runViewCacheEligible (NOT raw param.CacheLocal): the smart-cache-check path is a
2976
3077
  // second, independent cache transport, and gating it on CacheLocal alone re-admits the exact
@@ -2978,9 +3079,16 @@ export class ProviderBase {
2978
3079
  // BaseEntity event), count_only, AfterKey, BypassCache, cache-disallowed entities. On a client
2979
3080
  // (!TrustLocalCacheCompletely) runViewCacheEligible already implies CacheLocal===true, so this is
2980
3081
  // strictly a tightening — normal cacheable slots are unaffected.
3082
+ //
3083
+ // Compute clientCacheFingerprint BEFORE widening param.Fields for entity_object so the
3084
+ // fingerprint sees the caller's original Fields (normalizing to `|f:*` in O(1) without
3085
+ // building a full-entity field Set or misclassifying entities with excluded binary columns).
2981
3086
  if (this.runViewCacheEligibleForWrite(param) && LocalCacheManager.Instance.IsInitialized) {
2982
3087
  cacheable.push({ paramIndex: i, fingerprint: this.clientCacheFingerprint(param) });
2983
3088
  }
3089
+ if (param.ResultType === 'entity_object' && entity) {
3090
+ param.Fields = this.ComputeRunViewFetchFields(entity, param);
3091
+ }
2984
3092
  }
2985
3093
  // Phase 2 — batched read: one IDB transaction (or one Redis MGET) returns
2986
3094
  // every cached entry's status (maxUpdatedAt + rowCount). Was previously N
@@ -3005,7 +3113,8 @@ export class ProviderBase {
3005
3113
  allCached: false, // Don't return cached directly - let server validate
3006
3114
  useSmartCacheCheck: true,
3007
3115
  smartCacheCheckParams,
3008
- cacheStatusMap: new Map()
3116
+ cacheStatusMap: new Map(),
3117
+ preResolvedCache: cacheMap,
3009
3118
  };
3010
3119
  }
3011
3120
  /**
@@ -3039,20 +3148,30 @@ export class ProviderBase {
3039
3148
  UserViewRunID: ''
3040
3149
  }));
3041
3150
  }
3042
- // ── Batched cache pre-read for 'current' entries ──────────────────
3043
- // Each 'current' result needs its cached row data to materialize the response.
3044
- // Collect every fingerprint up front and issue ONE batched read instead of
3045
- // letting each per-param processor do its own GetRunViewResult call (~85
3046
- // serialized IDB transactions in the 8-engine warm-load case).
3047
- const currentFingerprints = [];
3151
+ // ── Reuse pre-resolved cache map from prepareSmartCacheCheckParams ──
3152
+ // prepareSmartCacheCheckParams already read all cacheable fingerprints from
3153
+ // LocalCacheManager in a single batched IDB transaction. Reuse that map directly
3154
+ // so 'current' entries don't trigger a second IDB transaction + JSON.parse pass.
3155
+ // Only query LocalCacheManager for any 'current' fingerprint not already present
3156
+ // in preResult.preResolvedCache (e.g., direct test invocations).
3157
+ const preResolvedCache = preResult.preResolvedCache
3158
+ ? new Map(preResult.preResolvedCache)
3159
+ : new Map();
3160
+ const missingFingerprints = [];
3048
3161
  for (const sr of response.results) {
3049
3162
  if (sr.status === 'current' && params[sr.viewIndex]) {
3050
- currentFingerprints.push(this.clientCacheFingerprint(params[sr.viewIndex]));
3163
+ const fp = this.clientCacheFingerprint(params[sr.viewIndex]);
3164
+ if (!preResolvedCache.has(fp)) {
3165
+ missingFingerprints.push(fp);
3166
+ }
3167
+ }
3168
+ }
3169
+ if (missingFingerprints.length > 0) {
3170
+ const fetched = await LocalCacheManager.Instance.GetRunViewResults(missingFingerprints);
3171
+ for (const [k, v] of fetched) {
3172
+ preResolvedCache.set(k, v);
3051
3173
  }
3052
3174
  }
3053
- const preResolvedCache = currentFingerprints.length > 0
3054
- ? await LocalCacheManager.Instance.GetRunViewResults(currentFingerprints)
3055
- : new Map();
3056
3175
  // Pre-build index Map for server results lookup — avoids O(N^2) .find() scans below.
3057
3176
  const serverResultsByViewIndex = new Map();
3058
3177
  if (response.results) {
@@ -4081,11 +4200,11 @@ export class ProviderBase {
4081
4200
  // settings to give it, so a LATER caller that does (StartupManager, passing the host's
4082
4201
  // cacheSettings) is not a no-op: Initialize applies that configuration to the already
4083
4202
  // initialized manager rather than discarding it, which is what made cacheSettings inert
4084
- // before.
4085
- if (!LocalCacheManager.Instance.IsInitialized) {
4086
- const storageProvider = this.LocalStorageProvider;
4087
- await LocalCacheManager.Instance.Initialize(storageProvider);
4088
- }
4203
+ // before. Kick off initialization without awaiting immediately so on warm boots it runs
4204
+ // concurrently with LoadLocalMetadataFromStorage() instead of adding a serial IDB round-trip.
4205
+ const localCacheInitPromise = !LocalCacheManager.Instance.IsInitialized
4206
+ ? LocalCacheManager.Instance.Initialize(this.LocalStorageProvider)
4207
+ : Promise.resolve();
4089
4208
  // first, let's check to see if we have an existing Metadata.Provider registered, if so
4090
4209
  // unless our data.IgnoreExistingMetadata is set to true, we will not refresh the metadata.
4091
4210
  //
@@ -4102,6 +4221,7 @@ export class ProviderBase {
4102
4221
  // we have an existing globally registered provider AND we are not
4103
4222
  // requested to ignore the existing metadata, so we will not refresh it
4104
4223
  if (this.CopyMetadataFromGlobalProvider()) {
4224
+ await localCacheInitPromise;
4105
4225
  return true; // we're done, if we fail here, we keep going and do normal logic
4106
4226
  }
4107
4227
  }
@@ -4115,7 +4235,7 @@ export class ProviderBase {
4115
4235
  // This is a stale-while-revalidate pattern: serve cached data instantly, then
4116
4236
  // refresh if the server says it's outdated.
4117
4237
  if (!this.TrustLocalCacheCompletely && !hardRefresh && !this._localMetadata?.AllEntities?.length) {
4118
- await this.LoadLocalMetadataFromStorage();
4238
+ await Promise.all([localCacheInitPromise, this.LoadLocalMetadataFromStorage()]);
4119
4239
  if (this._localMetadata?.AllEntities?.length) {
4120
4240
  LogStatusEx({ message: `⚡ [Metadata Cache] Loaded ${this._localMetadata.AllEntities.length} entities from local cache — pre-validating before engine startup`, verboseOnly: false });
4121
4241
  // Do NOT kick off background validation here — it writes to IndexedDB
@@ -4125,6 +4245,9 @@ export class ProviderBase {
4125
4245
  return true; // App can proceed immediately with cached metadata
4126
4246
  }
4127
4247
  }
4248
+ else {
4249
+ await localCacheInitPromise;
4250
+ }
4128
4251
  // An empty graph has nothing to throttle: the throttle exists to stop redundant checks of a
4129
4252
  // snapshot we already hold. After a status check succeeded but the download that followed
4130
4253
  // failed, the throttle is armed and a non-bypassing retry would read "current" and load
@@ -4132,36 +4255,71 @@ export class ProviderBase {
4132
4255
  const graphIsEmpty = !this._localMetadata?.AllEntities?.length;
4133
4256
  if (hardRefresh || await this.CheckToSeeIfRefreshNeeded(providerToUse, graphIsEmpty)) {
4134
4257
  // either a hard refresh flag was set within Refresh(), or LocalMetadata is Obsolete
4135
- // first, make sure we reset the flag to false so that if another call to this function happens
4136
- // while we are waiting for the async call to finish, we dont do it again
4137
- this._refresh = false;
4138
- // SINGLE-FLIGHT: at most one full metadata reload runs at a time. Without this, a
4139
- // second refresh request arriving while a reload is still awaiting its queries starts
4140
- // a CONCURRENT reload, and whichever finishes LAST wins the atomic swap — an older
4141
- // snapshot can overwrite a newer one. A joiner must not simply await and return,
4142
- // either: the in-flight reload's queries may predate the write that prompted the
4143
- // joiner, so it flags ONE follow-up; the loop below reruns after the current pass,
4144
- // guaranteeing the final swap comes from a read that started after the last request.
4145
- if (this._metadataReloadInFlight) {
4146
- this._metadataReloadQueued = true;
4147
- await this._metadataReloadInFlight;
4258
+ return this.ReloadMetadata(hardRefresh, providerToUse);
4259
+ }
4260
+ return true;
4261
+ }
4262
+ /**
4263
+ * Reloads this provider's metadata. Single-flight: at most one reload runs at a time, and a
4264
+ * request arriving mid-reload queues exactly one follow-up pass.
4265
+ * @param hardRefresh - re-read the remote timestamps first and bypass every cache layer
4266
+ * @param scopedFirst - try the scoped reload of the saved-query family (see
4267
+ * {@link PartialRefreshMetadataItemCodes}) before the full one. Defaults to true unless
4268
+ * hardRefresh: an explicit Refresh() is always a full reload.
4269
+ */
4270
+ async ReloadMetadata(hardRefresh, providerToUse, scopedFirst = !hardRefresh) {
4271
+ // first, make sure we reset the flag to false so that if another call to this function happens
4272
+ // while we are waiting for the async call to finish, we dont do it again
4273
+ this._refresh = false;
4274
+ // SINGLE-FLIGHT: at most one full metadata reload runs at a time. Without this, a
4275
+ // second refresh request arriving while a reload is still awaiting its queries starts
4276
+ // a CONCURRENT reload, and whichever finishes LAST wins the atomic swap — an older
4277
+ // snapshot can overwrite a newer one. A joiner must not simply await and return,
4278
+ // either: the in-flight reload's queries may predate the write that prompted the
4279
+ // joiner, so it flags ONE follow-up; the loop below reruns after the current pass,
4280
+ // guaranteeing the final swap comes from a read that started after the last request.
4281
+ if (this._metadataReloadInFlight) {
4282
+ this._metadataReloadQueued = true;
4283
+ if (this._metadataReadsJoinTransaction) {
4284
+ // Caller owns an open database transaction (RefreshWithinTransaction) and is
4285
+ // waiting for this reload before it can COMMIT or ROLLBACK. An in-flight pool
4286
+ // reload may be blocked in the database on the caller's own uncommitted locks
4287
+ // (LCK_M_S), so awaiting _metadataReloadInFlight here would self-deadlock.
4288
+ // Run the transactional reload directly on the ambient transaction; setting
4289
+ // _metadataReloadQueued = true above ensures the pool reload re-reads clean
4290
+ // committed state once the transaction finishes and releases its locks.
4291
+ await this.RefreshRemoteMetadataTimestamps(providerToUse);
4292
+ const res = await this.GetAllMetadata(providerToUse, true);
4293
+ if (res) {
4294
+ await this.adoptServerMetadata(res);
4295
+ this._configLoadedFromServer = true;
4296
+ }
4148
4297
  return true;
4149
4298
  }
4150
- this._metadataReloadInFlight = (async () => {
4151
- let effectiveHardRefresh = hardRefresh;
4152
- do {
4153
- this._metadataReloadQueued = false;
4154
- // The local timestamps must describe the snapshot about to be loaded. On the
4155
- // hard-refresh path the staleness check was SKIPPED, so the cached remote
4156
- // timestamps predate this pass — copying them as-is would make the next
4157
- // periodic check see a mismatch and reload once more for nothing. Re-read
4158
- // them (one cheap status query, authoritative) BEFORE the load, not after:
4159
- // a write landing DURING the load then leaves the stamped timestamps looking
4160
- // stale and the next tick reloads — the safe direction. Reading after could
4161
- // stamp the snapshot as containing a write it does not.
4162
- if (effectiveHardRefresh) {
4163
- await this.RefreshRemoteMetadataTimestamps(providerToUse);
4164
- }
4299
+ await this._metadataReloadInFlight;
4300
+ return true;
4301
+ }
4302
+ this._metadataReloadInFlight = (async () => {
4303
+ let effectiveHardRefresh = hardRefresh;
4304
+ let tryScoped = scopedFirst;
4305
+ do {
4306
+ this._metadataReloadQueued = false;
4307
+ // The local timestamps must describe the snapshot about to be loaded. On the
4308
+ // hard-refresh path the staleness check was SKIPPED, so the cached remote
4309
+ // timestamps predate this pass — copying them as-is would make the next
4310
+ // periodic check see a mismatch and reload once more for nothing. Re-read
4311
+ // them (one cheap status query, authoritative) BEFORE the load, not after:
4312
+ // a write landing DURING the load then leaves the stamped timestamps looking
4313
+ // stale and the next tick reloads — the safe direction. Reading after could
4314
+ // stamp the snapshot as containing a write it does not.
4315
+ if (effectiveHardRefresh) {
4316
+ await this.RefreshRemoteMetadataTimestamps(providerToUse);
4317
+ }
4318
+ // SCOPED FIRST: when the staleness is attributable entirely to the saved-query family
4319
+ // (see PartialRefreshMetadataItemCodes), reload just that family and keep every other
4320
+ // array as is. Anything else — or a failed scoped read — takes the full reload below.
4321
+ const scopedDone = tryScoped && await this.TryPartialMetadataReload(providerToUse);
4322
+ if (!scopedDone) {
4165
4323
  // Fetch new metadata without clearing current metadata
4166
4324
  // This ensures readers always see valid data (old until new is ready)
4167
4325
  const start = new Date().getTime();
@@ -4176,17 +4334,20 @@ export class ProviderBase {
4176
4334
  // GetAllMetadata failed - log error but keep existing metadata
4177
4335
  LogError('GetAllMetadata() returned undefined - metadata not updated');
4178
4336
  }
4179
- // A queued follow-up exists only because another refresh request arrived
4180
- // mid-reload; rerun hard so the re-read cannot be served by any cache layer.
4181
- effectiveHardRefresh = true;
4182
- } while (this._metadataReloadQueued);
4183
- })();
4184
- try {
4185
- await this._metadataReloadInFlight;
4186
- }
4187
- finally {
4188
- this._metadataReloadInFlight = null;
4189
- }
4337
+ }
4338
+ // A queued follow-up exists only because another refresh request arrived
4339
+ // mid-reload; rerun hard so the re-read cannot be served by any cache layer.
4340
+ effectiveHardRefresh = true;
4341
+ // The follow-up re-reads the timestamps first, so it may still be attributed to the
4342
+ // saved-query family — and a scoped read never trusts a cache layer either.
4343
+ tryScoped = true;
4344
+ } while (this._metadataReloadQueued);
4345
+ })();
4346
+ try {
4347
+ await this._metadataReloadInFlight;
4348
+ }
4349
+ finally {
4350
+ this._metadataReloadInFlight = null;
4190
4351
  }
4191
4352
  return true;
4192
4353
  }
@@ -4203,6 +4364,117 @@ export class ProviderBase {
4203
4364
  this._latestLocalMetadataTimestamps = this._latestRemoteMetadataTimestamps; // we just used the server to get all the stuff
4204
4365
  await this.SaveLocalMetadataToStorage();
4205
4366
  }
4367
+ /**
4368
+ * Decides whether the current staleness can be served by a scoped reload of the saved-query
4369
+ * family. Compares local and remote timestamps item by item (the same test as
4370
+ * {@link LocalMetadataObsolete}) and maps each stale entity back to its MJ_Metadata item code.
4371
+ * @returns the family's item codes (the whole family — the query classes are swapped as a set)
4372
+ * when every stale item belongs to it; null when anything else is stale, nothing is, or the
4373
+ * inputs needed to attribute the change are missing — the full reload is then the answer.
4374
+ */
4375
+ PartialRefreshItemCodes() {
4376
+ const items = this._metadataDatasetItems;
4377
+ const local = this.LatestLocalMetadata;
4378
+ const remote = this.LatestRemoteMetadata;
4379
+ if (!items?.length || !this._localMetadata?.AllEntities?.length || !local?.length || !remote?.length) {
4380
+ return null;
4381
+ }
4382
+ const family = new Set(ProviderBase.PartialRefreshMetadataItemCodes);
4383
+ const norm = (v) => (v ?? '').trim().toLowerCase();
4384
+ const rollup = norm(ProviderBase._allMetadataTypeName);
4385
+ let stale = 0;
4386
+ for (const r of remote) {
4387
+ if (norm(r.Type) === rollup) {
4388
+ continue; // the roll-up moves with any item; the per-item rows decide
4389
+ }
4390
+ const l = local.find(m => norm(m.Type) === norm(r.Type));
4391
+ if (l && !ProviderBase.metadataStampChanged(l, r)) {
4392
+ continue;
4393
+ }
4394
+ const owners = items.filter(i => norm(i.EntityName) === norm(r.Type));
4395
+ if (owners.length === 0 || owners.some(o => !family.has(o.Code))) {
4396
+ return null; // something outside the family moved, or a new item appeared
4397
+ }
4398
+ stale++;
4399
+ }
4400
+ if (local.some(l => norm(l.Type) !== rollup && !remote.some(r => norm(r.Type) === norm(l.Type)))) {
4401
+ return null; // an item left the dataset
4402
+ }
4403
+ if (stale === 0) {
4404
+ return null; // nothing attributable — the caller's own policy decides
4405
+ }
4406
+ const codes = [...new Set(items.map(i => i.Code).filter(c => family.has(c)))];
4407
+ return codes.length > 0 ? codes : null;
4408
+ }
4409
+ static metadataStampChanged(local, remote) {
4410
+ if (!local.UpdatedAt && !remote.UpdatedAt) {
4411
+ return false;
4412
+ }
4413
+ if (!local.UpdatedAt || !remote.UpdatedAt) {
4414
+ return true;
4415
+ }
4416
+ return new Date(local.UpdatedAt).getTime() !== new Date(remote.UpdatedAt).getTime() || local.RowCount !== remote.RowCount;
4417
+ }
4418
+ /**
4419
+ * Reloads only the saved-query family of MJ_Metadata and swaps it into the current metadata,
4420
+ * keeping every other array — and every object in them — as is. The dataset definition is
4421
+ * reused verbatim (same columns, where clauses and post-processing): every other item is
4422
+ * requested with a `1=0` filter, so it reads nothing. Always bypasses cache layers — the read
4423
+ * is small, and a cached answer is exactly what a staleness check cannot trust.
4424
+ * @returns true when the scoped reload was applied; false when it does not apply or any part
4425
+ * of it failed — the caller then runs the full reload, so a failure never leaves stale queries.
4426
+ */
4427
+ async TryPartialMetadataReload(providerToUse) {
4428
+ const codes = this.PartialRefreshItemCodes();
4429
+ const current = this._localMetadata;
4430
+ const items = this._metadataDatasetItems;
4431
+ if (!codes || !current || !items) {
4432
+ return false;
4433
+ }
4434
+ try {
4435
+ const keep = new Set(codes);
4436
+ const skipOthers = items
4437
+ .filter(item => !keep.has(item.Code))
4438
+ .map(item => ({ ItemCode: item.Code, Filter: '1=0' }));
4439
+ const start = Date.now();
4440
+ const d = await this.GetDatasetByName(ProviderBase._mjMetadataDatasetName, skipOthers, this.CurrentUser, providerToUse, true);
4441
+ if (!d?.Success) {
4442
+ LogStatusEx({ message: `[Metadata] Scoped reload of ${codes.join(', ')} failed (${d?.Status ?? 'no result'}); running the full reload`, verboseOnly: false });
4443
+ return false;
4444
+ }
4445
+ const simple = {};
4446
+ for (const code of codes) {
4447
+ const item = d.Results?.find(r => r.Code === code);
4448
+ if (!item || item.Success === false || !Array.isArray(item.Results)) {
4449
+ LogStatusEx({ message: `[Metadata] Scoped reload: item ${code} ${item ? `failed (${item.Status ?? 'no rows'})` : 'missing'}; running the full reload`, verboseOnly: false });
4450
+ return false;
4451
+ }
4452
+ simple[code] = item.Results;
4453
+ }
4454
+ const reloaded = MetadataFromSimpleObjectWithoutUser(simple, this);
4455
+ if (!reloaded) {
4456
+ return false;
4457
+ }
4458
+ const next = new AllMetadata();
4459
+ next.CurrentUser = current.CurrentUser;
4460
+ const target = next;
4461
+ const kept = current;
4462
+ const fresh = reloaded;
4463
+ for (const m of AllMetadataArrays) {
4464
+ target[m.key] = keep.has(m.key.substring(3)) ? fresh[m.key] : kept[m.key];
4465
+ }
4466
+ // Adopted like every other server snapshot. _configLoadedFromServer is NOT set: this read
4467
+ // fetched no current user (the existing one is carried over), so the next pre-validation
4468
+ // must still run its check.
4469
+ await this.adoptServerMetadata(next);
4470
+ LogStatusEx({ message: `[Metadata] Reloaded ${codes.join(', ')} only in ${Date.now() - start} ms; the rest of the metadata is unchanged`, verboseOnly: true });
4471
+ return true;
4472
+ }
4473
+ catch (e) {
4474
+ LogError(`[Metadata] Scoped reload failed; running the full reload: ${e instanceof Error ? e.message : String(e)}`);
4475
+ return false;
4476
+ }
4477
+ }
4206
4478
  /**
4207
4479
  * Background validation for the stale-while-revalidate fast-start pattern.
4208
4480
  * Checks if local metadata is still current; if stale, fetches fresh metadata
@@ -4266,7 +4538,12 @@ export class ProviderBase {
4266
4538
  return;
4267
4539
  }
4268
4540
  try {
4269
- const needsRefresh = await this.CheckToSeeIfRefreshNeeded(providerToUse);
4541
+ // Run the staleness check and CurrentUser refresh concurrently on warm boot so
4542
+ // when metadata is current (99%+ of warm loads) we eliminate a sequential round-trip.
4543
+ const [needsRefresh] = await Promise.all([
4544
+ this.CheckToSeeIfRefreshNeeded(providerToUse),
4545
+ this.RefreshCurrentUser(),
4546
+ ]);
4270
4547
  if (needsRefresh) {
4271
4548
  LogStatusEx({ message: `⚡ [Metadata Cache] Pre-validation: metadata is stale — refreshing before engine startup`, verboseOnly: false });
4272
4549
  const start = Date.now();
@@ -4279,9 +4556,6 @@ export class ProviderBase {
4279
4556
  }
4280
4557
  else {
4281
4558
  LogStatusEx({ message: `⚡ [Metadata Cache] Pre-validation: metadata is current`, verboseOnly: false });
4282
- // Even when entity metadata is current, refresh CurrentUser so user roles and permissions
4283
- // stay in sync with the database without requiring a schema change or manual cache clear.
4284
- await this.RefreshCurrentUser();
4285
4559
  }
4286
4560
  }
4287
4561
  catch (e) {
@@ -4332,6 +4606,15 @@ export class ProviderBase {
4332
4606
  * were on the client's global provider): treat Info objects and everything
4333
4607
  * they own as read-only; copy before sorting.
4334
4608
  *
4609
+ * Owning provider: the shared Info objects deliberately keep the
4610
+ * {@link BaseInfo.OwningProvider} of the provider that built them (the source
4611
+ * global provider); the shell does NOT re-stamp them. Re-stamping a shared
4612
+ * instance would re-point it for every other provider holding it. The sibling
4613
+ * lookups it drives (IS-A children, RLS filters, query fields, ...) therefore
4614
+ * read the source provider's graph — the same immutable graph this shell
4615
+ * points into, so the answers are identical. A provider that needs its own
4616
+ * graph (a different database) must load its own metadata, not adopt a shell.
4617
+ *
4335
4618
  * Override precedence: if a subclass overrides BOTH this method and the
4336
4619
  * deprecated {@link CloneAllMetadata}, the CloneAllMetadata override wins on
4337
4620
  * the fast path (see {@link CopyMetadataFromGlobalProvider}) — the
@@ -4490,8 +4773,17 @@ export class ProviderBase {
4490
4773
  try {
4491
4774
  const user = await this.GetCurrentUser();
4492
4775
  if (user && this._localMetadata) {
4776
+ const previousUserJson = this._localMetadata.CurrentUser
4777
+ ? JSON.stringify(this._localMetadata.CurrentUser)
4778
+ : null;
4493
4779
  this._localMetadata.CurrentUser = user;
4494
- void this.SaveLocalMetadataToStorage();
4780
+ // Only re-persist the full metadata snapshot when the user record or roles
4781
+ // actually changed; on warm boots where CurrentUser is unchanged, skipping
4782
+ // SaveLocalMetadataToStorage avoids a multi-MB JSON.stringify + gzip + IDB
4783
+ // write pass on the main thread during startup.
4784
+ if (previousUserJson !== JSON.stringify(user)) {
4785
+ void this.SaveLocalMetadataToStorage();
4786
+ }
4495
4787
  return user;
4496
4788
  }
4497
4789
  }
@@ -4578,7 +4870,7 @@ export class ProviderBase {
4578
4870
  if (organicKeys) {
4579
4871
  e.EntityOrganicKeys = activeOrganicKeysByEntityId.get(entityIdKey) || [];
4580
4872
  }
4581
- result.push(new EntityInfo(e));
4873
+ result.push(new EntityInfo(e, this));
4582
4874
  }
4583
4875
  // Check for schema name collision: if both 'MJ' and 'MJCustom' schemas exist,
4584
4876
  // the class name prefix for 'MJ' is 'MJCustom' which would collide with the
@@ -4872,8 +5164,13 @@ export class ProviderBase {
4872
5164
  * @returns True if refresh was successful or not needed
4873
5165
  */
4874
5166
  async RefreshIfNeeded(providerToUse, bypassMinCheckInterval) {
4875
- if (await this.CheckToSeeIfRefreshNeeded(providerToUse, bypassMinCheckInterval))
5167
+ if (await this.CheckToSeeIfRefreshNeeded(providerToUse, bypassMinCheckInterval)) {
5168
+ // Stale only in the saved-query family → reload just that family (fresh timestamps, no
5169
+ // cache layer), falling back to the full hard reload if the scoped read fails.
5170
+ if (this.PartialRefreshItemCodes() !== null)
5171
+ return this.ReloadMetadata(true, providerToUse, true);
4876
5172
  return this.Refresh(providerToUse);
5173
+ }
4877
5174
  else
4878
5175
  return true;
4879
5176
  }
@@ -5528,17 +5825,18 @@ export class ProviderBase {
5528
5825
  * Used for compressed metadata storage/retrieval.
5529
5826
  */
5530
5827
  static arrayBufferToBase64(buffer) {
5531
- // Node encodes natively. The loop below concatenates one character per byte, building a rope
5532
- // the size of the payload that then has to be flattened.
5828
+ // Node encodes natively. In browsers, process in 32KB chunks with spread to avoid
5829
+ // building a character-by-character rope across multi-MB payloads.
5533
5830
  if (typeof Buffer !== 'undefined') {
5534
5831
  return Buffer.from(buffer).toString('base64');
5535
5832
  }
5536
5833
  const bytes = new Uint8Array(buffer);
5537
- let binary = '';
5538
- for (let i = 0; i < bytes.byteLength; i++) {
5539
- binary += String.fromCharCode(bytes[i]);
5834
+ const chunkSize = 0x8000;
5835
+ const chunks = [];
5836
+ for (let i = 0; i < bytes.byteLength; i += chunkSize) {
5837
+ chunks.push(String.fromCharCode(...bytes.subarray(i, i + chunkSize)));
5540
5838
  }
5541
- return btoa(binary);
5839
+ return btoa(chunks.join(''));
5542
5840
  }
5543
5841
  /**
5544
5842
  * 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