@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.
- package/dist/generic/InMemoryLocalStorageProvider.d.ts +5 -0
- package/dist/generic/InMemoryLocalStorageProvider.d.ts.map +1 -1
- package/dist/generic/InMemoryLocalStorageProvider.js +5 -0
- package/dist/generic/InMemoryLocalStorageProvider.js.map +1 -1
- package/dist/generic/baseEngine.d.ts +24 -0
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +54 -2
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +35 -0
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +66 -13
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts +41 -8
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +67 -30
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +18 -2
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +26 -10
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/interfaces.d.ts +49 -2
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +1 -1
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +15 -1
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/providerBase.d.ts +174 -8
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +406 -33
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/remoteEventRow.d.ts +55 -0
- package/dist/generic/remoteEventRow.d.ts.map +1 -0
- package/dist/generic/remoteEventRow.js +110 -0
- package/dist/generic/remoteEventRow.js.map +1 -0
- package/dist/generic/runQuery.d.ts +18 -1
- package/dist/generic/runQuery.d.ts.map +1 -1
- package/dist/generic/runQuery.js.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.d.ts +7 -1
- package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.js +64 -139
- package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
- package/dist/generic/telemetryManager.d.ts +11 -0
- package/dist/generic/telemetryManager.d.ts.map +1 -1
- package/dist/generic/telemetryManager.js +40 -0
- package/dist/generic/telemetryManager.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- 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
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2938
|
-
//
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
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
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
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+)
|