@memberjunction/core 5.51.2 → 5.51.4

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 (51) hide show
  1. package/dist/generic/InMemoryLocalStorageProvider.d.ts +5 -0
  2. package/dist/generic/InMemoryLocalStorageProvider.d.ts.map +1 -1
  3. package/dist/generic/InMemoryLocalStorageProvider.js +5 -0
  4. package/dist/generic/InMemoryLocalStorageProvider.js.map +1 -1
  5. package/dist/generic/baseEngine.d.ts +24 -0
  6. package/dist/generic/baseEngine.d.ts.map +1 -1
  7. package/dist/generic/baseEngine.js +54 -2
  8. package/dist/generic/baseEngine.js.map +1 -1
  9. package/dist/generic/baseEntity.d.ts +35 -0
  10. package/dist/generic/baseEntity.d.ts.map +1 -1
  11. package/dist/generic/baseEntity.js +66 -13
  12. package/dist/generic/baseEntity.js.map +1 -1
  13. package/dist/generic/databaseProviderBase.d.ts +41 -8
  14. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  15. package/dist/generic/databaseProviderBase.js +67 -30
  16. package/dist/generic/databaseProviderBase.js.map +1 -1
  17. package/dist/generic/entityInfo.d.ts +18 -2
  18. package/dist/generic/entityInfo.d.ts.map +1 -1
  19. package/dist/generic/entityInfo.js +26 -10
  20. package/dist/generic/entityInfo.js.map +1 -1
  21. package/dist/generic/interfaces.d.ts +49 -2
  22. package/dist/generic/interfaces.d.ts.map +1 -1
  23. package/dist/generic/interfaces.js.map +1 -1
  24. package/dist/generic/localCacheManager.d.ts +1 -1
  25. package/dist/generic/localCacheManager.d.ts.map +1 -1
  26. package/dist/generic/localCacheManager.js +15 -1
  27. package/dist/generic/localCacheManager.js.map +1 -1
  28. package/dist/generic/providerBase.d.ts +174 -8
  29. package/dist/generic/providerBase.d.ts.map +1 -1
  30. package/dist/generic/providerBase.js +406 -33
  31. package/dist/generic/providerBase.js.map +1 -1
  32. package/dist/generic/remoteEventRow.d.ts +55 -0
  33. package/dist/generic/remoteEventRow.d.ts.map +1 -0
  34. package/dist/generic/remoteEventRow.js +110 -0
  35. package/dist/generic/remoteEventRow.js.map +1 -0
  36. package/dist/generic/runQuery.d.ts +18 -1
  37. package/dist/generic/runQuery.d.ts.map +1 -1
  38. package/dist/generic/runQuery.js.map +1 -1
  39. package/dist/generic/runQuerySQLFilterImplementations.d.ts +7 -1
  40. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  41. package/dist/generic/runQuerySQLFilterImplementations.js +64 -139
  42. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  43. package/dist/generic/telemetryManager.d.ts +11 -0
  44. package/dist/generic/telemetryManager.d.ts.map +1 -1
  45. package/dist/generic/telemetryManager.js +40 -0
  46. package/dist/generic/telemetryManager.js.map +1 -1
  47. package/dist/index.d.ts +1 -0
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +1 -0
  50. package/dist/index.js.map +1 -1
  51. package/package.json +3 -3
@@ -179,6 +179,10 @@ export class ProviderBase {
179
179
  // it can't be reset on metadata refresh; it needs its own eviction policy.
180
180
  this._entityRecordNameCache = new MJLruCache({ maxSize: 10000, ttlMs: 60 * 60 * 1000 });
181
181
  this._refresh = false;
182
+ /** Single-flight guard for the full metadata reload in {@link Config} — see the comment there. */
183
+ this._metadataReloadInFlight = null;
184
+ /** Set when a refresh request arrives while a reload is in flight; the reload loop reruns once. */
185
+ this._metadataReloadQueued = false;
182
186
  this._lastRefreshCheckAt = 0;
183
187
  /**
184
188
  * Pending coalesced requests waiting to be flushed.
@@ -203,8 +207,20 @@ export class ProviderBase {
203
207
  */
204
208
  this._inflightViews = new Map();
205
209
  this._lingerInvalidationWired = false;
210
+ /**
211
+ * Lowercased names of the entities whose rows COMPOSE this provider's metadata — the
212
+ * MJ_Metadata dataset's item entities, recorded each time the dataset is loaded (and restored
213
+ * from local storage on warm boot). Membership is DATA: adding a `DatasetItem` row to
214
+ * MJ_Metadata extends refresh coverage with no code change, which is why no entity names are
215
+ * hardcoded anywhere in this mechanism.
216
+ */
217
+ this._metadataDatasetEntityNames = null;
218
+ /** Debounce timer for {@link scheduleMetadataMemberRefresh}. */
219
+ this._metadataMemberRefreshTimer = null;
206
220
  this._clientFingerprintMemo = new WeakMap();
207
221
  this._cachedVisibleExplorerNavigationItems = null;
222
+ /** Guard for {@link logMetadataSnapshotDisabledOnce}. */
223
+ this._metadataSnapshotSkipLogged = false;
208
224
  }
209
225
  // ── Metadata Refresh Check Debounce ────────────────────────────────
210
226
  /**
@@ -257,19 +273,56 @@ export class ProviderBase {
257
273
  * instead of accumulating result arrays in memory.
258
274
  */
259
275
  static { this.MaxLingerEntries = 500; }
276
+ // ── Write-invalidation fan-out (process-wide, not per-instance) ────
260
277
  /**
261
- * Lazily subscribes (once per provider instance) to BaseEntity events so
262
- * in-flight/lingered RunView entries touching a written entity are dropped.
263
- * Deleting an IN-FLIGHT entry is deliberate: late joiners must not share a
264
- * query that may have read pre-commit data; the original caller still gets
265
- * its own result, and the orphaned promise's stash step no-ops safely.
278
+ * Every live provider instance that has opted into write-invalidation, held via
279
+ * `WeakRef` so registering here never keeps a provider (and everything it
280
+ * references — connection pool, entity metadata, `_inflightViews`) reachable.
281
+ * Short-lived providers (one per GraphQL request, one per durable task-graph
282
+ * task execution — see `MJServer/context.ts` and `TaskGraphProviderFactory`)
283
+ * become eligible for GC as soon as the request/task finishes; dead refs are
284
+ * pruned lazily the next time an entity event fires.
266
285
  */
267
- ensureInflightViewInvalidation() {
268
- if (this._lingerInvalidationWired) {
286
+ static { this._invalidationTargets = new Set(); }
287
+ /**
288
+ * The exact event-bus Observable the fan-out is currently subscribed to — identity, not a
289
+ * boolean "already wired" flag. `MJGlobal.GetEventListener(false)` returns `MJGlobal._events$`,
290
+ * and BOTH `MJGlobal.Reset()` and `resetMJSingletons()` (which drops the singleton out of the
291
+ * global object store entirely) replace that field with a fresh `Subject`. A boolean cannot
292
+ * tell that the bus underneath it changed, so it would leave the fan-out attached to the
293
+ * orphaned Subject for the rest of the process — no error, just linger entries that silently
294
+ * stop being invalidated. Comparing the reference detects the swap exactly.
295
+ */
296
+ static { this._wiredBus = null; }
297
+ /**
298
+ * The live fan-out subscription, retained so a bus swap can unsubscribe the stale one.
299
+ * Without this, every swap strands a subscriber on the dead Subject — a small instance of
300
+ * the very leak this fan-out exists to prevent.
301
+ */
302
+ static { this._busSubscription = null; }
303
+ /**
304
+ * Subscribes exactly ONCE PER EVENT BUS (not once per provider instance) to BaseEntity
305
+ * events and fans each one out to every still-live registered provider. This is what keeps
306
+ * the number of `MJGlobal` event-bus subscribers constant regardless of how many short-lived
307
+ * provider instances get created — subscribing per-instance (the original implementation)
308
+ * permanently pinned one subscription per instance on the process-wide `Subject`, which never
309
+ * releases subscribers on its own, so a fresh provider minted on every GraphQL request (or
310
+ * every durable task-graph task) leaked its entire object graph forever.
311
+ *
312
+ * Keyed on the bus's identity rather than a one-shot boolean, because the bus is a
313
+ * replaceable slot and not a process constant. See {@link _wiredBus}.
314
+ */
315
+ static ensureStaticInvalidationSubscription() {
316
+ const bus = MJGlobal.Instance.GetEventListener(false);
317
+ if (ProviderBase._wiredBus === bus) {
269
318
  return;
270
319
  }
271
- this._lingerInvalidationWired = true;
272
- MJGlobal.Instance.GetEventListener(false).subscribe((event) => {
320
+ // The bus was replaced (MJGlobal.Reset / singleton clear). Drop the subscriber stranded on
321
+ // the dead Subject, then attach to the live one. Assign _wiredBus BEFORE subscribing so a
322
+ // re-entrant call cannot double-wire.
323
+ ProviderBase._busSubscription?.unsubscribe();
324
+ ProviderBase._wiredBus = bus;
325
+ ProviderBase._busSubscription = bus.subscribe((event) => {
273
326
  if (event.event !== MJEventType.ComponentEvent || event.eventCode !== BaseEntity.BaseEventCode) {
274
327
  return;
275
328
  }
@@ -281,11 +334,42 @@ export class ProviderBase {
281
334
  return;
282
335
  }
283
336
  const entityName = (entityEvent.baseEntity?.EntityInfo?.Name ?? entityEvent.entityName)?.trim().toLowerCase();
284
- if (entityName) {
285
- this.invalidateInflightViewsForEntity(entityName);
337
+ if (!entityName) {
338
+ return;
339
+ }
340
+ for (const ref of ProviderBase._invalidationTargets) {
341
+ const instance = ref.deref();
342
+ if (!instance) {
343
+ // Provider was GC'd since it registered — prune the dead ref instead of
344
+ // letting the registry grow forever with entries that resolve to nothing.
345
+ ProviderBase._invalidationTargets.delete(ref);
346
+ continue;
347
+ }
348
+ instance.invalidateInflightViewsForEntity(entityName);
349
+ instance.handleMetadataMemberEntityEvent(entityName, entityEvent);
286
350
  }
287
351
  });
288
352
  }
353
+ /**
354
+ * Lazily registers this instance (once per provider instance) for write-invalidation
355
+ * of in-flight/lingered RunView entries touching a written entity. Deleting an IN-FLIGHT
356
+ * entry is deliberate: late joiners must not share a query that may have read pre-commit
357
+ * data; the original caller still gets its own result, and the orphaned promise's stash
358
+ * step no-ops safely.
359
+ */
360
+ ensureInflightViewInvalidation() {
361
+ // Deliberately ABOVE the per-instance guard: the static wiring is re-checked on every call,
362
+ // not just the first. A long-lived provider (Explorer's client provider, MJServer's system
363
+ // provider) that registered before a bus swap would otherwise stay wired to the dead Subject
364
+ // for the rest of the process — short-lived server providers self-heal via the next
365
+ // request's fresh instance, long-lived ones never would.
366
+ ProviderBase.ensureStaticInvalidationSubscription();
367
+ if (this._lingerInvalidationWired) {
368
+ return;
369
+ }
370
+ this._lingerInvalidationWired = true;
371
+ ProviderBase._invalidationTargets.add(new WeakRef(this));
372
+ }
289
373
  /**
290
374
  * Drops every in-flight/lingered RunView entry whose params touch the given
291
375
  * entity (lowercased name). Called on BaseEntity save/delete/remote-invalidate.
@@ -313,6 +397,155 @@ export class ProviderBase {
313
397
  }
314
398
  return names;
315
399
  }
400
+ // ── Metadata-Dataset Membership Refresh ────────────────────────────
401
+ /**
402
+ * Coalescing window, in milliseconds, for metadata refreshes triggered by writes to the
403
+ * entities that compose this provider's metadata. A single administrative action produces a
404
+ * burst (enabling field security writes one permission row per field/role pair, each raising
405
+ * its own event) — one refresh per burst, not one per row. The window also gives an enclosing
406
+ * entity transaction time to COMMIT before the re-read: every event in the burst re-arms the
407
+ * timer, so the refresh runs no earlier than this long after the LAST write.
408
+ */
409
+ static { this.MetadataDatasetRefreshDebounceMs = 500; }
410
+ /**
411
+ * Records which entities compose this provider's metadata, from the loaded MJ_Metadata
412
+ * dataset result, and wires this instance's write-invalidation event subscription so writes to any
413
+ * of them schedule a debounced metadata refresh. Called from {@link GetAllMetadata} on every
414
+ * successful load, so the set tracks the dataset definition as it changes.
415
+ */
416
+ registerMetadataDatasetMembership(dataset) {
417
+ const names = new Set();
418
+ for (const item of dataset.Results ?? []) {
419
+ const n = item.EntityName?.trim().toLowerCase();
420
+ if (n) {
421
+ names.add(n);
422
+ }
423
+ }
424
+ if (names.size === 0) {
425
+ return; // a failed/empty load must not erase a previously recorded set
426
+ }
427
+ this._metadataDatasetEntityNames = names;
428
+ this.ensureInflightViewInvalidation();
429
+ }
430
+ /**
431
+ * Write-invalidation callback: a BaseEntity save/delete (or a remote-invalidate from another
432
+ * server) touched `lowerEntityName`. If that entity is one of the entities this provider's
433
+ * metadata is BUILT FROM, the metadata this provider is serving — and, on the server, the
434
+ * metadata every per-request provider adopts from it — is now stale, so schedule a debounced
435
+ * refresh. Permission metadata is the load-bearing case: field-level security is enforced
436
+ * FROM metadata at every enforcement point, so a rule an administrator just tightened is
437
+ * simply not enforced until this re-read happens.
438
+ */
439
+ handleMetadataMemberEntityEvent(lowerEntityName, entityEvent) {
440
+ if (!this._metadataDatasetEntityNames?.has(lowerEntityName)) {
441
+ return; // the overwhelming majority of writes
442
+ }
443
+ if (!this.eventTargetsThisProviderBackend(entityEvent)) {
444
+ return;
445
+ }
446
+ this.scheduleMetadataMemberRefresh();
447
+ }
448
+ /**
449
+ * Whether the write described by `entityEvent` happened against the backend THIS provider's
450
+ * metadata comes from. In a multi-provider process (a client connected to several MJ servers,
451
+ * a server connected to several databases) a write on one backend must not refresh another's
452
+ * metadata. Deliberately fails OPEN — when the event does not identify its provider, or a
453
+ * connection string is unavailable, the answer is "yes": a spurious refresh is a bounded
454
+ * cost, a suppressed one is a stale-permissions window.
455
+ */
456
+ eventTargetsThisProviderBackend(entityEvent) {
457
+ try {
458
+ if (entityEvent.type === 'remote-invalidate') {
459
+ // The transport that received the message attaches itself; other providers in the
460
+ // same process are connected to other servers.
461
+ return !entityEvent.provider || entityEvent.provider === this;
462
+ }
463
+ const self = this;
464
+ const saveProvider = entityEvent.baseEntity?.ProviderToUse;
465
+ if (saveProvider instanceof ProviderBase && saveProvider !== self) {
466
+ const theirs = saveProvider.InstanceConnectionString;
467
+ const mine = this.InstanceConnectionString;
468
+ if (theirs && mine && theirs !== mine) {
469
+ return false;
470
+ }
471
+ }
472
+ return true;
473
+ }
474
+ catch {
475
+ return true; // a connection-string getter with no live pool must not veto the refresh
476
+ }
477
+ }
478
+ /**
479
+ * How long a member-entity write waits before this provider's refresh runs. The base value
480
+ * is the short debounce window — right for the server, where the writer is the refresher and
481
+ * the delay only exists to coalesce a burst and let the enclosing transaction commit.
482
+ * Transport providers override this with a much longer, RANDOMIZED window: every browser
483
+ * receives every write broadcast, so the delay is what turns "N clients each re-pull the
484
+ * metadata graph within the same half-second of any member write" into "each client pays at
485
+ * most one staleness check per window, at a moment no other client shares".
486
+ */
487
+ get MetadataMemberRefreshDelayMs() {
488
+ return ProviderBase.MetadataDatasetRefreshDebounceMs;
489
+ }
490
+ /**
491
+ * Whether a member-entity write arriving while the refresh timer is already armed RESTARTS
492
+ * the timer (debounce) or joins the pending window (coalesce/throttle).
493
+ *
494
+ * The base is a true debounce (`true`): the server's refresh must run AFTER the last write
495
+ * of the unit of work, so every event pushes the timer out — a burst costs one refresh, run
496
+ * once the burst ends. Transport providers return `false`: with a long window, re-arming
497
+ * would let steady org-wide write activity postpone the refresh indefinitely (starvation);
498
+ * joining the armed window guarantees at most one refresh per window regardless of write
499
+ * rate, which is the whole point of the window.
500
+ */
501
+ get MetadataMemberRefreshRearmsOnNewEvents() {
502
+ return true;
503
+ }
504
+ /**
505
+ * Schedules this provider's metadata refresh after a write to a metadata member entity.
506
+ * Delay and re-arm semantics come from {@link MetadataMemberRefreshDelayMs} and
507
+ * {@link MetadataMemberRefreshRearmsOnNewEvents} — debounce on the server, long jittered
508
+ * coalescing window on clients. The refresh targets THIS instance — the provider that loaded
509
+ * the dataset owns the metadata built from it; short-lived per-request providers never load
510
+ * the dataset (they adopt the global's metadata as a shared shell), so on the server only
511
+ * the process-global provider ever gets here.
512
+ */
513
+ scheduleMetadataMemberRefresh() {
514
+ if (this._metadataMemberRefreshTimer) {
515
+ if (!this.MetadataMemberRefreshRearmsOnNewEvents) {
516
+ return; // coalesce: this write joins the already-armed window
517
+ }
518
+ clearTimeout(this._metadataMemberRefreshTimer);
519
+ }
520
+ this._metadataMemberRefreshTimer = setTimeout(() => {
521
+ this._metadataMemberRefreshTimer = null;
522
+ this.RefreshAfterMetadataMemberChange().catch((e) => {
523
+ LogError(`Metadata refresh after a member-entity change failed: ${e instanceof Error ? e.message : String(e)}`);
524
+ });
525
+ }, this.MetadataMemberRefreshDelayMs);
526
+ }
527
+ /**
528
+ * How this provider refreshes after a metadata member entity changed. The base behavior is a
529
+ * hard {@link Refresh} — correct for database providers, where the process that PERFORMED the
530
+ * write is the one refreshing, so re-checking staleness first is wasted work and the re-read
531
+ * must bypass every cache layer. Transport providers (GraphQL) override this with a staleness
532
+ * check so a browser doesn't re-pull the full metadata graph for a change the server-side
533
+ * timestamp comparison can disconfirm.
534
+ */
535
+ async RefreshAfterMetadataMemberChange() {
536
+ return this.Refresh();
537
+ }
538
+ /**
539
+ * Cancels any pending debounced metadata refresh. Call during teardown (logout, provider
540
+ * disposal) so a timer armed just before teardown doesn't fire a refresh against a
541
+ * connection that no longer has a valid session.
542
+ */
543
+ CancelPendingMetadataMemberRefresh() {
544
+ if (this._metadataMemberRefreshTimer) {
545
+ clearTimeout(this._metadataMemberRefreshTimer);
546
+ this._metadataMemberRefreshTimer = null;
547
+ }
548
+ }
316
549
  /******** ABSTRACT SECTION ****************************************************************** */
317
550
  /**
318
551
  * When true, cached RunView/RunQuery results are returned immediately on a
@@ -2934,24 +3167,62 @@ export class ProviderBase {
2934
3167
  // first, make sure we reset the flag to false so that if another call to this function happens
2935
3168
  // while we are waiting for the async call to finish, we dont do it again
2936
3169
  this._refresh = false;
2937
- // Fetch new metadata without clearing current metadata
2938
- // This ensures readers always see valid data (old until new is ready)
2939
- const start = new Date().getTime();
2940
- const res = await this.GetAllMetadata(providerToUse, hardRefresh);
2941
- const end = new Date().getTime();
2942
- LogStatusEx({ message: `GetAllMetadata() took ${end - start} ms`, verboseOnly: true });
2943
- if (res) {
2944
- // Atomic swap via UpdateLocalMetadata: single property assignment is atomic in JavaScript
2945
- // Readers now see new metadata instead of old
2946
- // Uses UpdateLocalMetadata() to maintain consistency with LoadLocalMetadataFromStorage()
2947
- // and allow potential subclass overrides for extensibility
2948
- this.UpdateLocalMetadata(res);
2949
- this._latestLocalMetadataTimestamps = this._latestRemoteMetadataTimestamps; // update this since we just used server to get all the stuff
2950
- await this.SaveLocalMetadataToStorage();
3170
+ // SINGLE-FLIGHT: at most one full metadata reload runs at a time. Without this, a
3171
+ // second refresh request arriving while a reload is still awaiting its queries starts
3172
+ // a CONCURRENT reload, and whichever finishes LAST wins the atomic swap — an older
3173
+ // snapshot can overwrite a newer one. A joiner must not simply await and return,
3174
+ // either: the in-flight reload's queries may predate the write that prompted the
3175
+ // joiner, so it flags ONE follow-up; the loop below reruns after the current pass,
3176
+ // guaranteeing the final swap comes from a read that started after the last request.
3177
+ if (this._metadataReloadInFlight) {
3178
+ this._metadataReloadQueued = true;
3179
+ await this._metadataReloadInFlight;
3180
+ return true;
2951
3181
  }
2952
- else {
2953
- // GetAllMetadata failed - log error but keep existing metadata
2954
- LogError('GetAllMetadata() returned undefined - metadata not updated');
3182
+ this._metadataReloadInFlight = (async () => {
3183
+ let effectiveHardRefresh = hardRefresh;
3184
+ do {
3185
+ this._metadataReloadQueued = false;
3186
+ // The local timestamps must describe the snapshot about to be loaded. On the
3187
+ // hard-refresh path the staleness check was SKIPPED, so the cached remote
3188
+ // timestamps predate this pass — copying them as-is would make the next
3189
+ // periodic check see a mismatch and reload once more for nothing. Re-read
3190
+ // them (one cheap status query, authoritative) BEFORE the load, not after:
3191
+ // a write landing DURING the load then leaves the stamped timestamps looking
3192
+ // stale and the next tick reloads — the safe direction. Reading after could
3193
+ // stamp the snapshot as containing a write it does not.
3194
+ if (effectiveHardRefresh) {
3195
+ await this.RefreshRemoteMetadataTimestamps(providerToUse);
3196
+ }
3197
+ // Fetch new metadata without clearing current metadata
3198
+ // This ensures readers always see valid data (old until new is ready)
3199
+ const start = new Date().getTime();
3200
+ const res = await this.GetAllMetadata(providerToUse, effectiveHardRefresh);
3201
+ const end = new Date().getTime();
3202
+ LogStatusEx({ message: `GetAllMetadata() took ${end - start} ms`, verboseOnly: true });
3203
+ if (res) {
3204
+ // Atomic swap via UpdateLocalMetadata: single property assignment is atomic in JavaScript
3205
+ // Readers now see new metadata instead of old
3206
+ // Uses UpdateLocalMetadata() to maintain consistency with LoadLocalMetadataFromStorage()
3207
+ // and allow potential subclass overrides for extensibility
3208
+ this.UpdateLocalMetadata(res);
3209
+ this._latestLocalMetadataTimestamps = this._latestRemoteMetadataTimestamps; // update this since we just used server to get all the stuff
3210
+ await this.SaveLocalMetadataToStorage();
3211
+ }
3212
+ else {
3213
+ // GetAllMetadata failed - log error but keep existing metadata
3214
+ LogError('GetAllMetadata() returned undefined - metadata not updated');
3215
+ }
3216
+ // A queued follow-up exists only because another refresh request arrived
3217
+ // mid-reload; rerun hard so the re-read cannot be served by any cache layer.
3218
+ effectiveHardRefresh = true;
3219
+ } while (this._metadataReloadQueued);
3220
+ })();
3221
+ try {
3222
+ await this._metadataReloadInFlight;
3223
+ }
3224
+ finally {
3225
+ this._metadataReloadInFlight = null;
2955
3226
  }
2956
3227
  }
2957
3228
  return true;
@@ -3171,6 +3442,10 @@ export class ProviderBase {
3171
3442
  if (d && d.Success) {
3172
3443
  // cache the dataset for anyone who wants to use it
3173
3444
  await this.CacheDataset(ProviderBase._mjMetadataDatasetName, null, d);
3445
+ // Record which entities this metadata is built from, so a write to any of them
3446
+ // schedules a debounced refresh of this provider's metadata (see
3447
+ // registerMetadataDatasetMembership — membership is the dataset definition, not a list).
3448
+ this.registerMetadataDatasetMembership(d);
3174
3449
  // got the results, let's build our response in the format we need
3175
3450
  const simpleMetadata = {};
3176
3451
  for (let r of d.Results) {
@@ -3444,13 +3719,18 @@ export class ProviderBase {
3444
3719
  /**
3445
3720
  * Checks if local metadata is out of date and needs refreshing.
3446
3721
  * Compares local timestamps with server timestamps.
3722
+ * @param bypassMinCheckInterval - When true, skips the {@link MinRefreshCheckIntervalMs}
3723
+ * throttle. Event-driven callers pass true: they hold positive evidence that a metadata
3724
+ * member entity was just written, and the throttle otherwise answers "fresh" for any check
3725
+ * arriving within the window of the previous one — which would silently drop the second of
3726
+ * two permission changes made less than the window apart.
3447
3727
  * @returns True if refresh is needed, false otherwise
3448
3728
  */
3449
- async CheckToSeeIfRefreshNeeded(providerToUse) {
3729
+ async CheckToSeeIfRefreshNeeded(providerToUse, bypassMinCheckInterval) {
3450
3730
  if (!this.AllowRefresh)
3451
3731
  return false;
3452
3732
  const now = Date.now();
3453
- if ((now - this._lastRefreshCheckAt) < ProviderBase.MinRefreshCheckIntervalMs) {
3733
+ if (!bypassMinCheckInterval && (now - this._lastRefreshCheckAt) < ProviderBase.MinRefreshCheckIntervalMs) {
3454
3734
  LogStatusEx({
3455
3735
  message: `[RefreshCheck] Skipped — last check was ${now - this._lastRefreshCheckAt}ms ago (min interval ${ProviderBase.MinRefreshCheckIntervalMs}ms)`,
3456
3736
  verboseOnly: true
@@ -3465,10 +3745,13 @@ export class ProviderBase {
3465
3745
  /**
3466
3746
  * Refreshes metadata only if needed based on timestamp comparison.
3467
3747
  * Combines check and refresh into a single operation.
3748
+ * @param bypassMinCheckInterval - Passed through to {@link CheckToSeeIfRefreshNeeded};
3749
+ * event-driven callers set true so the throttle cannot eat a check they have positive
3750
+ * evidence for.
3468
3751
  * @returns True if refresh was successful or not needed
3469
3752
  */
3470
- async RefreshIfNeeded(providerToUse) {
3471
- if (await this.CheckToSeeIfRefreshNeeded(providerToUse))
3753
+ async RefreshIfNeeded(providerToUse, bypassMinCheckInterval) {
3754
+ if (await this.CheckToSeeIfRefreshNeeded(providerToUse, bypassMinCheckInterval))
3472
3755
  return this.Refresh(providerToUse);
3473
3756
  else
3474
3757
  return true;
@@ -3927,6 +4210,12 @@ export class ProviderBase {
3927
4210
  const ls = this.LocalStorageProvider;
3928
4211
  if (!ls)
3929
4212
  return;
4213
+ // Symmetrical with the save: an in-process store can only hand back a copy of the
4214
+ // metadata this heap already holds, at the cost of rebuilding every metadata object.
4215
+ if (!this.MetadataSnapshotPersistenceEnabled) {
4216
+ this.logMetadataSnapshotDisabledOnce();
4217
+ return;
4218
+ }
3930
4219
  const overallStart = Date.now();
3931
4220
  // The metadata snapshot uses three keys (timestamps, format, AllMetadata).
3932
4221
  // Pull them in a single batched read — saves two IDB transaction round-trips
@@ -3935,13 +4224,31 @@ export class ProviderBase {
3935
4224
  const tsKey = this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey;
3936
4225
  const fmtKey = this.LocalStoragePrefix + ProviderBase.localStorageFormatKey;
3937
4226
  const dataKey = this.LocalStoragePrefix + ProviderBase.localStorageAllMetadataKey;
4227
+ const membershipKey = this.LocalStoragePrefix + ProviderBase.localStorageDatasetMembershipKey;
3938
4228
  const readStart = Date.now();
3939
- const all = await ls.GetItems([tsKey, fmtKey, dataKey]);
4229
+ const all = await ls.GetItems([tsKey, fmtKey, dataKey, membershipKey]);
3940
4230
  const readMs = Date.now() - readStart;
3941
4231
  const tsRaw = all.get(tsKey) ?? null;
3942
4232
  const format = all.get(fmtKey) ?? null;
3943
4233
  const raw = all.get(dataKey) ?? null;
4234
+ const membershipRaw = all.get(membershipKey) ?? null;
3944
4235
  this._latestLocalMetadataTimestamps = tsRaw ? JSON.parse(tsRaw) : null;
4236
+ // Restore the metadata-member entity set so the event-driven refresh works on a warm
4237
+ // boot that never calls GetAllMetadata (the stale-while-revalidate fast start). The
4238
+ // set is only stored after a successful dataset load, so restoring it is exactly as
4239
+ // trustworthy as restoring the metadata itself.
4240
+ if (membershipRaw) {
4241
+ try {
4242
+ const names = JSON.parse(membershipRaw);
4243
+ if (Array.isArray(names) && names.length > 0) {
4244
+ this._metadataDatasetEntityNames = new Set(names);
4245
+ this.ensureInflightViewInvalidation();
4246
+ }
4247
+ }
4248
+ catch (membershipErr) {
4249
+ LogError(`[Metadata Cache] Failed to restore dataset membership: ${membershipErr instanceof Error ? membershipErr.message : String(membershipErr)}`);
4250
+ }
4251
+ }
3945
4252
  if (!raw)
3946
4253
  return;
3947
4254
  // Decompress if stored in compressed format, otherwise parse directly
@@ -3987,16 +4294,30 @@ export class ProviderBase {
3987
4294
  static { this.localStorageTimestampsKey = this.localStorageRootKey + '_Timestamps'; }
3988
4295
  static { this.localStorageAllMetadataKey = this.localStorageRootKey + '_AllMetadata'; }
3989
4296
  static { this.localStorageFormatKey = this.localStorageRootKey + '_Format'; }
4297
+ static { this.localStorageDatasetMembershipKey = this.localStorageRootKey + '_DatasetMembership'; }
3990
4298
  static { this.localStorageKeys = [
3991
4299
  ProviderBase.localStorageTimestampsKey,
3992
4300
  ProviderBase.localStorageAllMetadataKey,
3993
4301
  ProviderBase.localStorageFormatKey,
4302
+ ProviderBase.localStorageDatasetMembershipKey,
3994
4303
  ]; }
3995
4304
  /**
3996
4305
  * Converts a base64-encoded string to an ArrayBuffer.
3997
4306
  * Used for compressed metadata storage/retrieval.
3998
4307
  */
3999
4308
  static base64ToArrayBuffer(base64) {
4309
+ // Node decodes natively; the atob path below allocates an intermediate string and fills the
4310
+ // array a byte at a time.
4311
+ if (typeof Buffer !== 'undefined') {
4312
+ const buf = Buffer.from(base64, 'base64');
4313
+ // Copy into an exact-size buffer: `buf.buffer` is a view into a shared pool, usually
4314
+ // larger than the payload, so returning it directly would carry unrelated bytes and a
4315
+ // wrong byteLength. It is also typed ArrayBufferLike and will not assign to the
4316
+ // declared return type.
4317
+ const exact = new ArrayBuffer(buf.byteLength);
4318
+ new Uint8Array(exact).set(buf);
4319
+ return exact;
4320
+ }
4000
4321
  const binaryString = atob(base64);
4001
4322
  const bytes = new Uint8Array(binaryString.length);
4002
4323
  for (let i = 0; i < binaryString.length; i++) {
@@ -4009,6 +4330,11 @@ export class ProviderBase {
4009
4330
  * Used for compressed metadata storage/retrieval.
4010
4331
  */
4011
4332
  static arrayBufferToBase64(buffer) {
4333
+ // Node encodes natively. The loop below concatenates one character per byte, building a rope
4334
+ // the size of the payload that then has to be flattened.
4335
+ if (typeof Buffer !== 'undefined') {
4336
+ return Buffer.from(buffer).toString('base64');
4337
+ }
4012
4338
  const bytes = new Uint8Array(buffer);
4013
4339
  let binary = '';
4014
4340
  for (let i = 0; i < bytes.byteLength; i++) {
@@ -4024,6 +4350,42 @@ export class ProviderBase {
4024
4350
  get LocalStoragePrefix() {
4025
4351
  return "";
4026
4352
  }
4353
+ /**
4354
+ * Whether anything could read the metadata snapshot back, and therefore whether saving it is
4355
+ * worth the cost.
4356
+ *
4357
+ * The snapshot lets a cold process start from a cached copy of the metadata instead of querying
4358
+ * for it, which only works if the store outlives the writer. Where it does not, both halves of
4359
+ * the round trip are pure cost: the save serializes, gzips and base64-encodes the entire
4360
+ * metadata graph, and the load parses it and rebuilds every `EntityInfo` and `EntityFieldInfo`
4361
+ * from a copy of objects the heap already holds. On a large tenant that is expensive enough to
4362
+ * exhaust the heap.
4363
+ *
4364
+ * A provider that does not declare {@link ILocalStorageProvider.SupportsCrossProcessPersistence}
4365
+ * is treated as persistent, which fails in the safer direction.
4366
+ */
4367
+ get MetadataSnapshotPersistenceEnabled() {
4368
+ const ls = this.LocalStorageProvider;
4369
+ if (!ls) {
4370
+ return false;
4371
+ }
4372
+ return ls.SupportsCrossProcessPersistence !== false;
4373
+ }
4374
+ /**
4375
+ * Explains the skip once per process. The refresh path runs every 30 seconds on a busy server,
4376
+ * so logging per call would bury the one line that matters.
4377
+ */
4378
+ logMetadataSnapshotDisabledOnce() {
4379
+ if (this._metadataSnapshotSkipLogged) {
4380
+ return;
4381
+ }
4382
+ this._metadataSnapshotSkipLogged = true;
4383
+ const name = this.LocalStorageProvider?.constructor?.name ?? 'the local storage provider';
4384
+ LogStatusEx({
4385
+ message: `[Metadata Cache] Snapshot persistence disabled: ${name} is in-process only, so a saved snapshot could never be read back. Skipping the metadata snapshot save and load.`,
4386
+ verboseOnly: false
4387
+ });
4388
+ }
4027
4389
  /**
4028
4390
  * Saves current metadata to local storage for caching.
4029
4391
  * Serializes both timestamps and full metadata collections.
@@ -4033,11 +4395,22 @@ export class ProviderBase {
4033
4395
  const ls = this.LocalStorageProvider;
4034
4396
  if (!ls)
4035
4397
  return;
4398
+ // Nothing could ever read this snapshot back, so the entire serialize/compress/encode
4399
+ // pass buys nothing. See MetadataSnapshotPersistenceEnabled.
4400
+ if (!this.MetadataSnapshotPersistenceEnabled) {
4401
+ this.logMetadataSnapshotDisabledOnce();
4402
+ return;
4403
+ }
4036
4404
  const start = Date.now();
4037
4405
  // Save timestamps as a JSON string. The metadata snapshot path intentionally uses
4038
4406
  // string storage so the compressed (gzip+base64) format below can round-trip cleanly
4039
4407
  // through providers that don't support binary natively.
4040
4408
  await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey, JSON.stringify(this._latestLocalMetadataTimestamps));
4409
+ // Persist the metadata-member entity set beside the snapshot it belongs to, so the
4410
+ // event-driven refresh survives a warm boot (see LoadLocalMetadataFromStorage).
4411
+ if (this._metadataDatasetEntityNames?.size) {
4412
+ await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageDatasetMembershipKey, JSON.stringify([...this._metadataDatasetEntityNames]));
4413
+ }
4041
4414
  // Serialize the AllMetadata object
4042
4415
  const jsonString = JSON.stringify(this._localMetadata);
4043
4416
  // Attempt compressed storage using native CompressionStream (available in modern browsers and Node 18+)