@memberjunction/core 6.2.0-edge.1 → 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 (29) 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/baseEntity.d.ts +201 -11
  8. package/dist/generic/baseEntity.d.ts.map +1 -1
  9. package/dist/generic/baseEntity.js +392 -89
  10. package/dist/generic/baseEntity.js.map +1 -1
  11. package/dist/generic/databaseProviderBase.d.ts +13 -7
  12. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  13. package/dist/generic/databaseProviderBase.js +18 -9
  14. package/dist/generic/databaseProviderBase.js.map +1 -1
  15. package/dist/generic/entityConfiguration.d.ts +1 -0
  16. package/dist/generic/entityConfiguration.d.ts.map +1 -1
  17. package/dist/generic/entityConfiguration.js.map +1 -1
  18. package/dist/generic/entityInfo.d.ts +28 -1
  19. package/dist/generic/entityInfo.d.ts.map +1 -1
  20. package/dist/generic/entityInfo.js +37 -0
  21. package/dist/generic/entityInfo.js.map +1 -1
  22. package/dist/generic/interfaces.d.ts +12 -3
  23. package/dist/generic/interfaces.d.ts.map +1 -1
  24. package/dist/generic/interfaces.js.map +1 -1
  25. package/dist/generic/providerBase.d.ts +82 -1
  26. package/dist/generic/providerBase.d.ts.map +1 -1
  27. package/dist/generic/providerBase.js +154 -19
  28. package/dist/generic/providerBase.js.map +1 -1
  29. 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
@@ -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
  }