@memberjunction/core 6.1.0-edge.4 → 6.1.0-edge.6

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 (98) hide show
  1. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts +27 -0
  2. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts.map +1 -0
  3. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.js +6 -0
  4. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.js.map +1 -0
  5. package/dist/generic/baseEngine.d.ts.map +1 -1
  6. package/dist/generic/baseEngine.js +2 -3
  7. package/dist/generic/baseEngine.js.map +1 -1
  8. package/dist/generic/baseEntity.d.ts +172 -0
  9. package/dist/generic/baseEntity.d.ts.map +1 -1
  10. package/dist/generic/baseEntity.js +622 -87
  11. package/dist/generic/baseEntity.js.map +1 -1
  12. package/dist/generic/baseInfo.d.ts +6 -0
  13. package/dist/generic/baseInfo.d.ts.map +1 -1
  14. package/dist/generic/baseInfo.js +19 -1
  15. package/dist/generic/baseInfo.js.map +1 -1
  16. package/dist/generic/compositeKey.d.ts +108 -0
  17. package/dist/generic/compositeKey.d.ts.map +1 -1
  18. package/dist/generic/compositeKey.js +225 -10
  19. package/dist/generic/compositeKey.js.map +1 -1
  20. package/dist/generic/databaseProviderBase.d.ts +53 -2
  21. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  22. package/dist/generic/databaseProviderBase.js +92 -13
  23. package/dist/generic/databaseProviderBase.js.map +1 -1
  24. package/dist/generic/embeddedRecord.d.ts +2 -1
  25. package/dist/generic/embeddedRecord.d.ts.map +1 -1
  26. package/dist/generic/embeddedRecord.js +9 -2
  27. package/dist/generic/embeddedRecord.js.map +1 -1
  28. package/dist/generic/entityCompanion.d.ts +6 -1
  29. package/dist/generic/entityCompanion.d.ts.map +1 -1
  30. package/dist/generic/entityCompanion.js +7 -0
  31. package/dist/generic/entityCompanion.js.map +1 -1
  32. package/dist/generic/entityInfo.d.ts +136 -7
  33. package/dist/generic/entityInfo.d.ts.map +1 -1
  34. package/dist/generic/entityInfo.js +204 -14
  35. package/dist/generic/entityInfo.js.map +1 -1
  36. package/dist/generic/entitySubtypeResolver.d.ts +19 -0
  37. package/dist/generic/entitySubtypeResolver.d.ts.map +1 -0
  38. package/dist/generic/entitySubtypeResolver.js +24 -0
  39. package/dist/generic/entitySubtypeResolver.js.map +1 -0
  40. package/dist/generic/entityTransactionScope.d.ts +6 -3
  41. package/dist/generic/entityTransactionScope.d.ts.map +1 -1
  42. package/dist/generic/entityTransactionScope.js +6 -3
  43. package/dist/generic/entityTransactionScope.js.map +1 -1
  44. package/dist/generic/extendedTypeValue.d.ts +47 -0
  45. package/dist/generic/extendedTypeValue.d.ts.map +1 -0
  46. package/dist/generic/extendedTypeValue.js +152 -0
  47. package/dist/generic/extendedTypeValue.js.map +1 -0
  48. package/dist/generic/filters/compositeFilter.d.ts +77 -0
  49. package/dist/generic/filters/compositeFilter.d.ts.map +1 -0
  50. package/dist/generic/filters/compositeFilter.js +324 -0
  51. package/dist/generic/filters/compositeFilter.js.map +1 -0
  52. package/dist/generic/filters/filter.types.d.ts +29 -0
  53. package/dist/generic/filters/filter.types.d.ts.map +1 -0
  54. package/dist/generic/filters/filter.types.js +28 -0
  55. package/dist/generic/filters/filter.types.js.map +1 -0
  56. package/dist/generic/filters/index.d.ts +3 -0
  57. package/dist/generic/filters/index.d.ts.map +1 -0
  58. package/dist/generic/filters/index.js +3 -0
  59. package/dist/generic/filters/index.js.map +1 -0
  60. package/dist/generic/geoFields.d.ts +64 -0
  61. package/dist/generic/geoFields.d.ts.map +1 -0
  62. package/dist/generic/geoFields.js +108 -0
  63. package/dist/generic/geoFields.js.map +1 -0
  64. package/dist/generic/interfaces.d.ts +36 -2
  65. package/dist/generic/interfaces.d.ts.map +1 -1
  66. package/dist/generic/interfaces.js +25 -0
  67. package/dist/generic/interfaces.js.map +1 -1
  68. package/dist/generic/localCacheManager.d.ts +4 -2
  69. package/dist/generic/localCacheManager.d.ts.map +1 -1
  70. package/dist/generic/localCacheManager.js +5 -3
  71. package/dist/generic/localCacheManager.js.map +1 -1
  72. package/dist/generic/metadata.d.ts +11 -0
  73. package/dist/generic/metadata.d.ts.map +1 -1
  74. package/dist/generic/metadata.js +29 -0
  75. package/dist/generic/metadata.js.map +1 -1
  76. package/dist/generic/providerBase.d.ts +118 -3
  77. package/dist/generic/providerBase.d.ts.map +1 -1
  78. package/dist/generic/providerBase.js +356 -70
  79. package/dist/generic/providerBase.js.map +1 -1
  80. package/dist/generic/relatedRecordBatchLoader.js +2 -2
  81. package/dist/generic/relatedRecordBatchLoader.js.map +1 -1
  82. package/dist/generic/relatedRecordCollection.d.ts +2 -1
  83. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  84. package/dist/generic/relatedRecordCollection.js +16 -4
  85. package/dist/generic/relatedRecordCollection.js.map +1 -1
  86. package/dist/generic/schemaHydration.d.ts +54 -0
  87. package/dist/generic/schemaHydration.d.ts.map +1 -0
  88. package/dist/generic/schemaHydration.js +75 -0
  89. package/dist/generic/schemaHydration.js.map +1 -0
  90. package/dist/generic/transactionGroup.d.ts +18 -0
  91. package/dist/generic/transactionGroup.d.ts.map +1 -1
  92. package/dist/generic/transactionGroup.js +22 -0
  93. package/dist/generic/transactionGroup.js.map +1 -1
  94. package/dist/index.d.ts +6 -0
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +6 -0
  97. package/dist/index.js.map +1 -1
  98. package/package.json +3 -3
@@ -5,7 +5,7 @@ import { ComputeRRF } from "./scoring/ReciprocalRankFusion.js";
5
5
  import { LocalCacheManager } from "./localCacheManager.js";
6
6
  import { ApplicationInfo } from "../generic/applicationInfo.js";
7
7
  import { AuditLogTypeInfo, AuthorizationInfo, AuthorizationRoleInfo, RoleInfo, RowLevelSecurityFilterInfo, UserInfo } from "./securityInfo.js";
8
- import { MJGlobal, MJEventType, NormalizeUUID, UUIDsEqual, MJLruCache } from "@memberjunction/global";
8
+ import { MJGlobal, MJEventType, NormalizeUUID, UUIDsEqual, MJLruCache, EscapeSQLString, ordinalCompare } from "@memberjunction/global";
9
9
  import { TelemetryManager } from "./telemetryManager.js";
10
10
  import { LogError, LogStatus, LogStatusEx } from "./logging.js";
11
11
  import { QueryCategoryInfo, QueryFieldInfo, QueryInfo, QueryPermissionInfo, QueryEntityInfo, QueryParameterInfo, QueryDependencyInfo, SQLDialectInfo, QuerySQLInfo } from "./queryInfo.js";
@@ -189,6 +189,10 @@ export class ProviderBase {
189
189
  // it can't be reset on metadata refresh; it needs its own eviction policy.
190
190
  this._entityRecordNameCache = new MJLruCache({ maxSize: 10000, ttlMs: 60 * 60 * 1000 });
191
191
  this._refresh = false;
192
+ /** Single-flight guard for the full metadata reload in {@link Config} — see the comment there. */
193
+ this._metadataReloadInFlight = null;
194
+ /** Set when a refresh request arrives while a reload is in flight; the reload loop reruns once. */
195
+ this._metadataReloadQueued = false;
192
196
  this._lastRefreshCheckAt = 0;
193
197
  /**
194
198
  * Pending coalesced requests waiting to be flushed.
@@ -213,6 +217,16 @@ export class ProviderBase {
213
217
  */
214
218
  this._inflightViews = new Map();
215
219
  this._lingerInvalidationWired = false;
220
+ /**
221
+ * Lowercased names of the entities whose rows COMPOSE this provider's metadata — the
222
+ * MJ_Metadata dataset's item entities, recorded each time the dataset is loaded (and restored
223
+ * from local storage on warm boot). Membership is DATA: adding a `DatasetItem` row to
224
+ * MJ_Metadata extends refresh coverage with no code change, which is why no entity names are
225
+ * hardcoded anywhere in this mechanism.
226
+ */
227
+ this._metadataDatasetEntityNames = null;
228
+ /** Debounce timer for {@link scheduleMetadataMemberRefresh}. */
229
+ this._metadataMemberRefreshTimer = null;
216
230
  this._clientFingerprintMemo = new WeakMap();
217
231
  this._cachedVisibleExplorerNavigationItems = null;
218
232
  }
@@ -355,6 +369,7 @@ export class ProviderBase {
355
369
  continue;
356
370
  }
357
371
  instance.invalidateInflightViewsForEntity(entityName);
372
+ instance.handleMetadataMemberEntityEvent(entityName, entityEvent);
358
373
  }
359
374
  });
360
375
  }
@@ -405,6 +420,155 @@ export class ProviderBase {
405
420
  }
406
421
  return names;
407
422
  }
423
+ // ── Metadata-Dataset Membership Refresh ────────────────────────────
424
+ /**
425
+ * Coalescing window, in milliseconds, for metadata refreshes triggered by writes to the
426
+ * entities that compose this provider's metadata. A single administrative action produces a
427
+ * burst (enabling field security writes one permission row per field/role pair, each raising
428
+ * its own event) — one refresh per burst, not one per row. The window also gives an enclosing
429
+ * entity transaction time to COMMIT before the re-read: every event in the burst re-arms the
430
+ * timer, so the refresh runs no earlier than this long after the LAST write.
431
+ */
432
+ static { this.MetadataDatasetRefreshDebounceMs = 500; }
433
+ /**
434
+ * Records which entities compose this provider's metadata, from the loaded MJ_Metadata
435
+ * dataset result, and registers this instance with the static event fan-out so writes to any
436
+ * of them schedule a debounced metadata refresh. Called from {@link GetAllMetadata} on every
437
+ * successful load, so the set tracks the dataset definition as it changes.
438
+ */
439
+ registerMetadataDatasetMembership(dataset) {
440
+ const names = new Set();
441
+ for (const item of dataset.Results ?? []) {
442
+ const n = item.EntityName?.trim().toLowerCase();
443
+ if (n) {
444
+ names.add(n);
445
+ }
446
+ }
447
+ if (names.size === 0) {
448
+ return; // a failed/empty load must not erase a previously recorded set
449
+ }
450
+ this._metadataDatasetEntityNames = names;
451
+ this.ensureInflightViewInvalidation();
452
+ }
453
+ /**
454
+ * Static fan-out callback: a BaseEntity save/delete (or a remote-invalidate from another
455
+ * server) touched `lowerEntityName`. If that entity is one of the entities this provider's
456
+ * metadata is BUILT FROM, the metadata this provider is serving — and, on the server, the
457
+ * metadata every per-request provider adopts from it — is now stale, so schedule a debounced
458
+ * refresh. Permission metadata is the load-bearing case: field-level security is enforced
459
+ * FROM metadata at every enforcement point, so a rule an administrator just tightened is
460
+ * simply not enforced until this re-read happens.
461
+ */
462
+ handleMetadataMemberEntityEvent(lowerEntityName, entityEvent) {
463
+ if (!this._metadataDatasetEntityNames?.has(lowerEntityName)) {
464
+ return; // the overwhelming majority of writes
465
+ }
466
+ if (!this.eventTargetsThisProviderBackend(entityEvent)) {
467
+ return;
468
+ }
469
+ this.scheduleMetadataMemberRefresh();
470
+ }
471
+ /**
472
+ * Whether the write described by `entityEvent` happened against the backend THIS provider's
473
+ * metadata comes from. In a multi-provider process (a client connected to several MJ servers,
474
+ * a server connected to several databases) a write on one backend must not refresh another's
475
+ * metadata. Deliberately fails OPEN — when the event does not identify its provider, or a
476
+ * connection string is unavailable, the answer is "yes": a spurious refresh is a bounded
477
+ * cost, a suppressed one is a stale-permissions window.
478
+ */
479
+ eventTargetsThisProviderBackend(entityEvent) {
480
+ try {
481
+ if (entityEvent.type === 'remote-invalidate') {
482
+ // The transport that received the message attaches itself; other providers in the
483
+ // same process are connected to other servers.
484
+ return !entityEvent.provider || entityEvent.provider === this;
485
+ }
486
+ const self = this;
487
+ const saveProvider = entityEvent.baseEntity?.ProviderToUse;
488
+ if (saveProvider instanceof ProviderBase && saveProvider !== self) {
489
+ const theirs = saveProvider.InstanceConnectionString;
490
+ const mine = this.InstanceConnectionString;
491
+ if (theirs && mine && theirs !== mine) {
492
+ return false;
493
+ }
494
+ }
495
+ return true;
496
+ }
497
+ catch {
498
+ return true; // a connection-string getter with no live pool must not veto the refresh
499
+ }
500
+ }
501
+ /**
502
+ * How long a member-entity write waits before this provider's refresh runs. The base value
503
+ * is the short debounce window — right for the server, where the writer is the refresher and
504
+ * the delay only exists to coalesce a burst and let the enclosing transaction commit.
505
+ * Transport providers override this with a much longer, RANDOMIZED window: every browser
506
+ * receives every write broadcast, so the delay is what turns "N clients each re-pull the
507
+ * metadata graph within the same half-second of any member write" into "each client pays at
508
+ * most one staleness check per window, at a moment no other client shares".
509
+ */
510
+ get MetadataMemberRefreshDelayMs() {
511
+ return ProviderBase.MetadataDatasetRefreshDebounceMs;
512
+ }
513
+ /**
514
+ * Whether a member-entity write arriving while the refresh timer is already armed RESTARTS
515
+ * the timer (debounce) or joins the pending window (coalesce/throttle).
516
+ *
517
+ * The base is a true debounce (`true`): the server's refresh must run AFTER the last write
518
+ * of the unit of work, so every event pushes the timer out — a burst costs one refresh, run
519
+ * once the burst ends. Transport providers return `false`: with a long window, re-arming
520
+ * would let steady org-wide write activity postpone the refresh indefinitely (starvation);
521
+ * joining the armed window guarantees at most one refresh per window regardless of write
522
+ * rate, which is the whole point of the window.
523
+ */
524
+ get MetadataMemberRefreshRearmsOnNewEvents() {
525
+ return true;
526
+ }
527
+ /**
528
+ * Schedules this provider's metadata refresh after a write to a metadata member entity.
529
+ * Delay and re-arm semantics come from {@link MetadataMemberRefreshDelayMs} and
530
+ * {@link MetadataMemberRefreshRearmsOnNewEvents} — debounce on the server, long jittered
531
+ * coalescing window on clients. The refresh targets THIS instance — the provider that loaded
532
+ * the dataset owns the metadata built from it; short-lived per-request providers never load
533
+ * the dataset (they adopt the global's metadata as a shared shell), so on the server only
534
+ * the process-global provider ever gets here.
535
+ */
536
+ scheduleMetadataMemberRefresh() {
537
+ if (this._metadataMemberRefreshTimer) {
538
+ if (!this.MetadataMemberRefreshRearmsOnNewEvents) {
539
+ return; // coalesce: this write joins the already-armed window
540
+ }
541
+ clearTimeout(this._metadataMemberRefreshTimer);
542
+ }
543
+ this._metadataMemberRefreshTimer = setTimeout(() => {
544
+ this._metadataMemberRefreshTimer = null;
545
+ this.RefreshAfterMetadataMemberChange().catch((e) => {
546
+ LogError(`Metadata refresh after a member-entity change failed: ${e instanceof Error ? e.message : String(e)}`);
547
+ });
548
+ }, this.MetadataMemberRefreshDelayMs);
549
+ }
550
+ /**
551
+ * How this provider refreshes after a metadata member entity changed. The base behavior is a
552
+ * hard {@link Refresh} — correct for database providers, where the process that PERFORMED the
553
+ * write is the one refreshing, so re-checking staleness first is wasted work and the re-read
554
+ * must bypass every cache layer. Transport providers (GraphQL) override this with a staleness
555
+ * check so a browser doesn't re-pull the full metadata graph for a change the server-side
556
+ * timestamp comparison can disconfirm.
557
+ */
558
+ async RefreshAfterMetadataMemberChange() {
559
+ return this.Refresh();
560
+ }
561
+ /**
562
+ * Cancels any pending debounced metadata refresh. Call during teardown (logout, provider
563
+ * disposal) so a timer armed just before teardown doesn't fire a refresh against a
564
+ * connection that no longer has a valid session.
565
+ */
566
+ CancelPendingMetadataMemberRefresh() {
567
+ if (this._metadataMemberRefreshTimer) {
568
+ clearTimeout(this._metadataMemberRefreshTimer);
569
+ this._metadataMemberRefreshTimer = null;
570
+ }
571
+ }
408
572
  /******** ABSTRACT SECTION ****************************************************************** */
409
573
  /**
410
574
  * When true, cached RunView/RunQuery results are returned immediately on a
@@ -932,7 +1096,10 @@ export class ProviderBase {
932
1096
  const record = result.Results[j];
933
1097
  allResults.push({
934
1098
  EntityName: entity.Name,
935
- RecordID: String(record[entity.FirstPrimaryKey?.Name ?? 'ID'] ?? ''),
1099
+ // Compact CompositeKey segment: the bare value for a single-column key (any
1100
+ // column name), "F1|v1||F2|v2" for a composite key — what
1101
+ // CompositeKey.FromURLSegment(entity, RecordID) reads back.
1102
+ RecordID: CompositeKey.FromEntityRecord(entity, record).ToCompactURLSegment(),
936
1103
  Title: String(record[titleField] ?? 'Untitled'),
937
1104
  Snippet: String(record[snippetField] ?? '').substring(0, 200),
938
1105
  Score: 1.0 / (j + 1) // Rank-based scoring for RRF compatibility
@@ -987,7 +1154,7 @@ export class ProviderBase {
987
1154
  }
988
1155
  // Fallback to first text field
989
1156
  const textField = entity.Fields.find(f => f.Type.toLowerCase().includes('varchar') || f.Type.toLowerCase().includes('text'));
990
- return textField?.Name ?? entity.FirstPrimaryKey?.Name ?? 'ID';
1157
+ return textField?.Name ?? entity.FirstPrimaryKey.Name; // first-pk-ok: display-column fallback for FTS results, not a key construction
991
1158
  }
992
1159
  async RunViewsUncoalesced(params, contextUser) {
993
1160
  const key = this.GenerateDedupKey(params, contextUser);
@@ -1456,7 +1623,9 @@ export class ProviderBase {
1456
1623
  const nameField = entity.NameField?.Name ?? entity.Fields.find(f => f.IsNameField)?.Name ?? null;
1457
1624
  const out = [];
1458
1625
  for (const row of (r.Results ?? [])) {
1459
- const id = String(row['ID'] ?? '');
1626
+ // The entity is arbitrary: read the key off its primary-key metadata, not a hardcoded
1627
+ // `ID` column. Compact segment so single-column keys stay the raw value.
1628
+ const id = CompositeKey.FromEntityRecord(entity, row).ToCompactURLSegment();
1460
1629
  if (!id)
1461
1630
  continue;
1462
1631
  const nameVal = nameField ? String(row[nameField] ?? '').toLowerCase() : '';
@@ -1495,17 +1664,23 @@ export class ProviderBase {
1495
1664
  async searchEntitiesFilterByPermission(entity, ids, contextUser) {
1496
1665
  if (ids.length === 0)
1497
1666
  return new Set();
1498
- const escaped = ids.map(id => `'${id.replace(/'/g, "''")}'`).join(',');
1667
+ // `ids` are compact CompositeKey segments (see searchEntitiesLexicalPass): a single-column
1668
+ // key — whatever the column is called — uses one IN(); a composite key needs one
1669
+ // (F1=.. AND F2=..) term per record. Hardcoding `ID` here returned nothing for every entity
1670
+ // whose key isn't named ID, so SearchEntity silently produced zero results for them.
1671
+ const filter = entity.PrimaryKeys.length === 1
1672
+ ? `${entity.FirstPrimaryKey.Name} IN (${ids.map(id => `'${EscapeSQLString(id)}'`).join(',')})` // first-pk-ok: guarded by PrimaryKeys.length === 1
1673
+ : ids.map(id => `(${CompositeKey.FromURLSegment(entity, id).ToWhereClause()})`).join(' OR ');
1499
1674
  const r = await this.RunView({
1500
1675
  EntityName: entity.Name,
1501
- ExtraFilter: `ID IN (${escaped})`,
1502
- Fields: ['ID'],
1676
+ ExtraFilter: filter,
1677
+ Fields: entity.PrimaryKeys.map(pk => pk.Name),
1503
1678
  ResultType: 'simple',
1504
1679
  MaxRows: ids.length,
1505
1680
  }, contextUser);
1506
1681
  if (!r.Success)
1507
1682
  return new Set();
1508
- return new Set((r.Results ?? []).map(row => row.ID));
1683
+ return new Set((r.Results ?? []).map(row => CompositeKey.FromEntityRecord(entity, row).ToCompactURLSegment()));
1509
1684
  }
1510
1685
  /**
1511
1686
  * Returns true if any param in the batch has SaveViewResults set,
@@ -2402,13 +2577,21 @@ export class ProviderBase {
2402
2577
  else if (checkResult.status === 'differential') {
2403
2578
  // Cache is stale but we have differential data - merge with cached data
2404
2579
  const fingerprint = this.clientCacheFingerprint(param);
2405
- // Get entity info for primary key field name
2580
+ // Every primary key column, in order. The server's `deletedRecordIDs` are the full
2581
+ // RecordChanges.RecordID segments (`F1|v1||F2|v2` for a composite key), and the merge
2582
+ // keys cached and updated rows the same way — so keying on the first column alone made
2583
+ // composite-key deletes never match and collapsed rows sharing that column. An entity the
2584
+ // server described but this provider cannot resolve is not merged: fall through to the
2585
+ // full refetch below rather than inventing an `ID` key.
2406
2586
  const entity = this.EntityByName(param.EntityName);
2407
- const primaryKeyFieldName = entity?.FirstPrimaryKey?.Name || 'ID';
2587
+ const primaryKeyFieldNames = entity?.PrimaryKeys.map(pk => pk.Name) ?? [];
2588
+ if (primaryKeyFieldNames.length === 0) {
2589
+ LogError(`ProviderBase: server returned differential data for '${param.EntityName}' but the entity is not in this provider's metadata — refetching in full.`);
2590
+ }
2408
2591
  // Apply differential update to cache (runViewCacheEligible, not raw CacheLocal — see the
2409
2592
  // cacheable-gate note in prepareSmartCacheCheckParams; keeps Materialized/count_only/etc. out).
2410
- if (this.runViewCacheEligibleForWrite(param) && checkResult.differentialData && LocalCacheManager.Instance.IsInitialized) {
2411
- const merged = await LocalCacheManager.Instance.ApplyDifferentialUpdate(fingerprint, param, checkResult.differentialData.updatedRows, checkResult.differentialData.deletedRecordIDs, primaryKeyFieldName, checkResult.maxUpdatedAt || new Date().toISOString(), checkResult.rowCount || 0, checkResult.aggregateResults, // Pass fresh aggregate results (can't be differentially computed)
2593
+ if (primaryKeyFieldNames.length > 0 && this.runViewCacheEligibleForWrite(param) && checkResult.differentialData && LocalCacheManager.Instance.IsInitialized) {
2594
+ const merged = await LocalCacheManager.Instance.ApplyDifferentialUpdate(fingerprint, param, checkResult.differentialData.updatedRows, checkResult.differentialData.deletedRecordIDs, primaryKeyFieldNames, checkResult.maxUpdatedAt || new Date().toISOString(), checkResult.rowCount || 0, checkResult.aggregateResults, // Pass fresh aggregate results (can't be differentially computed)
2412
2595
  this);
2413
2596
  if (merged) {
2414
2597
  const mergedResult = {
@@ -3357,24 +3540,62 @@ export class ProviderBase {
3357
3540
  // first, make sure we reset the flag to false so that if another call to this function happens
3358
3541
  // while we are waiting for the async call to finish, we dont do it again
3359
3542
  this._refresh = false;
3360
- // Fetch new metadata without clearing current metadata
3361
- // This ensures readers always see valid data (old until new is ready)
3362
- const start = new Date().getTime();
3363
- const res = await this.GetAllMetadata(providerToUse, hardRefresh);
3364
- const end = new Date().getTime();
3365
- LogStatusEx({ message: `GetAllMetadata() took ${end - start} ms`, verboseOnly: true });
3366
- if (res) {
3367
- // Atomic swap via UpdateLocalMetadata: single property assignment is atomic in JavaScript
3368
- // Readers now see new metadata instead of old
3369
- // Uses UpdateLocalMetadata() to maintain consistency with LoadLocalMetadataFromStorage()
3370
- // and allow potential subclass overrides for extensibility
3371
- this.UpdateLocalMetadata(res);
3372
- this._latestLocalMetadataTimestamps = this._latestRemoteMetadataTimestamps; // update this since we just used server to get all the stuff
3373
- await this.SaveLocalMetadataToStorage();
3543
+ // SINGLE-FLIGHT: at most one full metadata reload runs at a time. Without this, a
3544
+ // second refresh request arriving while a reload is still awaiting its queries starts
3545
+ // a CONCURRENT reload, and whichever finishes LAST wins the atomic swap — an older
3546
+ // snapshot can overwrite a newer one. A joiner must not simply await and return,
3547
+ // either: the in-flight reload's queries may predate the write that prompted the
3548
+ // joiner, so it flags ONE follow-up; the loop below reruns after the current pass,
3549
+ // guaranteeing the final swap comes from a read that started after the last request.
3550
+ if (this._metadataReloadInFlight) {
3551
+ this._metadataReloadQueued = true;
3552
+ await this._metadataReloadInFlight;
3553
+ return true;
3374
3554
  }
3375
- else {
3376
- // GetAllMetadata failed - log error but keep existing metadata
3377
- LogError('GetAllMetadata() returned undefined - metadata not updated');
3555
+ this._metadataReloadInFlight = (async () => {
3556
+ let effectiveHardRefresh = hardRefresh;
3557
+ do {
3558
+ this._metadataReloadQueued = false;
3559
+ // The local timestamps must describe the snapshot about to be loaded. On the
3560
+ // hard-refresh path the staleness check was SKIPPED, so the cached remote
3561
+ // timestamps predate this pass — copying them as-is would make the next
3562
+ // periodic check see a mismatch and reload once more for nothing. Re-read
3563
+ // them (one cheap status query, authoritative) BEFORE the load, not after:
3564
+ // a write landing DURING the load then leaves the stamped timestamps looking
3565
+ // stale and the next tick reloads — the safe direction. Reading after could
3566
+ // stamp the snapshot as containing a write it does not.
3567
+ if (effectiveHardRefresh) {
3568
+ await this.RefreshRemoteMetadataTimestamps(providerToUse);
3569
+ }
3570
+ // Fetch new metadata without clearing current metadata
3571
+ // This ensures readers always see valid data (old until new is ready)
3572
+ const start = new Date().getTime();
3573
+ const res = await this.GetAllMetadata(providerToUse, effectiveHardRefresh);
3574
+ const end = new Date().getTime();
3575
+ LogStatusEx({ message: `GetAllMetadata() took ${end - start} ms`, verboseOnly: true });
3576
+ if (res) {
3577
+ // Atomic swap via UpdateLocalMetadata: single property assignment is atomic in JavaScript
3578
+ // Readers now see new metadata instead of old
3579
+ // Uses UpdateLocalMetadata() to maintain consistency with LoadLocalMetadataFromStorage()
3580
+ // and allow potential subclass overrides for extensibility
3581
+ this.UpdateLocalMetadata(res);
3582
+ this._latestLocalMetadataTimestamps = this._latestRemoteMetadataTimestamps; // update this since we just used server to get all the stuff
3583
+ await this.SaveLocalMetadataToStorage();
3584
+ }
3585
+ else {
3586
+ // GetAllMetadata failed - log error but keep existing metadata
3587
+ LogError('GetAllMetadata() returned undefined - metadata not updated');
3588
+ }
3589
+ // A queued follow-up exists only because another refresh request arrived
3590
+ // mid-reload; rerun hard so the re-read cannot be served by any cache layer.
3591
+ effectiveHardRefresh = true;
3592
+ } while (this._metadataReloadQueued);
3593
+ })();
3594
+ try {
3595
+ await this._metadataReloadInFlight;
3596
+ }
3597
+ finally {
3598
+ this._metadataReloadInFlight = null;
3378
3599
  }
3379
3600
  }
3380
3601
  return true;
@@ -3594,6 +3815,10 @@ export class ProviderBase {
3594
3815
  if (d && d.Success) {
3595
3816
  // cache the dataset for anyone who wants to use it
3596
3817
  await this.CacheDataset(ProviderBase._mjMetadataDatasetName, null, d);
3818
+ // Record which entities this metadata is built from, so a write to any of them
3819
+ // schedules a debounced refresh of this provider's metadata (see
3820
+ // registerMetadataDatasetMembership — membership is the dataset definition, not a list).
3821
+ this.registerMetadataDatasetMembership(d);
3597
3822
  // got the results, let's build our response in the format we need
3598
3823
  const simpleMetadata = {};
3599
3824
  for (let r of d.Results) {
@@ -3656,7 +3881,7 @@ export class ProviderBase {
3656
3881
  const result = [];
3657
3882
  // Sort entities alphabetically by name to ensure deterministic ordering
3658
3883
  // This prevents non-deterministic output in CodeGen and other metadata consumers
3659
- const sortedEntities = entities.sort((a, b) => a.Name.localeCompare(b.Name));
3884
+ const sortedEntities = entities.sort((a, b) => ordinalCompare(a.Name, b.Name) || ordinalCompare(a.ID, b.ID));
3660
3885
  if (fieldValues && fieldValues.length > 0) {
3661
3886
  const fieldValuesByFieldId = this.groupByNormalizedUUID(fieldValues, fv => fv.EntityFieldID);
3662
3887
  for (const f of fields) {
@@ -3680,7 +3905,7 @@ export class ProviderBase {
3680
3905
  for (const e of sortedEntities) {
3681
3906
  const entityIdKey = NormalizeUUID(e.ID);
3682
3907
  const entityFields = fieldsByEntityId.get(entityIdKey) || [];
3683
- e.EntityFields = entityFields.sort((a, b) => a.Sequence - b.Sequence);
3908
+ e.EntityFields = entityFields.sort((a, b) => (a.Sequence - b.Sequence) || ordinalCompare(a.Name, b.Name) || ordinalCompare(a.ID, b.ID));
3684
3909
  e.EntityPermissions = permissionsByEntityId.get(entityIdKey) || [];
3685
3910
  e.EntityRelationships = relationshipsByEntityId.get(entityIdKey) || [];
3686
3911
  e.EntitySettings = settingsByEntityId.get(entityIdKey) || [];
@@ -3867,13 +4092,18 @@ export class ProviderBase {
3867
4092
  /**
3868
4093
  * Checks if local metadata is out of date and needs refreshing.
3869
4094
  * Compares local timestamps with server timestamps.
4095
+ * @param bypassMinCheckInterval - When true, skips the {@link MinRefreshCheckIntervalMs}
4096
+ * throttle. Event-driven callers pass true: they hold positive evidence that a metadata
4097
+ * member entity was just written, and the throttle otherwise answers "fresh" for any check
4098
+ * arriving within the window of the previous one — which would silently drop the second of
4099
+ * two permission changes made less than the window apart.
3870
4100
  * @returns True if refresh is needed, false otherwise
3871
4101
  */
3872
- async CheckToSeeIfRefreshNeeded(providerToUse) {
4102
+ async CheckToSeeIfRefreshNeeded(providerToUse, bypassMinCheckInterval) {
3873
4103
  if (!this.AllowRefresh)
3874
4104
  return false;
3875
4105
  const now = Date.now();
3876
- if ((now - this._lastRefreshCheckAt) < ProviderBase.MinRefreshCheckIntervalMs) {
4106
+ if (!bypassMinCheckInterval && (now - this._lastRefreshCheckAt) < ProviderBase.MinRefreshCheckIntervalMs) {
3877
4107
  LogStatusEx({
3878
4108
  message: `[RefreshCheck] Skipped — last check was ${now - this._lastRefreshCheckAt}ms ago (min interval ${ProviderBase.MinRefreshCheckIntervalMs}ms)`,
3879
4109
  verboseOnly: true
@@ -3888,10 +4118,13 @@ export class ProviderBase {
3888
4118
  /**
3889
4119
  * Refreshes metadata only if needed based on timestamp comparison.
3890
4120
  * Combines check and refresh into a single operation.
4121
+ * @param bypassMinCheckInterval - Passed through to {@link CheckToSeeIfRefreshNeeded};
4122
+ * event-driven callers set true so the throttle cannot eat a check they have positive
4123
+ * evidence for.
3891
4124
  * @returns True if refresh was successful or not needed
3892
4125
  */
3893
- async RefreshIfNeeded(providerToUse) {
3894
- if (await this.CheckToSeeIfRefreshNeeded(providerToUse))
4126
+ async RefreshIfNeeded(providerToUse, bypassMinCheckInterval) {
4127
+ if (await this.CheckToSeeIfRefreshNeeded(providerToUse, bypassMinCheckInterval))
3895
4128
  return this.Refresh(providerToUse);
3896
4129
  else
3897
4130
  return true;
@@ -3943,6 +4176,11 @@ export class ProviderBase {
3943
4176
  // to a downstream `.constructor`/`.LoadFromData` crash.
3944
4177
  throw new Error(`Entity '${entityName}' could not be instantiated — MJGlobal ClassFactory returned null. Ensure LoadGeneratedEntities()/LoadCoreEntities() has run so the entity's class is registered.`);
3945
4178
  }
4179
+ // Always rebind. ClassFactory passes `(Entity, this)` into the constructor,
4180
+ // but a 1-arg subclass (`constructor(Entity) { super(Entity); }`) silently
4181
+ // drops the provider. Without this, ProviderToUse falls back to the global
4182
+ // host and a nested save on an independent instance deadlocks on FKs.
4183
+ newObject.BindProvider(this);
3946
4184
  await newObject.Config(actualContextUser);
3947
4185
  // Initialize IS-A parent entity composition chain before any data operations
3948
4186
  await newObject.InitializeParentEntity();
@@ -4361,13 +4599,31 @@ export class ProviderBase {
4361
4599
  const tsKey = this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey;
4362
4600
  const fmtKey = this.LocalStoragePrefix + ProviderBase.localStorageFormatKey;
4363
4601
  const dataKey = this.LocalStoragePrefix + ProviderBase.localStorageAllMetadataKey;
4602
+ const membershipKey = this.LocalStoragePrefix + ProviderBase.localStorageDatasetMembershipKey;
4364
4603
  const readStart = Date.now();
4365
- const all = await ls.GetItems([tsKey, fmtKey, dataKey]);
4604
+ const all = await ls.GetItems([tsKey, fmtKey, dataKey, membershipKey]);
4366
4605
  const readMs = Date.now() - readStart;
4367
4606
  const tsRaw = all.get(tsKey) ?? null;
4368
4607
  const format = all.get(fmtKey) ?? null;
4369
4608
  const raw = all.get(dataKey) ?? null;
4609
+ const membershipRaw = all.get(membershipKey) ?? null;
4370
4610
  this._latestLocalMetadataTimestamps = tsRaw ? JSON.parse(tsRaw) : null;
4611
+ // Restore the metadata-member entity set so the event-driven refresh works on a warm
4612
+ // boot that never calls GetAllMetadata (the stale-while-revalidate fast start). The
4613
+ // set is only stored after a successful dataset load, so restoring it is exactly as
4614
+ // trustworthy as restoring the metadata itself.
4615
+ if (membershipRaw) {
4616
+ try {
4617
+ const names = JSON.parse(membershipRaw);
4618
+ if (Array.isArray(names) && names.length > 0) {
4619
+ this._metadataDatasetEntityNames = new Set(names);
4620
+ this.ensureInflightViewInvalidation();
4621
+ }
4622
+ }
4623
+ catch (membershipErr) {
4624
+ LogError(`[Metadata Cache] Failed to restore dataset membership: ${membershipErr instanceof Error ? membershipErr.message : String(membershipErr)}`);
4625
+ }
4626
+ }
4371
4627
  if (!raw)
4372
4628
  return;
4373
4629
  // Decompress if stored in compressed format, otherwise parse directly
@@ -4413,10 +4669,12 @@ export class ProviderBase {
4413
4669
  static { this.localStorageTimestampsKey = this.localStorageRootKey + '_Timestamps'; }
4414
4670
  static { this.localStorageAllMetadataKey = this.localStorageRootKey + '_AllMetadata'; }
4415
4671
  static { this.localStorageFormatKey = this.localStorageRootKey + '_Format'; }
4672
+ static { this.localStorageDatasetMembershipKey = this.localStorageRootKey + '_DatasetMembership'; }
4416
4673
  static { this.localStorageKeys = [
4417
4674
  ProviderBase.localStorageTimestampsKey,
4418
4675
  ProviderBase.localStorageAllMetadataKey,
4419
4676
  ProviderBase.localStorageFormatKey,
4677
+ ProviderBase.localStorageDatasetMembershipKey,
4420
4678
  ]; }
4421
4679
  /**
4422
4680
  * Converts a base64-encoded string to an ArrayBuffer.
@@ -4460,46 +4718,74 @@ export class ProviderBase {
4460
4718
  if (!ls)
4461
4719
  return;
4462
4720
  const start = Date.now();
4463
- // Save timestamps as a JSON string. The metadata snapshot path intentionally uses
4464
- // string storage so the compressed (gzip+base64) format below can round-trip cleanly
4465
- // through providers that don't support binary natively.
4466
- await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey, JSON.stringify(this._latestLocalMetadataTimestamps));
4467
- // Serialize the AllMetadata object
4721
+ // Serialize the AllMetadata object FIRST. If this throws (or the payload write below
4722
+ // fails), nothing else has been written yet, so the previously stored timestamps still
4723
+ // describe the previously stored payload and LocalMetadataObsolete() will report
4724
+ // obsolete on the next boot — which is what makes the save retry. Writing timestamps
4725
+ // before the payload left the cache claiming freshness with no payload behind it, and
4726
+ // that state was never retried.
4468
4727
  const jsonString = JSON.stringify(this._localMetadata);
4469
- // Attempt compressed storage using native CompressionStream (available in modern browsers and Node 18+)
4470
- if (typeof CompressionStream !== 'undefined') {
4471
- try {
4472
- const blob = new Blob([jsonString]);
4473
- const cs = new CompressionStream('gzip');
4474
- const compressedStream = blob.stream().pipeThrough(cs);
4475
- const compressedBuffer = await new Response(compressedStream).arrayBuffer();
4476
- const base64 = ProviderBase.arrayBufferToBase64(compressedBuffer);
4477
- await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageAllMetadataKey, base64);
4478
- await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageFormatKey, 'gzip');
4479
- const elapsed = Date.now() - start;
4480
- const ratio = jsonString.length > 0 ? (base64.length / jsonString.length * 100).toFixed(1) : '?';
4481
- LogStatusEx({
4482
- message: `[Metadata Cache] Save complete: ${elapsed}ms, raw=${(jsonString.length / 1024 / 1024).toFixed(1)}MB, compressed=${(base64.length / 1024 / 1024).toFixed(1)}MB (${ratio}%)`,
4483
- verboseOnly: true
4484
- });
4485
- return;
4486
- }
4487
- catch (compressErr) {
4488
- // Compression failed fall through to uncompressed save
4489
- LogError(`[Metadata Cache] Compression failed, falling back to uncompressed: ${compressErr instanceof Error ? compressErr.message : String(compressErr)}`);
4490
- }
4728
+ const snapshot = await this.writeMetadataSnapshot(ls, jsonString);
4729
+ // Persist the metadata-member entity set beside the snapshot it belongs to, so the
4730
+ // event-driven refresh survives a warm boot (see LoadLocalMetadataFromStorage).
4731
+ if (this._metadataDatasetEntityNames?.size) {
4732
+ await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageDatasetMembershipKey, JSON.stringify([...this._metadataDatasetEntityNames]));
4733
+ }
4734
+ // Timestamps LAST: they are the freshness claim for everything written above, so they
4735
+ // must be the final thing to land.
4736
+ await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey, JSON.stringify(this._latestLocalMetadataTimestamps));
4737
+ this.logMetadataSaveComplete(Date.now() - start, jsonString.length, snapshot);
4738
+ }
4739
+ catch (e) {
4740
+ LogError(`[Metadata Cache] SaveLocalMetadataToStorage failed: ${e instanceof Error ? e.message : String(e)}`);
4741
+ }
4742
+ }
4743
+ /**
4744
+ * Writes the serialized metadata payload and its format marker. Compressed (gzip+base64) when
4745
+ * CompressionStream is available, otherwise plain JSON. The snapshot path intentionally uses
4746
+ * string storage so the compressed form round-trips cleanly through providers that don't
4747
+ * support binary natively. A compression failure falls back to the uncompressed write; a
4748
+ * failure of the write itself propagates to the caller so nothing after it is stored.
4749
+ */
4750
+ async writeMetadataSnapshot(ls, jsonString) {
4751
+ const dataKey = this.LocalStoragePrefix + ProviderBase.localStorageAllMetadataKey;
4752
+ const formatKey = this.LocalStoragePrefix + ProviderBase.localStorageFormatKey;
4753
+ // Attempt compressed storage using native CompressionStream (available in modern browsers and Node 18+)
4754
+ if (typeof CompressionStream !== 'undefined') {
4755
+ try {
4756
+ const blob = new Blob([jsonString]);
4757
+ const cs = new CompressionStream('gzip');
4758
+ const compressedStream = blob.stream().pipeThrough(cs);
4759
+ const compressedBuffer = await new Response(compressedStream).arrayBuffer();
4760
+ const base64 = ProviderBase.arrayBufferToBase64(compressedBuffer);
4761
+ await ls.SetItem(dataKey, base64);
4762
+ await ls.SetItem(formatKey, 'gzip');
4763
+ return { compressed: true, storedLength: base64.length };
4764
+ }
4765
+ catch (compressErr) {
4766
+ // Compression failed — fall through to uncompressed save
4767
+ LogError(`[Metadata Cache] Compression failed, falling back to uncompressed: ${compressErr instanceof Error ? compressErr.message : String(compressErr)}`);
4491
4768
  }
4492
- // Fallback: uncompressed save (older environments without CompressionStream)
4493
- await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageAllMetadataKey, jsonString);
4494
- await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageFormatKey, 'json');
4495
- const elapsed = Date.now() - start;
4769
+ }
4770
+ // Fallback: uncompressed save (older environments without CompressionStream)
4771
+ await ls.SetItem(dataKey, jsonString);
4772
+ await ls.SetItem(formatKey, 'json');
4773
+ return { compressed: false, storedLength: jsonString.length };
4774
+ }
4775
+ logMetadataSaveComplete(elapsedMs, rawLength, snapshot) {
4776
+ const mb = (n) => (n / 1024 / 1024).toFixed(1);
4777
+ if (snapshot.compressed) {
4778
+ const ratio = rawLength > 0 ? (snapshot.storedLength / rawLength * 100).toFixed(1) : '?';
4496
4779
  LogStatusEx({
4497
- message: `[Metadata Cache] Save complete (uncompressed): ${elapsed}ms, size=${(jsonString.length / 1024 / 1024).toFixed(1)}MB`,
4780
+ message: `[Metadata Cache] Save complete: ${elapsedMs}ms, raw=${mb(rawLength)}MB, compressed=${mb(snapshot.storedLength)}MB (${ratio}%)`,
4498
4781
  verboseOnly: true
4499
4782
  });
4500
4783
  }
4501
- catch (e) {
4502
- LogError(`[Metadata Cache] SaveLocalMetadataToStorage failed: ${e instanceof Error ? e.message : String(e)}`);
4784
+ else {
4785
+ LogStatusEx({
4786
+ message: `[Metadata Cache] Save complete (uncompressed): ${elapsedMs}ms, size=${mb(rawLength)}MB`,
4787
+ verboseOnly: true
4788
+ });
4503
4789
  }
4504
4790
  }
4505
4791
  /**