@memberjunction/core 6.2.0-edge.0 → 6.2.0-edge.2

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 (124) hide show
  1. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +180 -0
  2. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
  3. package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts +28 -0
  4. package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts.map +1 -1
  5. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts +76 -0
  6. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts.map +1 -1
  7. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts +14 -0
  8. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts.map +1 -1
  9. package/dist/generic/QueryCache.d.ts +17 -0
  10. package/dist/generic/QueryCache.d.ts.map +1 -1
  11. package/dist/generic/QueryCache.js +25 -5
  12. package/dist/generic/QueryCache.js.map +1 -1
  13. package/dist/generic/RegisterForStartup.d.ts +5 -5
  14. package/dist/generic/RegisterForStartup.js +12 -12
  15. package/dist/generic/applicationInfo.d.ts +4 -2
  16. package/dist/generic/applicationInfo.d.ts.map +1 -1
  17. package/dist/generic/applicationInfo.js +13 -9
  18. package/dist/generic/applicationInfo.js.map +1 -1
  19. package/dist/generic/baseEngine.d.ts +9 -1
  20. package/dist/generic/baseEngine.d.ts.map +1 -1
  21. package/dist/generic/baseEngine.js +13 -3
  22. package/dist/generic/baseEngine.js.map +1 -1
  23. package/dist/generic/baseEngineRegistry.d.ts +22 -9
  24. package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
  25. package/dist/generic/baseEngineRegistry.js +43 -25
  26. package/dist/generic/baseEngineRegistry.js.map +1 -1
  27. package/dist/generic/baseEntity.d.ts +422 -28
  28. package/dist/generic/baseEntity.d.ts.map +1 -1
  29. package/dist/generic/baseEntity.js +930 -202
  30. package/dist/generic/baseEntity.js.map +1 -1
  31. package/dist/generic/baseInfo.d.ts +12 -0
  32. package/dist/generic/baseInfo.d.ts.map +1 -1
  33. package/dist/generic/baseInfo.js +22 -1
  34. package/dist/generic/baseInfo.js.map +1 -1
  35. package/dist/generic/column-descriptors.d.ts +25 -5
  36. package/dist/generic/column-descriptors.d.ts.map +1 -1
  37. package/dist/generic/column-descriptors.js +45 -5
  38. package/dist/generic/column-descriptors.js.map +1 -1
  39. package/dist/generic/compositeKey.d.ts +1 -1
  40. package/dist/generic/compositeKey.js +2 -2
  41. package/dist/generic/data-table.js +2 -2
  42. package/dist/generic/data-table.js.map +1 -1
  43. package/dist/generic/databaseProviderBase.d.ts +13 -7
  44. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  45. package/dist/generic/databaseProviderBase.js +20 -11
  46. package/dist/generic/databaseProviderBase.js.map +1 -1
  47. package/dist/generic/entityConfiguration.d.ts +1 -0
  48. package/dist/generic/entityConfiguration.d.ts.map +1 -1
  49. package/dist/generic/entityConfiguration.js.map +1 -1
  50. package/dist/generic/entityInfo.d.ts +199 -39
  51. package/dist/generic/entityInfo.d.ts.map +1 -1
  52. package/dist/generic/entityInfo.js +351 -78
  53. package/dist/generic/entityInfo.js.map +1 -1
  54. package/dist/generic/entitySubtypeResolver.d.ts +51 -5
  55. package/dist/generic/entitySubtypeResolver.d.ts.map +1 -1
  56. package/dist/generic/entitySubtypeResolver.js +50 -3
  57. package/dist/generic/entitySubtypeResolver.js.map +1 -1
  58. package/dist/generic/filters/compositeFilter.d.ts +19 -19
  59. package/dist/generic/filters/compositeFilter.js +60 -60
  60. package/dist/generic/graphqlTypeNames.d.ts +6 -0
  61. package/dist/generic/graphqlTypeNames.d.ts.map +1 -1
  62. package/dist/generic/graphqlTypeNames.js +18 -6
  63. package/dist/generic/graphqlTypeNames.js.map +1 -1
  64. package/dist/generic/interfaces.d.ts +13 -4
  65. package/dist/generic/interfaces.d.ts.map +1 -1
  66. package/dist/generic/interfaces.js +3 -3
  67. package/dist/generic/interfaces.js.map +1 -1
  68. package/dist/generic/logging.d.ts +2 -0
  69. package/dist/generic/logging.d.ts.map +1 -1
  70. package/dist/generic/logging.js +7 -3
  71. package/dist/generic/logging.js.map +1 -1
  72. package/dist/generic/metadata.d.ts +1 -1
  73. package/dist/generic/metadata.js +3 -3
  74. package/dist/generic/providerBase.d.ts +89 -8
  75. package/dist/generic/providerBase.d.ts.map +1 -1
  76. package/dist/generic/providerBase.js +180 -45
  77. package/dist/generic/providerBase.js.map +1 -1
  78. package/dist/generic/queryResultEnricher.d.ts +2 -0
  79. package/dist/generic/queryResultEnricher.d.ts.map +1 -1
  80. package/dist/generic/queryResultEnricher.js +5 -1
  81. package/dist/generic/queryResultEnricher.js.map +1 -1
  82. package/dist/generic/querySQLFilters.d.ts +4 -0
  83. package/dist/generic/querySQLFilters.d.ts.map +1 -1
  84. package/dist/generic/querySQLFilters.js +10 -2
  85. package/dist/generic/querySQLFilters.js.map +1 -1
  86. package/dist/generic/relatedRecordCollection.d.ts +2 -0
  87. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  88. package/dist/generic/relatedRecordCollection.js +5 -1
  89. package/dist/generic/relatedRecordCollection.js.map +1 -1
  90. package/dist/generic/remoteEventRow.d.ts +55 -0
  91. package/dist/generic/remoteEventRow.d.ts.map +1 -0
  92. package/dist/generic/remoteEventRow.js +110 -0
  93. package/dist/generic/remoteEventRow.js.map +1 -0
  94. package/dist/generic/remoteOperationDispatch.d.ts +2 -0
  95. package/dist/generic/remoteOperationDispatch.d.ts.map +1 -1
  96. package/dist/generic/remoteOperationDispatch.js +5 -1
  97. package/dist/generic/remoteOperationDispatch.js.map +1 -1
  98. package/dist/generic/runQuerySQLFilterImplementations.d.ts +8 -0
  99. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  100. package/dist/generic/runQuerySQLFilterImplementations.js +20 -4
  101. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  102. package/dist/generic/schemaHydration.d.ts +8 -0
  103. package/dist/generic/schemaHydration.d.ts.map +1 -1
  104. package/dist/generic/schemaHydration.js +21 -5
  105. package/dist/generic/schemaHydration.js.map +1 -1
  106. package/dist/generic/securityInfo.d.ts +11 -9
  107. package/dist/generic/securityInfo.d.ts.map +1 -1
  108. package/dist/generic/securityInfo.js +31 -27
  109. package/dist/generic/securityInfo.js.map +1 -1
  110. package/dist/generic/telemetryManager.d.ts +16 -0
  111. package/dist/generic/telemetryManager.d.ts.map +1 -1
  112. package/dist/generic/telemetryManager.js +52 -20
  113. package/dist/generic/telemetryManager.js.map +1 -1
  114. package/dist/generic/transactionGroup.d.ts +1 -1
  115. package/dist/generic/transactionGroup.js +4 -4
  116. package/dist/generic/transactionGroup.js.map +1 -1
  117. package/dist/index.d.ts +1 -0
  118. package/dist/index.d.ts.map +1 -1
  119. package/dist/index.js +1 -0
  120. package/dist/index.js.map +1 -1
  121. package/dist/views/viewInfo.d.ts +2 -2
  122. package/dist/views/viewInfo.js +13 -13
  123. package/dist/views/viewInfo.js.map +1 -1
  124. package/package.json +3 -3
@@ -195,7 +195,20 @@ export class ProviderBase {
195
195
  this._metadataReloadInFlight = null;
196
196
  /** Set when a refresh request arrives while a reload is in flight; the reload loop reruns once. */
197
197
  this._metadataReloadQueued = false;
198
+ /** When the last SUCCESSFUL refresh check's status request completed; 0 = never. */
198
199
  this._lastRefreshCheckAt = 0;
200
+ /**
201
+ * The check currently running in {@link CheckToSeeIfRefreshNeeded}; null when none is. While
202
+ * set, a non-bypassing caller is answered `false` without a request — the owner of this check
203
+ * acts on its answer. Held as the promise (not a flag) so only its owner clears it.
204
+ */
205
+ this._refreshCheckInFlight = null;
206
+ /**
207
+ * Set when Config() adopts a snapshot it loaded from the server (which fetched the current user
208
+ * with it); consumed by the next {@link preValidateAndRefresh}, which then has nothing to check.
209
+ */
210
+ this._configLoadedFromServer = false;
211
+ this._lastMetadataLoadError = null;
199
212
  /**
200
213
  * Pending coalesced requests waiting to be flushed.
201
214
  * Each entry tracks the original params, contextUser, and a resolver
@@ -227,6 +240,12 @@ export class ProviderBase {
227
240
  * hardcoded anywhere in this mechanism.
228
241
  */
229
242
  this._metadataDatasetEntityNames = null;
243
+ /**
244
+ * True only while {@link RefreshWithinTransaction} is reloading. The metadata dataset is
245
+ * normally read on the pool (#4514); this window joins those reads to the caller's transaction
246
+ * so a push can see the metadata rows it has not committed yet (MJ#4836).
247
+ */
248
+ this._metadataReadsJoinTransaction = false;
230
249
  /** Debounce timer for {@link scheduleMetadataMemberRefresh}. */
231
250
  this._metadataMemberRefreshTimer = null;
232
251
  this._metadataMemberRefreshWaits = 0;
@@ -253,9 +272,22 @@ export class ProviderBase {
253
272
  * Minimum interval (ms) between metadata refresh checks to prevent
254
273
  * redundant network calls when Config()/RefreshIfNeeded() fire in
255
274
  * quick succession (e.g., multiple engines during startup).
275
+ * The throttle is armed only by a check whose status request succeeded — a check
276
+ * that threw or got no timestamps proved nothing, so the next caller checks again.
277
+ * A caller arriving while a check is in flight is told no refresh is needed, as the
278
+ * in-flight owner acts on the answer (see {@link CheckToSeeIfRefreshNeeded}).
256
279
  * Does NOT affect forced Refresh() calls. Default: 30 000 ms.
257
280
  */
258
281
  static { this.MinRefreshCheckIntervalMs = 30000; }
282
+ /**
283
+ * Why the most recent metadata download ({@link GetAllMetadata}) failed; null when it succeeded
284
+ * or none has run. GetAllMetadata logs and returns undefined on failure so a background refresh
285
+ * keeps the last good graph; a boot that ends with no metadata reads this to report the cause
286
+ * (e.g. a user with no roles cannot read `MJ: User Roles`) instead of a generic "nothing loaded".
287
+ */
288
+ get LastMetadataLoadError() {
289
+ return this._lastMetadataLoadError;
290
+ }
259
291
  // ── Server-Side Auto-Cache ────────────────────────────────────────
260
292
  /**
261
293
  * Maximum row count for auto-caching on the server side. When
@@ -453,6 +485,26 @@ export class ProviderBase {
453
485
  this._metadataDatasetEntityNames = names;
454
486
  this.ensureInflightViewInvalidation();
455
487
  }
488
+ /**
489
+ * Whether `entityName` is one of the entities this provider's metadata is built from.
490
+ * False when the membership set has not been loaded yet — a caller must not treat "unknown"
491
+ * as "reload". Names are compared case-insensitively, matching the set recorded by
492
+ * {@link registerMetadataDatasetMembership}.
493
+ */
494
+ IsMetadataDatasetMember(entityName) {
495
+ const name = entityName?.trim().toLowerCase();
496
+ if (!name || !this._metadataDatasetEntityNames) {
497
+ return false;
498
+ }
499
+ return this._metadataDatasetEntityNames.has(name);
500
+ }
501
+ /**
502
+ * True while {@link RefreshWithinTransaction} is running, so metadata-dataset reads join the
503
+ * ambient transaction instead of the pool.
504
+ */
505
+ get MetadataReadsJoinTransaction() {
506
+ return this._metadataReadsJoinTransaction;
507
+ }
456
508
  /**
457
509
  * Static fan-out callback: a BaseEntity save/delete (or a remote-invalidate from another
458
510
  * server) touched `lowerEntityName`. If that entity is one of the entities this provider's
@@ -876,8 +928,8 @@ export class ProviderBase {
876
928
  // CALLER's objects — reusing params across calls must be safe.
877
929
  params = params.map(p => ({ ...p }));
878
930
  // Bypass dedup for side-effect calls (SaveViewResults creates DB records)
879
- if (this.ShouldBypassDedup(params)) {
880
- return this.ExecuteRunViewsPipeline(params, contextUser);
931
+ if (this.shouldBypassDedup(params)) {
932
+ return this.executeRunViewsPipeline(params, contextUser);
881
933
  }
882
934
  // ── Coalescing: merge concurrent RunViews into one mega-batch ──
883
935
  if (ProviderBase.CoalesceWindowMs > 0 && !this.TrustLocalCacheCompletely) {
@@ -885,7 +937,7 @@ export class ProviderBase {
885
937
  // Server-side trusts its cache (no network calls), so coalescing adds no benefit.
886
938
  return this.enqueueCoalescedRunViews(params, contextUser);
887
939
  }
888
- const key = this.GenerateDedupKey(params, contextUser);
940
+ const key = this.generateDedupKey(params, contextUser);
889
941
  const existing = this._inflightViews.get(key);
890
942
  // ── Linger hit: resolved result still within the linger window ──
891
943
  if (existing?.resolvedResults && existing.resolvedAt) {
@@ -896,7 +948,7 @@ export class ProviderBase {
896
948
  message: `[Dedup] Linger hit for [${entities}] — returning cached result (age ${age}ms, window ${ProviderBase.DedupLingerMs}ms)`,
897
949
  verboseOnly: true
898
950
  });
899
- return existing.resolvedResults.map(r => this.ShallowCopyResult(r));
951
+ return existing.resolvedResults.map(r => this.shallowCopyResult(r));
900
952
  }
901
953
  // Linger expired — fall through to fresh execution
902
954
  this._inflightViews.delete(key);
@@ -909,10 +961,10 @@ export class ProviderBase {
909
961
  verboseOnly: true
910
962
  });
911
963
  const results = await existing.promise;
912
- return results.map(r => this.ShallowCopyResult(r));
964
+ return results.map(r => this.shallowCopyResult(r));
913
965
  }
914
966
  // ── Fresh execution ──
915
- const promise = this.ExecuteRunViewsPipeline(params, contextUser)
967
+ const promise = this.executeRunViewsPipeline(params, contextUser)
916
968
  .then(results => {
917
969
  // Stash resolved results for the linger window. Safety cap: under
918
970
  // extreme churn (hundreds of distinct keys resolving within one linger
@@ -948,14 +1000,14 @@ export class ProviderBase {
948
1000
  this.ensureInflightViewInvalidation();
949
1001
  this._inflightViews.set(key, { promise, entityNames: this.collectParamEntityNames(params) });
950
1002
  const results = await promise;
951
- return results.map(r => this.ShallowCopyResult(r));
1003
+ return results.map(r => this.shallowCopyResult(r));
952
1004
  }
953
1005
  // ── Dedup helpers ──────────────────────────────────────────────────
954
1006
  /**
955
1007
  * The original RunViews execution pipeline (pre-processing, cache,
956
1008
  * internal execution, post-processing).
957
1009
  */
958
- async ExecuteRunViewsPipeline(params, contextUser) {
1010
+ async executeRunViewsPipeline(params, contextUser) {
959
1011
  // Pre-processing for batch
960
1012
  const preResult = await this.PreRunViews(params, contextUser);
961
1013
  // Check for smart cache check mode
@@ -1027,7 +1079,7 @@ export class ProviderBase {
1027
1079
  if (queue.length === 1) {
1028
1080
  const entry = queue[0];
1029
1081
  try {
1030
- const results = await this.RunViewsUncoalesced(entry.params, entry.contextUser);
1082
+ const results = await this.runViewsUncoalesced(entry.params, entry.contextUser);
1031
1083
  entry.resolve(results);
1032
1084
  }
1033
1085
  catch (err) {
@@ -1047,7 +1099,7 @@ export class ProviderBase {
1047
1099
  for (const entry of queue) {
1048
1100
  const entryIndices = [];
1049
1101
  for (const param of entry.params) {
1050
- const key = this.GenerateDedupKey([param], contextUser);
1102
+ const key = this.generateDedupKey([param], contextUser);
1051
1103
  let idx = uniqueKeys.get(key);
1052
1104
  if (idx === undefined) {
1053
1105
  idx = uniqueParams.length;
@@ -1076,12 +1128,12 @@ export class ProviderBase {
1076
1128
  });
1077
1129
  try {
1078
1130
  // Execute the deduplicated mega-batch as a single pipeline call
1079
- const uniqueResults = await this.RunViewsUncoalesced(uniqueParams, contextUser);
1131
+ const uniqueResults = await this.runViewsUncoalesced(uniqueParams, contextUser);
1080
1132
  // Route deduped results back to each original caller, preserving order.
1081
1133
  // ShallowCopyResult gives each caller an independent Results array (rows
1082
1134
  // are still shared refs; callers should not mutate rows in place).
1083
1135
  for (let i = 0; i < queue.length; i++) {
1084
- const callerResults = callerIndexMaps[i].map(idx => this.ShallowCopyResult(uniqueResults[idx]));
1136
+ const callerResults = callerIndexMaps[i].map(idx => this.shallowCopyResult(uniqueResults[idx]));
1085
1137
  queue[i].resolve(callerResults);
1086
1138
  }
1087
1139
  TelemetryManager.Instance.EndEvent(eventId);
@@ -1208,24 +1260,24 @@ export class ProviderBase {
1208
1260
  const textField = entity.Fields.find(f => f.Type.toLowerCase().includes('varchar') || f.Type.toLowerCase().includes('text'));
1209
1261
  return textField?.Name ?? entity.FirstPrimaryKey.Name; // first-pk-ok: display-column fallback for FTS results, not a key construction
1210
1262
  }
1211
- async RunViewsUncoalesced(params, contextUser) {
1212
- const key = this.GenerateDedupKey(params, contextUser);
1263
+ async runViewsUncoalesced(params, contextUser) {
1264
+ const key = this.generateDedupKey(params, contextUser);
1213
1265
  const existing = this._inflightViews.get(key);
1214
1266
  // ── Linger hit ──
1215
1267
  if (existing?.resolvedResults && existing.resolvedAt) {
1216
1268
  const age = Date.now() - existing.resolvedAt;
1217
1269
  if (age < ProviderBase.DedupLingerMs) {
1218
- return existing.resolvedResults.map(r => this.ShallowCopyResult(r));
1270
+ return existing.resolvedResults.map(r => this.shallowCopyResult(r));
1219
1271
  }
1220
1272
  this._inflightViews.delete(key);
1221
1273
  }
1222
1274
  // ── In-flight hit ──
1223
1275
  if (existing && !existing.resolvedResults) {
1224
1276
  const results = await existing.promise;
1225
- return results.map(r => this.ShallowCopyResult(r));
1277
+ return results.map(r => this.shallowCopyResult(r));
1226
1278
  }
1227
1279
  // ── Fresh execution ──
1228
- const promise = this.ExecuteRunViewsPipeline(params, contextUser)
1280
+ const promise = this.executeRunViewsPipeline(params, contextUser)
1229
1281
  .then(results => {
1230
1282
  const entry = this._inflightViews.get(key);
1231
1283
  if (entry && entry.promise === promise) {
@@ -1252,7 +1304,7 @@ export class ProviderBase {
1252
1304
  this.ensureInflightViewInvalidation();
1253
1305
  this._inflightViews.set(key, { promise, entityNames: this.collectParamEntityNames(params) });
1254
1306
  const results = await promise;
1255
- return results.map(r => this.ShallowCopyResult(r));
1307
+ return results.map(r => this.shallowCopyResult(r));
1256
1308
  }
1257
1309
  /**
1258
1310
  * Generates a deterministic dedup key for a batch of RunViewParams.
@@ -1384,11 +1436,11 @@ export class ProviderBase {
1384
1436
  }
1385
1437
  return result;
1386
1438
  }
1387
- GenerateDedupKey(params, contextUser) {
1439
+ generateDedupKey(params, contextUser) {
1388
1440
  const parts = params.map(p => {
1389
1441
  const base = LocalCacheManager.Instance.GenerateRunViewFingerprint(p, this.InstanceConnectionString);
1390
1442
  const extras = [
1391
- ProviderBase.NormalizeFieldsKey(p.Fields),
1443
+ ProviderBase.normalizeFieldsKey(p.Fields),
1392
1444
  p.ResultType ?? 'simple',
1393
1445
  p.UserSearchString ?? '',
1394
1446
  p.ViewID ?? '',
@@ -1406,7 +1458,7 @@ export class ProviderBase {
1406
1458
  * semantically identical requests collapse to the same key. Used by both the
1407
1459
  * request-dedup key and the client-side cache fingerprint.
1408
1460
  */
1409
- static NormalizeFieldsKey(fields) {
1461
+ static normalizeFieldsKey(fields) {
1410
1462
  return fields && fields.length > 0
1411
1463
  ? fields.map(f => f.trim().toLowerCase()).sort().join(',')
1412
1464
  : '*';
@@ -1460,7 +1512,7 @@ export class ProviderBase {
1460
1512
  // This touches ONLY how the slot is keyed. param.Fields is left untouched, so what the
1461
1513
  // provider FETCHES is unchanged — an earlier attempt cleared param.Fields instead and was
1462
1514
  // reverted precisely because fetch behavior could not be verified.
1463
- const fieldsKey = this.isFullCoverageFieldList(param) ? '*' : ProviderBase.NormalizeFieldsKey(param.Fields);
1515
+ const fieldsKey = this.isFullCoverageFieldList(param) ? '*' : ProviderBase.normalizeFieldsKey(param.Fields);
1464
1516
  const fingerprint = `${base}|f:${fieldsKey}`;
1465
1517
  this._clientFingerprintMemo.set(param, fingerprint);
1466
1518
  return fingerprint;
@@ -1747,7 +1799,7 @@ export class ProviderBase {
1747
1799
  * which means the call has a side effect (creating UserViewRun records)
1748
1800
  * and must not be deduplicated.
1749
1801
  */
1750
- ShouldBypassDedup(params) {
1802
+ shouldBypassDedup(params) {
1751
1803
  // BypassCache:true MUST bypass the dedup-linger cache as well —
1752
1804
  // otherwise the "skip the cache to see DB truth" contract leaks: a
1753
1805
  // recent identical RunView (within DedupLingerMs) would return its
@@ -1764,7 +1816,7 @@ export class ProviderBase {
1764
1816
  * new array instance (protecting against push/sort/splice by other
1765
1817
  * callers) but the individual row objects inside are shared references.
1766
1818
  */
1767
- ShallowCopyResult(result) {
1819
+ shallowCopyResult(result) {
1768
1820
  return {
1769
1821
  ...result,
1770
1822
  Results: [...result.Results]
@@ -2330,7 +2382,7 @@ export class ProviderBase {
2330
2382
  if (denied.size === 0) {
2331
2383
  return rows;
2332
2384
  }
2333
- return ProviderBase.OmitFieldsFromRows(rows, denied);
2385
+ return ProviderBase.omitFieldsFromRows(rows, denied);
2334
2386
  }
2335
2387
  /**
2336
2388
  * Row-loop half of {@link ApplyFieldSecurityProjection}, split out so the policy above reads
@@ -2340,7 +2392,7 @@ export class ProviderBase {
2340
2392
  *
2341
2393
  * @param deniedLowercase field names to omit, already lowercased
2342
2394
  */
2343
- static OmitFieldsFromRows(rows, deniedLowercase) {
2395
+ static omitFieldsFromRows(rows, deniedLowercase) {
2344
2396
  const probe = rows[0];
2345
2397
  if (!probe || typeof probe !== 'object') {
2346
2398
  return rows;
@@ -3879,6 +3931,9 @@ export class ProviderBase {
3879
3931
  */
3880
3932
  async Config(data, providerToUse) {
3881
3933
  this._ConfigData = data;
3934
+ // Describes THIS Config only: a flag left by an earlier Refresh() must not make the
3935
+ // pre-validation that follows a Config which loaded nothing skip its check.
3936
+ this._configLoadedFromServer = false;
3882
3937
  // Initialize LocalCacheManager early so dataset loading can use the cache.
3883
3938
  // Initialize() is idempotent — subsequent calls (e.g. from StartupManager) are no-ops.
3884
3939
  if (!LocalCacheManager.Instance.IsInitialized) {
@@ -3924,7 +3979,12 @@ export class ProviderBase {
3924
3979
  return true; // App can proceed immediately with cached metadata
3925
3980
  }
3926
3981
  }
3927
- if (hardRefresh || await this.CheckToSeeIfRefreshNeeded(providerToUse)) {
3982
+ // An empty graph has nothing to throttle: the throttle exists to stop redundant checks of a
3983
+ // snapshot we already hold. After a status check succeeded but the download that followed
3984
+ // failed, the throttle is armed and a non-bypassing retry would read "current" and load
3985
+ // nothing for the whole window (#4887).
3986
+ const graphIsEmpty = !this._localMetadata?.AllEntities?.length;
3987
+ if (hardRefresh || await this.CheckToSeeIfRefreshNeeded(providerToUse, graphIsEmpty)) {
3928
3988
  // either a hard refresh flag was set within Refresh(), or LocalMetadata is Obsolete
3929
3989
  // first, make sure we reset the flag to false so that if another call to this function happens
3930
3990
  // while we are waiting for the async call to finish, we dont do it again
@@ -3963,13 +4023,8 @@ export class ProviderBase {
3963
4023
  const end = new Date().getTime();
3964
4024
  LogStatusEx({ message: `GetAllMetadata() took ${end - start} ms`, verboseOnly: true });
3965
4025
  if (res) {
3966
- // Atomic swap via UpdateLocalMetadata: single property assignment is atomic in JavaScript
3967
- // Readers now see new metadata instead of old
3968
- // Uses UpdateLocalMetadata() to maintain consistency with LoadLocalMetadataFromStorage()
3969
- // and allow potential subclass overrides for extensibility
3970
- this.UpdateLocalMetadata(res);
3971
- this._latestLocalMetadataTimestamps = this._latestRemoteMetadataTimestamps; // update this since we just used server to get all the stuff
3972
- await this.SaveLocalMetadataToStorage();
4026
+ await this.adoptServerMetadata(res);
4027
+ this._configLoadedFromServer = true;
3973
4028
  }
3974
4029
  else {
3975
4030
  // GetAllMetadata failed - log error but keep existing metadata
@@ -3989,6 +4044,19 @@ export class ProviderBase {
3989
4044
  }
3990
4045
  return true;
3991
4046
  }
4047
+ /**
4048
+ * Adopts a metadata snapshot just loaded from the server: swaps it in, records that the
4049
+ * local copy now matches the server's timestamps, and persists it to local storage.
4050
+ *
4051
+ * The swap goes through UpdateLocalMetadata — a single property assignment, atomic in
4052
+ * JavaScript, so readers see the old snapshot until the new one is complete — which keeps
4053
+ * it consistent with LoadLocalMetadataFromStorage() and lets subclasses override it.
4054
+ */
4055
+ async adoptServerMetadata(res) {
4056
+ this.UpdateLocalMetadata(res);
4057
+ this._latestLocalMetadataTimestamps = this._latestRemoteMetadataTimestamps; // we just used the server to get all the stuff
4058
+ await this.SaveLocalMetadataToStorage();
4059
+ }
3992
4060
  /**
3993
4061
  * Background validation for the stale-while-revalidate fast-start pattern.
3994
4062
  * Checks if local metadata is still current; if stale, fetches fresh metadata
@@ -4004,9 +4072,7 @@ export class ProviderBase {
4004
4072
  const res = await this.GetAllMetadata(providerToUse, false);
4005
4073
  const elapsed = Date.now() - start;
4006
4074
  if (res) {
4007
- this.UpdateLocalMetadata(res);
4008
- this._latestLocalMetadataTimestamps = this._latestRemoteMetadataTimestamps;
4009
- await this.SaveLocalMetadataToStorage();
4075
+ await this.adoptServerMetadata(res);
4010
4076
  LogStatusEx({ message: `⚡ [Metadata Cache] Background refresh complete (${elapsed}ms) — metadata updated in place`, verboseOnly: false });
4011
4077
  }
4012
4078
  }
@@ -4037,9 +4103,22 @@ export class ProviderBase {
4037
4103
  * on RTT). On the warm-stale path we additionally pay the full metadata fetch but
4038
4104
  * avoid serving stale data to the UI in the first place.
4039
4105
  *
4106
+ * On a cold boot, where Config() just loaded the graph (and the current user) from the
4107
+ * server, the first call after that load returns immediately — no check, no current-user
4108
+ * re-fetch. Later calls, and calls after a background refresh, check as usual.
4109
+ *
4040
4110
  * Caller contract: invoke this before `StartupManager.Startup()`.
4041
4111
  */
4042
4112
  async preValidateAndRefresh(providerToUse) {
4113
+ // The snapshot Config just loaded from the server (a cold boot) is current by construction,
4114
+ // and GetAllMetadata fetched CurrentUser with it. Checking again would hit the refresh
4115
+ // throttle, read "current", and re-fetch the same user serially (#4887). One-shot: only the
4116
+ // first pre-validation after that load skips.
4117
+ if (this._configLoadedFromServer) {
4118
+ this._configLoadedFromServer = false;
4119
+ LogStatusEx({ message: `⚡ [Metadata Cache] Pre-validation: metadata was just loaded from the server — skipping`, verboseOnly: true });
4120
+ return;
4121
+ }
4043
4122
  try {
4044
4123
  const needsRefresh = await this.CheckToSeeIfRefreshNeeded(providerToUse);
4045
4124
  if (needsRefresh) {
@@ -4048,9 +4127,7 @@ export class ProviderBase {
4048
4127
  const res = await this.GetAllMetadata(providerToUse, false);
4049
4128
  const elapsed = Date.now() - start;
4050
4129
  if (res) {
4051
- this.UpdateLocalMetadata(res);
4052
- this._latestLocalMetadataTimestamps = this._latestRemoteMetadataTimestamps;
4053
- await this.SaveLocalMetadataToStorage();
4130
+ await this.adoptServerMetadata(res);
4054
4131
  LogStatusEx({ message: `⚡ [Metadata Cache] Pre-validation refresh complete (${elapsed}ms)`, verboseOnly: false });
4055
4132
  }
4056
4133
  }
@@ -4213,7 +4290,7 @@ export class ProviderBase {
4213
4290
  const entitiesItem = d?.Success ? d.Results?.find(r => r.Code === 'Entities') : undefined;
4214
4291
  const hasEntities = Array.isArray(entitiesItem?.Results) && entitiesItem.Results.length > 0;
4215
4292
  if (d && d.Success && !hasEntities) {
4216
- LogError(`GetAllMetadata() - the ${ProviderBase._mjMetadataDatasetName} dataset returned no entities; keeping the metadata already loaded`);
4293
+ this.recordMetadataLoadFailure(new Error(`GetAllMetadata() - the ${ProviderBase._mjMetadataDatasetName} dataset returned no entities; keeping the metadata already loaded`));
4217
4294
  }
4218
4295
  else if (d && d.Success) {
4219
4296
  // cache the dataset for anyone who wants to use it
@@ -4241,16 +4318,23 @@ export class ProviderBase {
4241
4318
  // build the base return type
4242
4319
  const returnMetadata = MetadataFromSimpleObjectWithoutUser(simpleMetadata, this);
4243
4320
  returnMetadata.CurrentUser = await this.GetCurrentUser();
4321
+ this._lastMetadataLoadError = null;
4244
4322
  return returnMetadata;
4245
4323
  }
4246
4324
  else {
4247
- LogError('GetAllMetadata() - Error getting metadata from server' + (d ? ': ' + d.Status : ''));
4325
+ this.recordMetadataLoadFailure(new Error('GetAllMetadata() - Error getting metadata from server' + (d ? ': ' + d.Status : '')));
4248
4326
  }
4249
4327
  }
4250
4328
  catch (e) {
4251
4329
  LogError(e);
4330
+ this._lastMetadataLoadError = e instanceof Error ? e : new Error(String(e));
4252
4331
  }
4253
4332
  }
4333
+ /** Logs a failed metadata download and keeps it for {@link LastMetadataLoadError}. */
4334
+ recordMetadataLoadFailure(error) {
4335
+ LogError(error.message);
4336
+ this._lastMetadataLoadError = error;
4337
+ }
4254
4338
  /**
4255
4339
  * Refreshes the CurrentUser from the server and updates local metadata in place.
4256
4340
  * Useful on warm boot or when user roles/permissions change dynamically without
@@ -4524,6 +4608,24 @@ export class ProviderBase {
4524
4608
  else
4525
4609
  return true; // subclass is telling us not to do any refresh ops right now
4526
4610
  }
4611
+ /**
4612
+ * The same hard reload as {@link Refresh}, but the metadata dataset's reads join the ambient
4613
+ * transaction for the duration of the call (MJ#4836).
4614
+ *
4615
+ * A timer-driven refresh must stay on the pool so it cannot land beside COMMIT (#4514).
4616
+ * The caller that owns the transaction — `mj sync push`, between its own writes — is the
4617
+ * one case that has to see its uncommitted metadata rows, and it waits for this call, so
4618
+ * the read cannot race the commit.
4619
+ */
4620
+ async RefreshWithinTransaction(providerToUse) {
4621
+ this._metadataReadsJoinTransaction = true;
4622
+ try {
4623
+ return await this.Refresh(providerToUse);
4624
+ }
4625
+ finally {
4626
+ this._metadataReadsJoinTransaction = false;
4627
+ }
4628
+ }
4527
4629
  /**
4528
4630
  * Checks if local metadata is out of date and needs refreshing.
4529
4631
  * Compares local timestamps with server timestamps.
@@ -4531,12 +4633,28 @@ export class ProviderBase {
4531
4633
  * throttle. Event-driven callers pass true: they hold positive evidence that a metadata
4532
4634
  * member entity was just written, and the throttle otherwise answers "fresh" for any check
4533
4635
  * arriving within the window of the previous one — which would silently drop the second of
4534
- * two permission changes made less than the window apart.
4636
+ * two permission changes made less than the window apart. For the same reason a bypassing
4637
+ * caller always runs its own check, even while another is in flight: that check may have
4638
+ * started before the write.
4639
+ *
4640
+ * The throttle is armed only when the status request succeeded (returned timestamps). A
4641
+ * check that throws or returns no timestamps leaves it unarmed, so a retried boot inside
4642
+ * the window really checks instead of reading "current" and loading nothing (#4887).
4643
+ * A non-bypassing caller arriving while a check is in flight is told no refresh is needed,
4644
+ * as the in-flight owner acts on the answer; that keeps N engines starting together down to
4645
+ * one request. It must not share the owner's answer: a shared `true` would send every caller
4646
+ * into its own full metadata reload (preValidateAndRefresh / backgroundValidateAndRefresh /
4647
+ * RefreshIfNeeded load outside Config's reload single-flight). For the same reason it gets
4648
+ * `false`, not the owner's rejection, when the owner's check throws.
4535
4649
  * @returns True if refresh is needed, false otherwise
4536
4650
  */
4537
4651
  async CheckToSeeIfRefreshNeeded(providerToUse, bypassMinCheckInterval) {
4538
4652
  if (!this.AllowRefresh)
4539
4653
  return false;
4654
+ if (!bypassMinCheckInterval && this._refreshCheckInFlight) {
4655
+ LogStatusEx({ message: `[RefreshCheck] Skipped — a check is already in flight; its caller acts on the answer`, verboseOnly: true });
4656
+ return false;
4657
+ }
4540
4658
  const now = Date.now();
4541
4659
  if (!bypassMinCheckInterval && (now - this._lastRefreshCheckAt) < ProviderBase.MinRefreshCheckIntervalMs) {
4542
4660
  LogStatusEx({
@@ -4545,8 +4663,25 @@ export class ProviderBase {
4545
4663
  });
4546
4664
  return false;
4547
4665
  }
4548
- this._lastRefreshCheckAt = now;
4549
- await this.RefreshRemoteMetadataTimestamps(providerToUse);
4666
+ const check = this.runRefreshCheck(providerToUse);
4667
+ this._refreshCheckInFlight = check;
4668
+ try {
4669
+ return await check;
4670
+ }
4671
+ finally {
4672
+ // A bypassing caller may have replaced the slot with its own newer check while this
4673
+ // one ran; only the check that still owns the slot clears it.
4674
+ if (this._refreshCheckInFlight === check) {
4675
+ this._refreshCheckInFlight = null;
4676
+ }
4677
+ }
4678
+ }
4679
+ /** One refresh check: fetch remote timestamps, arm the throttle only on success, compare. */
4680
+ async runRefreshCheck(providerToUse) {
4681
+ const gotTimestamps = await this.RefreshRemoteMetadataTimestamps(providerToUse);
4682
+ if (gotTimestamps) {
4683
+ this._lastRefreshCheckAt = Date.now();
4684
+ }
4550
4685
  await this.LoadLocalMetadataFromStorage();
4551
4686
  return this.LocalMetadataObsolete();
4552
4687
  }