@memberjunction/core 5.51.3 → 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 (47) 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 +5 -0
  6. package/dist/generic/baseEngine.d.ts.map +1 -1
  7. package/dist/generic/baseEngine.js +6 -1
  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 +38 -0
  22. package/dist/generic/interfaces.d.ts.map +1 -1
  23. package/dist/generic/interfaces.js.map +1 -1
  24. package/dist/generic/providerBase.d.ts +66 -5
  25. package/dist/generic/providerBase.d.ts.map +1 -1
  26. package/dist/generic/providerBase.js +147 -13
  27. package/dist/generic/providerBase.js.map +1 -1
  28. package/dist/generic/remoteEventRow.d.ts +55 -0
  29. package/dist/generic/remoteEventRow.d.ts.map +1 -0
  30. package/dist/generic/remoteEventRow.js +110 -0
  31. package/dist/generic/remoteEventRow.js.map +1 -0
  32. package/dist/generic/runQuery.d.ts +18 -1
  33. package/dist/generic/runQuery.d.ts.map +1 -1
  34. package/dist/generic/runQuery.js.map +1 -1
  35. package/dist/generic/runQuerySQLFilterImplementations.d.ts +7 -1
  36. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  37. package/dist/generic/runQuerySQLFilterImplementations.js +64 -139
  38. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  39. package/dist/generic/telemetryManager.d.ts +11 -0
  40. package/dist/generic/telemetryManager.d.ts.map +1 -1
  41. package/dist/generic/telemetryManager.js +40 -0
  42. package/dist/generic/telemetryManager.js.map +1 -1
  43. package/dist/index.d.ts +1 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +1 -0
  46. package/dist/index.js.map +1 -1
  47. package/package.json +3 -3
@@ -219,6 +219,8 @@ export class ProviderBase {
219
219
  this._metadataMemberRefreshTimer = null;
220
220
  this._clientFingerprintMemo = new WeakMap();
221
221
  this._cachedVisibleExplorerNavigationItems = null;
222
+ /** Guard for {@link logMetadataSnapshotDisabledOnce}. */
223
+ this._metadataSnapshotSkipLogged = false;
222
224
  }
223
225
  // ── Metadata Refresh Check Debounce ────────────────────────────────
224
226
  /**
@@ -271,19 +273,56 @@ export class ProviderBase {
271
273
  * instead of accumulating result arrays in memory.
272
274
  */
273
275
  static { this.MaxLingerEntries = 500; }
274
- /**
275
- * Lazily subscribes (once per provider instance) to BaseEntity events so
276
- * in-flight/lingered RunView entries touching a written entity are dropped.
277
- * Deleting an IN-FLIGHT entry is deliberate: late joiners must not share a
278
- * query that may have read pre-commit data; the original caller still gets
279
- * its own result, and the orphaned promise's stash step no-ops safely.
276
+ // ── Write-invalidation fan-out (process-wide, not per-instance) ────
277
+ /**
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.
285
+ */
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}.
280
314
  */
281
- ensureInflightViewInvalidation() {
282
- if (this._lingerInvalidationWired) {
315
+ static ensureStaticInvalidationSubscription() {
316
+ const bus = MJGlobal.Instance.GetEventListener(false);
317
+ if (ProviderBase._wiredBus === bus) {
283
318
  return;
284
319
  }
285
- this._lingerInvalidationWired = true;
286
- 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) => {
287
326
  if (event.event !== MJEventType.ComponentEvent || event.eventCode !== BaseEntity.BaseEventCode) {
288
327
  return;
289
328
  }
@@ -295,12 +334,42 @@ export class ProviderBase {
295
334
  return;
296
335
  }
297
336
  const entityName = (entityEvent.baseEntity?.EntityInfo?.Name ?? entityEvent.entityName)?.trim().toLowerCase();
298
- if (entityName) {
299
- this.invalidateInflightViewsForEntity(entityName);
300
- this.handleMetadataMemberEntityEvent(entityName, entityEvent);
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);
301
350
  }
302
351
  });
303
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
+ }
304
373
  /**
305
374
  * Drops every in-flight/lingered RunView entry whose params touch the given
306
375
  * entity (lowercased name). Called on BaseEntity save/delete/remote-invalidate.
@@ -4141,6 +4210,12 @@ export class ProviderBase {
4141
4210
  const ls = this.LocalStorageProvider;
4142
4211
  if (!ls)
4143
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
+ }
4144
4219
  const overallStart = Date.now();
4145
4220
  // The metadata snapshot uses three keys (timestamps, format, AllMetadata).
4146
4221
  // Pull them in a single batched read — saves two IDB transaction round-trips
@@ -4231,6 +4306,18 @@ export class ProviderBase {
4231
4306
  * Used for compressed metadata storage/retrieval.
4232
4307
  */
4233
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
+ }
4234
4321
  const binaryString = atob(base64);
4235
4322
  const bytes = new Uint8Array(binaryString.length);
4236
4323
  for (let i = 0; i < binaryString.length; i++) {
@@ -4243,6 +4330,11 @@ export class ProviderBase {
4243
4330
  * Used for compressed metadata storage/retrieval.
4244
4331
  */
4245
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
+ }
4246
4338
  const bytes = new Uint8Array(buffer);
4247
4339
  let binary = '';
4248
4340
  for (let i = 0; i < bytes.byteLength; i++) {
@@ -4258,6 +4350,42 @@ export class ProviderBase {
4258
4350
  get LocalStoragePrefix() {
4259
4351
  return "";
4260
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
+ }
4261
4389
  /**
4262
4390
  * Saves current metadata to local storage for caching.
4263
4391
  * Serializes both timestamps and full metadata collections.
@@ -4267,6 +4395,12 @@ export class ProviderBase {
4267
4395
  const ls = this.LocalStorageProvider;
4268
4396
  if (!ls)
4269
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
+ }
4270
4404
  const start = Date.now();
4271
4405
  // Save timestamps as a JSON string. The metadata snapshot path intentionally uses
4272
4406
  // string storage so the compressed (gzip+base64) format below can round-trip cleanly