@memberjunction/core 6.1.0-edge.5 → 6.1.0-edge.7

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 +52 -1
  6. package/dist/generic/baseEngine.d.ts.map +1 -1
  7. package/dist/generic/baseEngine.js +95 -7
  8. package/dist/generic/baseEngine.js.map +1 -1
  9. package/dist/generic/baseEntity.d.ts +292 -0
  10. package/dist/generic/baseEntity.d.ts.map +1 -1
  11. package/dist/generic/baseEntity.js +878 -98
  12. package/dist/generic/baseEntity.js.map +1 -1
  13. package/dist/generic/baseInfo.d.ts +6 -0
  14. package/dist/generic/baseInfo.d.ts.map +1 -1
  15. package/dist/generic/baseInfo.js +19 -1
  16. package/dist/generic/baseInfo.js.map +1 -1
  17. package/dist/generic/compositeKey.d.ts +108 -0
  18. package/dist/generic/compositeKey.d.ts.map +1 -1
  19. package/dist/generic/compositeKey.js +225 -10
  20. package/dist/generic/compositeKey.js.map +1 -1
  21. package/dist/generic/databaseProviderBase.d.ts +49 -0
  22. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  23. package/dist/generic/databaseProviderBase.js +90 -11
  24. package/dist/generic/databaseProviderBase.js.map +1 -1
  25. package/dist/generic/embeddedRecord.d.ts +2 -1
  26. package/dist/generic/embeddedRecord.d.ts.map +1 -1
  27. package/dist/generic/embeddedRecord.js +9 -2
  28. package/dist/generic/embeddedRecord.js.map +1 -1
  29. package/dist/generic/entityCompanion.d.ts +6 -1
  30. package/dist/generic/entityCompanion.d.ts.map +1 -1
  31. package/dist/generic/entityCompanion.js +7 -0
  32. package/dist/generic/entityCompanion.js.map +1 -1
  33. package/dist/generic/entityInfo.d.ts +550 -8
  34. package/dist/generic/entityInfo.d.ts.map +1 -1
  35. package/dist/generic/entityInfo.js +695 -20
  36. package/dist/generic/entityInfo.js.map +1 -1
  37. package/dist/generic/entitySubtypeResolver.d.ts +19 -0
  38. package/dist/generic/entitySubtypeResolver.d.ts.map +1 -0
  39. package/dist/generic/entitySubtypeResolver.js +24 -0
  40. package/dist/generic/entitySubtypeResolver.js.map +1 -0
  41. package/dist/generic/entityTransactionScope.d.ts +6 -3
  42. package/dist/generic/entityTransactionScope.d.ts.map +1 -1
  43. package/dist/generic/entityTransactionScope.js +6 -3
  44. package/dist/generic/entityTransactionScope.js.map +1 -1
  45. package/dist/generic/extendedTypeValue.d.ts +47 -0
  46. package/dist/generic/extendedTypeValue.d.ts.map +1 -0
  47. package/dist/generic/extendedTypeValue.js +152 -0
  48. package/dist/generic/extendedTypeValue.js.map +1 -0
  49. package/dist/generic/filters/compositeFilter.d.ts +77 -0
  50. package/dist/generic/filters/compositeFilter.d.ts.map +1 -0
  51. package/dist/generic/filters/compositeFilter.js +324 -0
  52. package/dist/generic/filters/compositeFilter.js.map +1 -0
  53. package/dist/generic/filters/filter.types.d.ts +29 -0
  54. package/dist/generic/filters/filter.types.d.ts.map +1 -0
  55. package/dist/generic/filters/filter.types.js +28 -0
  56. package/dist/generic/filters/filter.types.js.map +1 -0
  57. package/dist/generic/filters/index.d.ts +3 -0
  58. package/dist/generic/filters/index.d.ts.map +1 -0
  59. package/dist/generic/filters/index.js +3 -0
  60. package/dist/generic/filters/index.js.map +1 -0
  61. package/dist/generic/geoFields.d.ts +64 -0
  62. package/dist/generic/geoFields.d.ts.map +1 -0
  63. package/dist/generic/geoFields.js +108 -0
  64. package/dist/generic/geoFields.js.map +1 -0
  65. package/dist/generic/interfaces.d.ts +16 -2
  66. package/dist/generic/interfaces.d.ts.map +1 -1
  67. package/dist/generic/interfaces.js.map +1 -1
  68. package/dist/generic/localCacheManager.d.ts +34 -3
  69. package/dist/generic/localCacheManager.d.ts.map +1 -1
  70. package/dist/generic/localCacheManager.js +53 -4
  71. package/dist/generic/localCacheManager.js.map +1 -1
  72. package/dist/generic/providerBase.d.ts +282 -5
  73. package/dist/generic/providerBase.d.ts.map +1 -1
  74. package/dist/generic/providerBase.js +754 -93
  75. package/dist/generic/providerBase.js.map +1 -1
  76. package/dist/generic/recordChangeFieldSecurity.d.ts +164 -0
  77. package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -0
  78. package/dist/generic/recordChangeFieldSecurity.js +279 -0
  79. package/dist/generic/recordChangeFieldSecurity.js.map +1 -0
  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/saveEntityGraphOperation.d.ts +10 -0
  87. package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
  88. package/dist/generic/saveEntityGraphOperation.js +2 -1
  89. package/dist/generic/saveEntityGraphOperation.js.map +1 -1
  90. package/dist/generic/wellKnownUserSource.d.ts +70 -0
  91. package/dist/generic/wellKnownUserSource.d.ts.map +1 -0
  92. package/dist/generic/wellKnownUserSource.js +82 -0
  93. package/dist/generic/wellKnownUserSource.js.map +1 -0
  94. package/dist/index.d.ts +7 -0
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +7 -0
  97. package/dist/index.js.map +1 -1
  98. package/package.json +3 -3
@@ -1,13 +1,15 @@
1
1
  import { BaseEntity } from "./baseEntity.js";
2
- import { EntityDocumentTypeInfo, EntityFieldTSType, EntityInfo, EntityPermissionType } from "./entityInfo.js";
2
+ import { EntityDocumentTypeInfo, EntityFieldTSType, EntityInfo, EntityPermissionType, FieldSecurityDenialMessage, FieldSecurityError } from "./entityInfo.js";
3
3
  import { AllMetadata } from "./interfaces.js";
4
+ import { RecordChangeFieldSecurityProjector } from "./recordChangeFieldSecurity.js";
4
5
  import { ComputeRRF } from "./scoring/ReciprocalRankFusion.js";
5
6
  import { LocalCacheManager } from "./localCacheManager.js";
6
7
  import { ApplicationInfo } from "../generic/applicationInfo.js";
7
8
  import { AuditLogTypeInfo, AuthorizationInfo, AuthorizationRoleInfo, RoleInfo, RowLevelSecurityFilterInfo, UserInfo } from "./securityInfo.js";
8
- import { MJGlobal, MJEventType, NormalizeUUID, UUIDsEqual, MJLruCache } from "@memberjunction/global";
9
+ import { MJGlobal, MJEventType, NormalizeUUID, UUIDsEqual, MJLruCache, EscapeSQLString, ordinalCompare } from "@memberjunction/global";
10
+ import { FindReferencedIdentifiers } from "@memberjunction/sql-dialect";
9
11
  import { TelemetryManager } from "./telemetryManager.js";
10
- import { LogError, LogStatus, LogStatusEx } from "./logging.js";
12
+ import { LogDebug, LogError, LogStatus, LogStatusEx } from "./logging.js";
11
13
  import { QueryCategoryInfo, QueryFieldInfo, QueryInfo, QueryPermissionInfo, QueryEntityInfo, QueryParameterInfo, QueryDependencyInfo, SQLDialectInfo, QuerySQLInfo } from "./queryInfo.js";
12
14
  import { LibraryInfo } from "./libraryInfo.js";
13
15
  import { CompositeKey } from "./compositeKey.js";
@@ -189,6 +191,10 @@ export class ProviderBase {
189
191
  // it can't be reset on metadata refresh; it needs its own eviction policy.
190
192
  this._entityRecordNameCache = new MJLruCache({ maxSize: 10000, ttlMs: 60 * 60 * 1000 });
191
193
  this._refresh = false;
194
+ /** Single-flight guard for the full metadata reload in {@link Config} — see the comment there. */
195
+ this._metadataReloadInFlight = null;
196
+ /** Set when a refresh request arrives while a reload is in flight; the reload loop reruns once. */
197
+ this._metadataReloadQueued = false;
192
198
  this._lastRefreshCheckAt = 0;
193
199
  /**
194
200
  * Pending coalesced requests waiting to be flushed.
@@ -213,6 +219,16 @@ export class ProviderBase {
213
219
  */
214
220
  this._inflightViews = new Map();
215
221
  this._lingerInvalidationWired = false;
222
+ /**
223
+ * Lowercased names of the entities whose rows COMPOSE this provider's metadata — the
224
+ * MJ_Metadata dataset's item entities, recorded each time the dataset is loaded (and restored
225
+ * from local storage on warm boot). Membership is DATA: adding a `DatasetItem` row to
226
+ * MJ_Metadata extends refresh coverage with no code change, which is why no entity names are
227
+ * hardcoded anywhere in this mechanism.
228
+ */
229
+ this._metadataDatasetEntityNames = null;
230
+ /** Debounce timer for {@link scheduleMetadataMemberRefresh}. */
231
+ this._metadataMemberRefreshTimer = null;
216
232
  this._clientFingerprintMemo = new WeakMap();
217
233
  this._cachedVisibleExplorerNavigationItems = null;
218
234
  }
@@ -355,6 +371,7 @@ export class ProviderBase {
355
371
  continue;
356
372
  }
357
373
  instance.invalidateInflightViewsForEntity(entityName);
374
+ instance.handleMetadataMemberEntityEvent(entityName, entityEvent);
358
375
  }
359
376
  });
360
377
  }
@@ -405,6 +422,155 @@ export class ProviderBase {
405
422
  }
406
423
  return names;
407
424
  }
425
+ // ── Metadata-Dataset Membership Refresh ────────────────────────────
426
+ /**
427
+ * Coalescing window, in milliseconds, for metadata refreshes triggered by writes to the
428
+ * entities that compose this provider's metadata. A single administrative action produces a
429
+ * burst (enabling field security writes one permission row per field/role pair, each raising
430
+ * its own event) — one refresh per burst, not one per row. The window also gives an enclosing
431
+ * entity transaction time to COMMIT before the re-read: every event in the burst re-arms the
432
+ * timer, so the refresh runs no earlier than this long after the LAST write.
433
+ */
434
+ static { this.MetadataDatasetRefreshDebounceMs = 500; }
435
+ /**
436
+ * Records which entities compose this provider's metadata, from the loaded MJ_Metadata
437
+ * dataset result, and registers this instance with the static event fan-out so writes to any
438
+ * of them schedule a debounced metadata refresh. Called from {@link GetAllMetadata} on every
439
+ * successful load, so the set tracks the dataset definition as it changes.
440
+ */
441
+ registerMetadataDatasetMembership(dataset) {
442
+ const names = new Set();
443
+ for (const item of dataset.Results ?? []) {
444
+ const n = item.EntityName?.trim().toLowerCase();
445
+ if (n) {
446
+ names.add(n);
447
+ }
448
+ }
449
+ if (names.size === 0) {
450
+ return; // a failed/empty load must not erase a previously recorded set
451
+ }
452
+ this._metadataDatasetEntityNames = names;
453
+ this.ensureInflightViewInvalidation();
454
+ }
455
+ /**
456
+ * Static fan-out callback: a BaseEntity save/delete (or a remote-invalidate from another
457
+ * server) touched `lowerEntityName`. If that entity is one of the entities this provider's
458
+ * metadata is BUILT FROM, the metadata this provider is serving — and, on the server, the
459
+ * metadata every per-request provider adopts from it — is now stale, so schedule a debounced
460
+ * refresh. Permission metadata is the load-bearing case: field-level security is enforced
461
+ * FROM metadata at every enforcement point, so a rule an administrator just tightened is
462
+ * simply not enforced until this re-read happens.
463
+ */
464
+ handleMetadataMemberEntityEvent(lowerEntityName, entityEvent) {
465
+ if (!this._metadataDatasetEntityNames?.has(lowerEntityName)) {
466
+ return; // the overwhelming majority of writes
467
+ }
468
+ if (!this.eventTargetsThisProviderBackend(entityEvent)) {
469
+ return;
470
+ }
471
+ this.scheduleMetadataMemberRefresh();
472
+ }
473
+ /**
474
+ * Whether the write described by `entityEvent` happened against the backend THIS provider's
475
+ * metadata comes from. In a multi-provider process (a client connected to several MJ servers,
476
+ * a server connected to several databases) a write on one backend must not refresh another's
477
+ * metadata. Deliberately fails OPEN — when the event does not identify its provider, or a
478
+ * connection string is unavailable, the answer is "yes": a spurious refresh is a bounded
479
+ * cost, a suppressed one is a stale-permissions window.
480
+ */
481
+ eventTargetsThisProviderBackend(entityEvent) {
482
+ try {
483
+ if (entityEvent.type === 'remote-invalidate') {
484
+ // The transport that received the message attaches itself; other providers in the
485
+ // same process are connected to other servers.
486
+ return !entityEvent.provider || entityEvent.provider === this;
487
+ }
488
+ const self = this;
489
+ const saveProvider = entityEvent.baseEntity?.ProviderToUse;
490
+ if (saveProvider instanceof ProviderBase && saveProvider !== self) {
491
+ const theirs = saveProvider.InstanceConnectionString;
492
+ const mine = this.InstanceConnectionString;
493
+ if (theirs && mine && theirs !== mine) {
494
+ return false;
495
+ }
496
+ }
497
+ return true;
498
+ }
499
+ catch {
500
+ return true; // a connection-string getter with no live pool must not veto the refresh
501
+ }
502
+ }
503
+ /**
504
+ * How long a member-entity write waits before this provider's refresh runs. The base value
505
+ * is the short debounce window — right for the server, where the writer is the refresher and
506
+ * the delay only exists to coalesce a burst and let the enclosing transaction commit.
507
+ * Transport providers override this with a much longer, RANDOMIZED window: every browser
508
+ * receives every write broadcast, so the delay is what turns "N clients each re-pull the
509
+ * metadata graph within the same half-second of any member write" into "each client pays at
510
+ * most one staleness check per window, at a moment no other client shares".
511
+ */
512
+ get MetadataMemberRefreshDelayMs() {
513
+ return ProviderBase.MetadataDatasetRefreshDebounceMs;
514
+ }
515
+ /**
516
+ * Whether a member-entity write arriving while the refresh timer is already armed RESTARTS
517
+ * the timer (debounce) or joins the pending window (coalesce/throttle).
518
+ *
519
+ * The base is a true debounce (`true`): the server's refresh must run AFTER the last write
520
+ * of the unit of work, so every event pushes the timer out — a burst costs one refresh, run
521
+ * once the burst ends. Transport providers return `false`: with a long window, re-arming
522
+ * would let steady org-wide write activity postpone the refresh indefinitely (starvation);
523
+ * joining the armed window guarantees at most one refresh per window regardless of write
524
+ * rate, which is the whole point of the window.
525
+ */
526
+ get MetadataMemberRefreshRearmsOnNewEvents() {
527
+ return true;
528
+ }
529
+ /**
530
+ * Schedules this provider's metadata refresh after a write to a metadata member entity.
531
+ * Delay and re-arm semantics come from {@link MetadataMemberRefreshDelayMs} and
532
+ * {@link MetadataMemberRefreshRearmsOnNewEvents} — debounce on the server, long jittered
533
+ * coalescing window on clients. The refresh targets THIS instance — the provider that loaded
534
+ * the dataset owns the metadata built from it; short-lived per-request providers never load
535
+ * the dataset (they adopt the global's metadata as a shared shell), so on the server only
536
+ * the process-global provider ever gets here.
537
+ */
538
+ scheduleMetadataMemberRefresh() {
539
+ if (this._metadataMemberRefreshTimer) {
540
+ if (!this.MetadataMemberRefreshRearmsOnNewEvents) {
541
+ return; // coalesce: this write joins the already-armed window
542
+ }
543
+ clearTimeout(this._metadataMemberRefreshTimer);
544
+ }
545
+ this._metadataMemberRefreshTimer = setTimeout(() => {
546
+ this._metadataMemberRefreshTimer = null;
547
+ this.RefreshAfterMetadataMemberChange().catch((e) => {
548
+ LogError(`Metadata refresh after a member-entity change failed: ${e instanceof Error ? e.message : String(e)}`);
549
+ });
550
+ }, this.MetadataMemberRefreshDelayMs);
551
+ }
552
+ /**
553
+ * How this provider refreshes after a metadata member entity changed. The base behavior is a
554
+ * hard {@link Refresh} — correct for database providers, where the process that PERFORMED the
555
+ * write is the one refreshing, so re-checking staleness first is wasted work and the re-read
556
+ * must bypass every cache layer. Transport providers (GraphQL) override this with a staleness
557
+ * check so a browser doesn't re-pull the full metadata graph for a change the server-side
558
+ * timestamp comparison can disconfirm.
559
+ */
560
+ async RefreshAfterMetadataMemberChange() {
561
+ return this.Refresh();
562
+ }
563
+ /**
564
+ * Cancels any pending debounced metadata refresh. Call during teardown (logout, provider
565
+ * disposal) so a timer armed just before teardown doesn't fire a refresh against a
566
+ * connection that no longer has a valid session.
567
+ */
568
+ CancelPendingMetadataMemberRefresh() {
569
+ if (this._metadataMemberRefreshTimer) {
570
+ clearTimeout(this._metadataMemberRefreshTimer);
571
+ this._metadataMemberRefreshTimer = null;
572
+ }
573
+ }
408
574
  /******** ABSTRACT SECTION ****************************************************************** */
409
575
  /**
410
576
  * When true, cached RunView/RunQuery results are returned immediately on a
@@ -932,7 +1098,10 @@ export class ProviderBase {
932
1098
  const record = result.Results[j];
933
1099
  allResults.push({
934
1100
  EntityName: entity.Name,
935
- RecordID: String(record[entity.FirstPrimaryKey?.Name ?? 'ID'] ?? ''),
1101
+ // Compact CompositeKey segment: the bare value for a single-column key (any
1102
+ // column name), "F1|v1||F2|v2" for a composite key — what
1103
+ // CompositeKey.FromURLSegment(entity, RecordID) reads back.
1104
+ RecordID: CompositeKey.FromEntityRecord(entity, record).ToCompactURLSegment(),
936
1105
  Title: String(record[titleField] ?? 'Untitled'),
937
1106
  Snippet: String(record[snippetField] ?? '').substring(0, 200),
938
1107
  Score: 1.0 / (j + 1) // Rank-based scoring for RRF compatibility
@@ -987,7 +1156,7 @@ export class ProviderBase {
987
1156
  }
988
1157
  // Fallback to first text field
989
1158
  const textField = entity.Fields.find(f => f.Type.toLowerCase().includes('varchar') || f.Type.toLowerCase().includes('text'));
990
- return textField?.Name ?? entity.FirstPrimaryKey?.Name ?? 'ID';
1159
+ return textField?.Name ?? entity.FirstPrimaryKey.Name; // first-pk-ok: display-column fallback for FTS results, not a key construction
991
1160
  }
992
1161
  async RunViewsUncoalesced(params, contextUser) {
993
1162
  const key = this.GenerateDedupKey(params, contextUser);
@@ -1077,6 +1246,8 @@ export class ProviderBase {
1077
1246
  param.ResultType !== 'count_only' &&
1078
1247
  (param.CacheLocal === true || this.TrustLocalCacheCompletely) &&
1079
1248
  this.IsServerCacheAllowedForEntity(param);
1249
+ // Deliberately not user-dependent: server slots are full-width and shared, so field
1250
+ // security never changes whether a query is cacheable, only what is projected out of it.
1080
1251
  }
1081
1252
  /** True when the entity is a CodeGen materialized-query wrapper (materialized_vw*) whose snapshot is
1082
1253
  * refreshed out-of-band — the same one IsServerCacheAllowedForEntity excludes from the server cache. */
@@ -1221,7 +1392,13 @@ export class ProviderBase {
1221
1392
  if (memoized) {
1222
1393
  return memoized;
1223
1394
  }
1224
- const base = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString);
1395
+ // The fls: segment participates in CLIENT slot identity too, keyed on the ALLOWED list
1396
+ // (see ComputeClientFLSAllowedKey). Without it, a user whose field access is TIGHTENED
1397
+ // keeps being served their persisted IndexedDB slot: the currency check compares only
1398
+ // maxUpdatedAt and rowCount, neither of which notices a column, so the server answers
1399
+ // "current" and the browser shows a column that was just taken away — until the rows
1400
+ // change. Empty for unrestricted users, whose fingerprints are unchanged.
1401
+ const base = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString, undefined, this.ComputeClientFLSAllowedKey(param));
1225
1402
  // Normalize a FULL-COVERAGE field list to '*' — in the FINGERPRINT only (B44).
1226
1403
  //
1227
1404
  // entity_object params get widened to an explicit list of every entity field
@@ -1456,7 +1633,9 @@ export class ProviderBase {
1456
1633
  const nameField = entity.NameField?.Name ?? entity.Fields.find(f => f.IsNameField)?.Name ?? null;
1457
1634
  const out = [];
1458
1635
  for (const row of (r.Results ?? [])) {
1459
- const id = String(row['ID'] ?? '');
1636
+ // The entity is arbitrary: read the key off its primary-key metadata, not a hardcoded
1637
+ // `ID` column. Compact segment so single-column keys stay the raw value.
1638
+ const id = CompositeKey.FromEntityRecord(entity, row).ToCompactURLSegment();
1460
1639
  if (!id)
1461
1640
  continue;
1462
1641
  const nameVal = nameField ? String(row[nameField] ?? '').toLowerCase() : '';
@@ -1495,17 +1674,23 @@ export class ProviderBase {
1495
1674
  async searchEntitiesFilterByPermission(entity, ids, contextUser) {
1496
1675
  if (ids.length === 0)
1497
1676
  return new Set();
1498
- const escaped = ids.map(id => `'${id.replace(/'/g, "''")}'`).join(',');
1677
+ // `ids` are compact CompositeKey segments (see searchEntitiesLexicalPass): a single-column
1678
+ // key — whatever the column is called — uses one IN(); a composite key needs one
1679
+ // (F1=.. AND F2=..) term per record. Hardcoding `ID` here returned nothing for every entity
1680
+ // whose key isn't named ID, so SearchEntity silently produced zero results for them.
1681
+ const filter = entity.PrimaryKeys.length === 1
1682
+ ? `${entity.FirstPrimaryKey.Name} IN (${ids.map(id => `'${EscapeSQLString(id)}'`).join(',')})` // first-pk-ok: guarded by PrimaryKeys.length === 1
1683
+ : ids.map(id => `(${CompositeKey.FromURLSegment(entity, id).ToWhereClause()})`).join(' OR ');
1499
1684
  const r = await this.RunView({
1500
1685
  EntityName: entity.Name,
1501
- ExtraFilter: `ID IN (${escaped})`,
1502
- Fields: ['ID'],
1686
+ ExtraFilter: filter,
1687
+ Fields: entity.PrimaryKeys.map(pk => pk.Name),
1503
1688
  ResultType: 'simple',
1504
1689
  MaxRows: ids.length,
1505
1690
  }, contextUser);
1506
1691
  if (!r.Success)
1507
1692
  return new Set();
1508
- return new Set((r.Results ?? []).map(row => row.ID));
1693
+ return new Set((r.Results ?? []).map(row => CompositeKey.FromEntityRecord(entity, row).ToCompactURLSegment()));
1509
1694
  }
1510
1695
  /**
1511
1696
  * Returns true if any param in the batch has SaveViewResults set,
@@ -1909,6 +2094,70 @@ export class ProviderBase {
1909
2094
  return '';
1910
2095
  return entity.GetEffectiveRowFilterWhereClause(user, EntityPermissionType.Read, '');
1911
2096
  }
2097
+ /**
2098
+ * The CLIENT's field-security cache key: a canonical list of the fields this user MAY read.
2099
+ *
2100
+ * Keyed on the ALLOWED set rather than the denied set for two reasons. Once metadata ships
2101
+ * to browsers filtered to what a user may see ([#3485](https://github.com/MemberJunction/MJ/issues/3485)),
2102
+ * a denied field will not appear in the client's field list at all — so a denied-set key
2103
+ * would be empty and would silently stop segmenting. The allowed list also resolves the
2104
+ * `f:*` ambiguity in the client's projection segment, where "full width" means different
2105
+ * columns for different users.
2106
+ *
2107
+ * Returns '' when the entity has field security off or the user is denied nothing, so
2108
+ * unrestricted users keep byte-identical fingerprints and shared slots.
2109
+ *
2110
+ * Only takes effect once the client's metadata refreshes. A client on stale metadata
2111
+ * computes a stale key; the backstop is that the server strips denied columns from every
2112
+ * fresh fetch regardless.
2113
+ */
2114
+ ComputeClientFLSAllowedKey(params) {
2115
+ const user = this.CurrentUser;
2116
+ if (!user || !params.EntityName)
2117
+ return '';
2118
+ const entity = this.EntityByName(params.EntityName);
2119
+ if (!entity?.EnableFieldLevelSecurity)
2120
+ return '';
2121
+ const denied = entity.GetDeniedReadFields(user);
2122
+ if (denied.size === 0)
2123
+ return '';
2124
+ return entity.Fields
2125
+ .map(f => f.Name.trim().toLowerCase())
2126
+ .filter(n => !denied.has(n))
2127
+ .sort()
2128
+ .join(',');
2129
+ }
2130
+ /**
2131
+ * The field-security segment for a {@link LocalCacheManager} RunView fingerprint — the one
2132
+ * place the client/server asymmetry is decided, so the two tiers cannot drift.
2133
+ *
2134
+ * **SERVER → no segment.** Its slots are full-width and shared by every user; per-request
2135
+ * narrowing happens at read time in {@link ApplyFieldSecurityProjection}, which runs on
2136
+ * every hit and every miss. A segment here would fragment one shared slot into one per
2137
+ * permission class and protect nothing the projection does not already handle.
2138
+ *
2139
+ * **CLIENT → the allowed-list key.** Its slots are stored exactly as the server returned
2140
+ * them (already narrowed on the wire) and are not projected on read, so slot identity has to
2141
+ * carry the field set. Without it, a user whose access is tightened keeps being served their
2142
+ * persisted IndexedDB slot: the currency check compares `maxUpdatedAt` and `rowCount` only,
2143
+ * neither of which notices a column, so the server answers "current" and the browser keeps
2144
+ * showing a column that was just taken away.
2145
+ *
2146
+ * Empty/undefined for unrestricted users on both tiers, so their fingerprints stay
2147
+ * byte-identical and keep sharing slots.
2148
+ */
2149
+ ComputeRunViewFLSFingerprintKey(params) {
2150
+ return this.TrustLocalCacheCompletely ? undefined : this.ComputeClientFLSAllowedKey(params);
2151
+ }
2152
+ /**
2153
+ * The fetch-widening field list for a cache-eligible request: always every entity field.
2154
+ * One slot per (entity, filter, order) serves every caller regardless of the field subset
2155
+ * they asked for, and field security narrows per request at read time via
2156
+ * {@link ApplyFieldSecurityProjection}.
2157
+ */
2158
+ ComputeRunViewFetchFields(entity) {
2159
+ return entity.Fields.map(f => f.Name);
2160
+ }
1912
2161
  /**
1913
2162
  * Pre-processing hook for RunView.
1914
2163
  * Handles telemetry, validation, entity status check, and cache lookup.
@@ -1916,6 +2165,210 @@ export class ProviderBase {
1916
2165
  * @param contextUser - Optional user context
1917
2166
  * @returns Pre-processing result with cache status and optional cached result
1918
2167
  */
2168
+ // ========================================================================
2169
+ // FIELD-LEVEL SECURITY
2170
+ // ========================================================================
2171
+ /**
2172
+ * Rejects a RunView whose `ExtraFilter`, `OrderBy`, or `Aggregates` expressions reference a
2173
+ * field the user cannot read.
2174
+ *
2175
+ * Output projection alone is security theater. A user denied `Salary` can send
2176
+ * `ExtraFilter: "Salary > 200000"` or `OrderBy: "Salary DESC"` and reconstruct the values
2177
+ * from which rows come back and in what order — the column never appears in a result, so
2178
+ * every output-stripping point reports "secure." Aggregates are the same channel in a purer
2179
+ * form: `Aggregates: [{expression: 'MIN(Salary)'}]` under a narrow filter returns a denied
2180
+ * field's exact values directly. Predicate validation is a first-class enforcement point,
2181
+ * not a belt-and-braces afterthought. Together these cover every caller-authored expression
2182
+ * surface (`UserSearchString` is handled by excluding denied fields from the searched set,
2183
+ * not by rejection — see below).
2184
+ *
2185
+ * Lives at the provider layer rather than in the GraphQL resolver (where the plan first
2186
+ * placed it) because every RunView funnels through here — the batch path, server-internal
2187
+ * agents and actions running under a restricted `contextUser`, and the resolver alike. One
2188
+ * gate, no path left uncovered.
2189
+ *
2190
+ * The error deliberately does not say whether the field is missing or merely forbidden —
2191
+ * see {@link ProviderBase.FieldSecurityDenialMessage}.
2192
+ */
2193
+ AssertPredicatesRespectFieldSecurity(params, contextUser) {
2194
+ if (!contextUser || !params.EntityName) {
2195
+ return;
2196
+ }
2197
+ const entity = this.EntityByName(params.EntityName);
2198
+ if (!entity?.EnableFieldLevelSecurity) {
2199
+ return; // the overwhelmingly common case — one boolean, no allocation
2200
+ }
2201
+ const denied = entity.GetDeniedReadFields(contextUser);
2202
+ if (denied.size === 0) {
2203
+ return;
2204
+ }
2205
+ // Map lowercased denied names back to their real casing for the message.
2206
+ const deniedByName = new Map();
2207
+ for (const field of entity.Fields) {
2208
+ if (denied.has(field.Name.trim().toLowerCase())) {
2209
+ deniedByName.set(field.Name.trim().toLowerCase(), field.Name);
2210
+ }
2211
+ }
2212
+ // Aliases are deliberately not scanned — an alias names the output column, no data
2213
+ // flows through it.
2214
+ const clauses = [
2215
+ params.ExtraFilter,
2216
+ params.OrderBy,
2217
+ ...(params.Aggregates ?? []).map(a => a?.expression),
2218
+ ];
2219
+ for (const clause of clauses) {
2220
+ if (typeof clause !== 'string' || clause.length === 0) {
2221
+ continue;
2222
+ }
2223
+ const hits = FindReferencedIdentifiers(clause, deniedByName.values());
2224
+ if (hits.length > 0) {
2225
+ LogDebug(`[FieldSecurity] Rejected RunView on '${entity.Name}' for user ${contextUser.Email}: ` +
2226
+ `predicate references denied field(s) ${hits.join(', ')}`);
2227
+ throw new FieldSecurityError(hits[0], entity.Name);
2228
+ }
2229
+ }
2230
+ // UserSearchString is not a predicate the caller authored — it is a search term the
2231
+ // platform expands across IncludeInUserSearchAPI fields. There is nothing to reject;
2232
+ // the correct handling is to not search the denied fields, which the SQL builder does
2233
+ // by consulting the same denied set. Rejecting here would make the search box fail
2234
+ // for a term that merely happens to match a restricted field's name.
2235
+ }
2236
+ /**
2237
+ * The single wording for "you cannot use this field," modeled on SQL Server's posture of
2238
+ * never disclosing whether an object is missing or merely inaccessible.
2239
+ *
2240
+ * Naming the field is safe — the caller supplied it, so it tells them nothing they did not
2241
+ * already know. Naming the REASON is not: confirming "this field exists and is restricted"
2242
+ * turns any predicate into an oracle for probing which columns a deployment considers
2243
+ * sensitive. The ambiguity also keeps this message correct after
2244
+ * [#3485](https://github.com/MemberJunction/MJ/issues/3485) tiers metadata and restricted
2245
+ * fields stop shipping to clients at all, at which point "does not exist" becomes literally
2246
+ * true from the client's vantage point.
2247
+ */
2248
+ static FieldSecurityDenialMessage(fieldName, entityName) {
2249
+ return FieldSecurityDenialMessage(fieldName, entityName);
2250
+ }
2251
+ /**
2252
+ * Strips fields the user cannot read from plain-object result rows.
2253
+ *
2254
+ * This is the primary read-time control for list results, and it runs on BOTH cache hits
2255
+ * and cache misses — the property the rest of the cache design is arranged around. Because
2256
+ * it reads live metadata, a permission change takes effect on the next metadata refresh
2257
+ * with no result-cache invalidation: the cached full-width superset stays valid and only
2258
+ * the projection changes.
2259
+ *
2260
+ * It composes with two per-request, never-cached narrowings that avoid pulling columns a
2261
+ * restricted service account has no business holding: the `simple`-path SELECT-list
2262
+ * intersection, and `Load()`'s allowed-column SELECT.
2263
+ *
2264
+ * NEVER applied to `entity_object` results. Those become `BaseEntity` instances whose
2265
+ * fields round-trip through `GenerateSaveSQL`, which iterates ALL `IsSPParameter` fields
2266
+ * reading `field.Value` — not just dirty ones. A stripped field would therefore be written
2267
+ * back as a real NULL on the user's next save: silent data loss. Entity objects keep their
2268
+ * values in server memory exactly as encrypted fields do; the trust boundary is the API
2269
+ * output, which the GraphQL layer enforces separately.
2270
+ */
2271
+ ApplyFieldSecurityProjection(rows, params, contextUser) {
2272
+ if (!rows?.length || !contextUser || !params.EntityName || params.ResultType === 'entity_object') {
2273
+ return rows;
2274
+ }
2275
+ const entity = this.EntityByName(params.EntityName);
2276
+ if (!entity?.EnableFieldLevelSecurity) {
2277
+ return rows;
2278
+ }
2279
+ const denied = entity.GetDeniedReadFields(contextUser);
2280
+ if (denied.size === 0) {
2281
+ return rows;
2282
+ }
2283
+ return ProviderBase.OmitFieldsFromRows(rows, denied);
2284
+ }
2285
+ /**
2286
+ * Row-loop half of {@link ApplyFieldSecurityProjection}, split out so the policy above reads
2287
+ * as policy. Mirrors {@link ProjectRowsToFields}' matching semantics (trim + lowercase) and
2288
+ * its no-op probe: when the first row carries none of the denied keys, the whole result is
2289
+ * returned untouched rather than rebuilt row by row.
2290
+ *
2291
+ * @param deniedLowercase field names to omit, already lowercased
2292
+ */
2293
+ static OmitFieldsFromRows(rows, deniedLowercase) {
2294
+ const probe = rows[0];
2295
+ if (!probe || typeof probe !== 'object') {
2296
+ return rows;
2297
+ }
2298
+ const keysToOmit = Object.keys(probe).filter(k => deniedLowercase.has(k.trim().toLowerCase()));
2299
+ if (keysToOmit.length === 0) {
2300
+ return rows;
2301
+ }
2302
+ const omit = new Set(keysToOmit);
2303
+ return rows.map((row) => {
2304
+ const source = row;
2305
+ const filtered = {};
2306
+ for (const key of Object.keys(source)) {
2307
+ if (!omit.has(key)) {
2308
+ filtered[key] = source[key];
2309
+ }
2310
+ }
2311
+ return filtered;
2312
+ });
2313
+ }
2314
+ /**
2315
+ * Strips or narrows the `MJ: Record Changes` payload columns a user may not read, using the
2316
+ * denied set of the entity each row is ABOUT rather than of Record Changes itself.
2317
+ *
2318
+ * A sibling of {@link ApplyFieldSecurityProjection} rather than part of it, because that
2319
+ * method short-circuits on `EnableFieldLevelSecurity` for the entity named in the RunView
2320
+ * params — which here is `MJ: Record Changes`, whose flag is off in every default deployment.
2321
+ * Everything about the per-row denied set, the payload treatment, and the fail-closed decision
2322
+ * lives in {@link RecordChangeFieldSecurityProjector}; this is only the RunView wiring.
2323
+ *
2324
+ * Runs at all four RunView projection points, matching the main projection: both cache-hit
2325
+ * paths and both cache-miss paths. The cache-hit path is not optional — it is the exact path
2326
+ * the original cross-user leak runs through, an unrestricted user warming a full-width slot
2327
+ * that a restricted user then hits.
2328
+ *
2329
+ * NEVER applied to `entity_object` results, for the reason the main projection is exempt plus
2330
+ * one specific to this one. The main reason transfers directly: an entity object's fields
2331
+ * round-trip through `GenerateSaveSQL`, which reads EVERY `IsSPParameter` field's value rather
2332
+ * than only dirty ones, so a withheld `ChangesDescription` would be written back as a real
2333
+ * NULL and a narrowed `ChangesJSON` as the narrowed payload — destroying audit history instead
2334
+ * of merely hiding it. Record Changes rows genuinely are saved through the entity layer
2335
+ * (replay writes `Status`/`ErrorLog`, users write `Comments`), so this is not hypothetical.
2336
+ * The additional reason is that the exemption cannot become a hole: the GraphQL RunView
2337
+ * resolver coerces `entity_object` to `simple` on the wire, so an `entity_object` Record
2338
+ * Changes result is by construction server-internal — and server-internal code holding full
2339
+ * values in memory is the documented trust boundary (FLS guide §3.4), exactly as for
2340
+ * encrypted fields.
2341
+ */
2342
+ ApplyRecordChangeFieldSecurityProjection(rows, params, contextUser) {
2343
+ if (!rows?.length || !contextUser || params.ResultType === 'entity_object') {
2344
+ return rows;
2345
+ }
2346
+ if (!RecordChangeFieldSecurityProjector.IsRecordChangesEntity(this.ResolveRunViewEntitySync(params))) {
2347
+ return rows;
2348
+ }
2349
+ return new RecordChangeFieldSecurityProjector(this, contextUser).ProjectRows(rows);
2350
+ }
2351
+ /**
2352
+ * The entity a RunView targets, resolved with NO I/O — for gates that run on the result path,
2353
+ * where an async `RunView.GetEntityNameFromRunViewParams` (which issues a User Views query for
2354
+ * a bare `ViewID`) would be a query per result set.
2355
+ *
2356
+ * Covers the two shapes every caller in this repository uses: an explicit `EntityName`, and a
2357
+ * loaded `ViewEntity`. A request carrying only `ViewID`/`ViewName` with neither is not
2358
+ * resolvable here and returns undefined — the same shape `RunView`'s own row normalization
2359
+ * already declines to handle for the same reason, and the same one
2360
+ * {@link cacheDeniedForViewOnlyRequest} exists to fail closed for on the cache path.
2361
+ */
2362
+ ResolveRunViewEntitySync(params) {
2363
+ if (params.EntityName) {
2364
+ return this.EntityByName(params.EntityName);
2365
+ }
2366
+ // Weak typing on ViewEntity is deliberate and pre-existing: this is MJCore, so it cannot
2367
+ // import the UserView subclass from core-entities without a circular dependency. Same
2368
+ // idiom as cacheDeniedForViewOnlyRequest and RunView.GetEntityNameFromRunViewParams.
2369
+ const viewEntityID = params.ViewEntity?.Get('EntityID');
2370
+ return typeof viewEntityID === 'string' ? this.EntityByID(viewEntityID) : undefined;
2371
+ }
1919
2372
  async PreRunView(params, contextUser) {
1920
2373
  const preViewStart = performance.now();
1921
2374
  // Resolve any PlatformSQL values to plain strings for the active platform
@@ -1924,6 +2377,10 @@ export class ProviderBase {
1924
2377
  // Hooks run after PlatformSQL resolution so they see plain-string filters,
1925
2378
  // and before cache fingerprinting so injected filters affect the cache key.
1926
2379
  params = await this.RunPreRunViewHooks(params, contextUser);
2380
+ // Field-level security: reject predicates referencing unreadable fields. Runs AFTER
2381
+ // hooks so injected filters are scanned too, and BEFORE the cache lookup so a rejected
2382
+ // request never consults (or warms) a cache slot.
2383
+ this.AssertPredicatesRespectFieldSecurity(params, contextUser);
1927
2384
  // Start telemetry tracking
1928
2385
  const telemetryStart = performance.now();
1929
2386
  // After ResolvePlatformSQLInParams, ExtraFilter/OrderBy are guaranteed to be strings
@@ -1956,13 +2413,23 @@ export class ProviderBase {
1956
2413
  let callerRequestedFields = params.Fields && params.Fields.length > 0
1957
2414
  ? params.Fields.map(f => f.trim().toLowerCase())
1958
2415
  : null; // null = caller wants all fields
1959
- // Only override Fields to all entity fields when caching will actually happen
2416
+ // Only override Fields to the widened fetch set when caching will actually happen
1960
2417
  // for this call. For non-cached calls we respect the caller's narrow Fields
1961
- // end-to-end — there's no cache-coherence concern to preserve.
2418
+ // end-to-end — there's no cache-coherence concern to preserve. The widened set is
2419
+ // ALL entity fields, for every user: the server cache holds full-width slots and
2420
+ // narrows per request at read time (ApplyFieldSecurityProjection).
1962
2421
  const entity = params.EntityName ? this.EntityByName(params.EntityName) : null;
1963
2422
  const willCache = this.runViewCacheEligible(params);
1964
- if (entity && willCache) {
1965
- params.Fields = entity.Fields.map(f => f.Name);
2423
+ // entity_object ALWAYS widens, cache-eligible or not: a BaseEntity must hydrate from
2424
+ // every column it is entitled to, or it round-trips partial state into a save. This
2425
+ // must happen here rather than only in the server's PreProcessRunView, which is not on
2426
+ // the client's path — a client entity_object request without CacheLocal would otherwise
2427
+ // ship the caller's narrow Fields over the wire and materialize partial entities. What a
2428
+ // field-restricted user RECEIVES is still their allowed set: the server strips denied
2429
+ // columns on the wire, and the missing keys mark those fields not-loaded.
2430
+ const widenForEntityObject = params.ResultType === 'entity_object';
2431
+ if (entity && (willCache || widenForEntityObject)) {
2432
+ params.Fields = this.ComputeRunViewFetchFields(entity);
1966
2433
  // Platform contract: explicit Fields always include the primary key(s) —
1967
2434
  // project back down to requested ∪ PK, matching the direct SQL path.
1968
2435
  if (callerRequestedFields) {
@@ -1988,7 +2455,8 @@ export class ProviderBase {
1988
2455
  this.cacheDeniedForViewOnlyRequest(params, contextUser);
1989
2456
  if (willCache && !cacheReadDenied && LocalCacheManager.Instance.IsInitialized) {
1990
2457
  const rlsWhereClause = this.ComputeRunViewRLSWhereClause(params, contextUser);
1991
- fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, rlsWhereClause);
2458
+ const flsFieldsKey = this.ComputeRunViewFLSFingerprintKey(params);
2459
+ fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, rlsWhereClause, undefined, flsFieldsKey);
1992
2460
  const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
1993
2461
  if (cached) {
1994
2462
  // These rows are the cache's shared, deep-frozen objects — the runtime freeze is
@@ -1998,6 +2466,17 @@ export class ProviderBase {
1998
2466
  if (callerRequestedFields && params.ResultType !== 'entity_object') {
1999
2467
  results = ProjectRowsToFields(results, callerRequestedFields);
2000
2468
  }
2469
+ // Field-level security MUST be applied here, not only in PostRunView: this path
2470
+ // RETURNS (see RunView) without ever calling PostRunView, and it is the exact
2471
+ // path the cross-user leak runs through — an unrestricted user warms the cache
2472
+ // with every column, then a restricted user hits that same slot. The cache is
2473
+ // column-agnostic by design (one entry serves every field subset), so the
2474
+ // caller's Fields list is unrelated to security and cannot be relied on.
2475
+ results = this.ApplyFieldSecurityProjection(results, params, contextUser);
2476
+ // Record Changes payload security, on the same cache-hit path and for the same
2477
+ // reason: the slot is shared and full-width, so whoever warmed it decides nothing
2478
+ // about who may read what out of it.
2479
+ results = this.ApplyRecordChangeFieldSecurityProjection(results, params, contextUser);
2001
2480
  // Reconstruct RunViewResult from cached data
2002
2481
  cachedResult = {
2003
2482
  Success: true,
@@ -2050,6 +2529,10 @@ export class ProviderBase {
2050
2529
  // Run registered PreRunView hooks on each param in the batch
2051
2530
  for (let i = 0; i < params.length; i++) {
2052
2531
  params[i] = await this.RunPreRunViewHooks(params[i], contextUser);
2532
+ // Field-level security predicate gate, same contract as the single-view path.
2533
+ // One bad view rejects the whole batch — a batch is one caller request, and
2534
+ // silently returning partial results would hide the denial.
2535
+ this.AssertPredicatesRespectFieldSecurity(params[i], contextUser);
2053
2536
  }
2054
2537
  // Start telemetry tracking for batch operation
2055
2538
  const fromEngine = params.some(p => p._fromEngine);
@@ -2112,13 +2595,16 @@ export class ProviderBase {
2112
2595
  let callerFields = param.Fields && param.Fields.length > 0
2113
2596
  ? param.Fields.map(f => f.trim().toLowerCase())
2114
2597
  : null;
2115
- // Only override Fields to all entity fields when caching will actually happen
2598
+ // Only override Fields to the widened fetch set when caching will actually happen
2116
2599
  // for this call. For non-cached calls we respect the caller's narrow Fields
2117
- // end-to-end — there's no cache-coherence concern to preserve.
2600
+ // end-to-end — there's no cache-coherence concern to preserve. Widened = ALL
2601
+ // fields, for every user (see PreRunView).
2118
2602
  const batchEntity = param.EntityName ? this.EntityByName(param.EntityName) : null;
2119
2603
  const batchWillCache = this.runViewCacheEligible(param);
2120
- if (batchEntity && batchWillCache) {
2121
- param.Fields = batchEntity.Fields.map(f => f.Name);
2604
+ // Same entity_object-always-widens rule as the single-view path above.
2605
+ const batchWidenForEntityObject = param.ResultType === 'entity_object';
2606
+ if (batchEntity && (batchWillCache || batchWidenForEntityObject)) {
2607
+ param.Fields = this.ComputeRunViewFetchFields(batchEntity);
2122
2608
  // Platform contract: explicit Fields always include the primary key(s)
2123
2609
  if (callerFields) {
2124
2610
  callerFields = ProviderBase.UnionFieldsWithPrimaryKeys(callerFields, batchEntity);
@@ -2138,7 +2624,8 @@ export class ProviderBase {
2138
2624
  this.cacheDeniedForViewOnlyRequest(param, contextUser);
2139
2625
  if (batchWillCache && !batchCacheReadDenied && LocalCacheManager.Instance.IsInitialized) {
2140
2626
  const rlsWhereClause = this.ComputeRunViewRLSWhereClause(param, contextUser);
2141
- const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString, rlsWhereClause);
2627
+ const flsFieldsKey = this.ComputeRunViewFLSFingerprintKey(param);
2628
+ const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(param, this.InstanceConnectionString, rlsWhereClause, undefined, flsFieldsKey);
2142
2629
  fingerprintMap.set(i, fingerprint);
2143
2630
  const cached = await LocalCacheManager.Instance.GetRunViewResult(fingerprint);
2144
2631
  if (cached) {
@@ -2147,6 +2634,11 @@ export class ProviderBase {
2147
2634
  if (callerFields && param.ResultType !== 'entity_object') {
2148
2635
  results = ProjectRowsToFields(results, callerFields);
2149
2636
  }
2637
+ // Field-level security on the batch cache-hit path — same reasoning as the
2638
+ // single-view path in PreRunView: this result is returned without
2639
+ // PostRunViews projecting it, and the cached superset is column-agnostic.
2640
+ results = this.ApplyFieldSecurityProjection(results, param, contextUser);
2641
+ results = this.ApplyRecordChangeFieldSecurityProjection(results, param, contextUser);
2150
2642
  const cachedViewResult = {
2151
2643
  Success: true,
2152
2644
  Results: results,
@@ -2402,13 +2894,21 @@ export class ProviderBase {
2402
2894
  else if (checkResult.status === 'differential') {
2403
2895
  // Cache is stale but we have differential data - merge with cached data
2404
2896
  const fingerprint = this.clientCacheFingerprint(param);
2405
- // Get entity info for primary key field name
2897
+ // Every primary key column, in order. The server's `deletedRecordIDs` are the full
2898
+ // RecordChanges.RecordID segments (`F1|v1||F2|v2` for a composite key), and the merge
2899
+ // keys cached and updated rows the same way — so keying on the first column alone made
2900
+ // composite-key deletes never match and collapsed rows sharing that column. An entity the
2901
+ // server described but this provider cannot resolve is not merged: fall through to the
2902
+ // full refetch below rather than inventing an `ID` key.
2406
2903
  const entity = this.EntityByName(param.EntityName);
2407
- const primaryKeyFieldName = entity?.FirstPrimaryKey?.Name || 'ID';
2904
+ const primaryKeyFieldNames = entity?.PrimaryKeys.map(pk => pk.Name) ?? [];
2905
+ if (primaryKeyFieldNames.length === 0) {
2906
+ LogError(`ProviderBase: server returned differential data for '${param.EntityName}' but the entity is not in this provider's metadata — refetching in full.`);
2907
+ }
2408
2908
  // Apply differential update to cache (runViewCacheEligible, not raw CacheLocal — see the
2409
2909
  // 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)
2910
+ if (primaryKeyFieldNames.length > 0 && this.runViewCacheEligibleForWrite(param) && checkResult.differentialData && LocalCacheManager.Instance.IsInitialized) {
2911
+ 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
2912
  this);
2413
2913
  if (merged) {
2414
2914
  const mergedResult = {
@@ -2564,11 +3064,11 @@ export class ProviderBase {
2564
3064
  const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
2565
3065
  await LocalCacheManager.Instance.SetRunViewResult(preResult.fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
2566
3066
  }
2567
- else if (this.shouldAutoCache(params, result)) {
3067
+ else if (this.shouldAutoCache(params, result, contextUser)) {
2568
3068
  // Server-side auto-cache: small, unfiltered, unsorted results are
2569
3069
  // automatically cached even without explicit CacheLocal. These are
2570
3070
  // safe for in-place upsert on entity changes (no filter to evaluate).
2571
- const fingerprint = preResult.fingerprint || LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, this.ComputeRunViewRLSWhereClause(params, contextUser));
3071
+ const fingerprint = preResult.fingerprint || LocalCacheManager.Instance.GenerateRunViewFingerprint(params, this.InstanceConnectionString, this.ComputeRunViewRLSWhereClause(params, contextUser), undefined, this.ComputeRunViewFLSFingerprintKey(params));
2572
3072
  const maxUpdatedAt = this.extractMaxUpdatedAt(result.Results);
2573
3073
  await LocalCacheManager.Instance.SetRunViewResult(fingerprint, params, result.Results, maxUpdatedAt, result.AggregateResults, result.TotalRowCount, this);
2574
3074
  LogStatusEx({ message: ` 📦 [Auto-Cache] RunView "${params.EntityName || params.ViewName || 'unknown'}" — ${result.Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
@@ -2582,6 +3082,15 @@ export class ProviderBase {
2582
3082
  if (result.Success && preResult.callerRequestedFields && params.ResultType !== 'entity_object') {
2583
3083
  result.Results = ProjectRowsToFields(result.Results, preResult.callerRequestedFields);
2584
3084
  }
3085
+ // Field-level security on the cache-MISS path (the hit path is enforced in PreRunView,
3086
+ // which returns before this hook ever runs). Must come AFTER the cache write above so
3087
+ // the cache keeps the universal superset — permission changes then take effect on the
3088
+ // next metadata refresh with no result-cache invalidation, because visibility is a pure
3089
+ // read-time projection rather than something baked into the cached rows.
3090
+ if (result.Success) {
3091
+ result.Results = this.ApplyFieldSecurityProjection(result.Results, params, contextUser);
3092
+ result.Results = this.ApplyRecordChangeFieldSecurityProjection(result.Results, params, contextUser);
3093
+ }
2585
3094
  // Transform the result set into BaseEntity-derived objects, if needed
2586
3095
  await this.TransformSimpleObjectToEntityObject(params, result, contextUser);
2587
3096
  // Run registered PostRunView hooks (e.g., data masking, audit logging).
@@ -2636,7 +3145,7 @@ export class ProviderBase {
2636
3145
  // per item. Compute lazily only for indexes PreRunViews skipped (cache
2637
3146
  // was disabled for them but auto-cache/OnDataChanged may still need it).
2638
3147
  const fingerprint = preResult.fingerprintMap?.get(i)
2639
- ?? LocalCacheManager.Instance.GenerateRunViewFingerprint(params[i], this.InstanceConnectionString, this.ComputeRunViewRLSWhereClause(params[i], contextUser));
3148
+ ?? LocalCacheManager.Instance.GenerateRunViewFingerprint(params[i], this.InstanceConnectionString, this.ComputeRunViewRLSWhereClause(params[i], contextUser), undefined, this.ComputeRunViewFLSFingerprintKey(params[i]));
2640
3149
  // CRITICAL: must be the SAME eligibility predicate PreRunViews used to decide
2641
3150
  // whether to widen Fields. Writing a non-widened (narrow or keyset-paged)
2642
3151
  // result here poisons the Fields-agnostic superset slot — this exact gate
@@ -2645,7 +3154,7 @@ export class ProviderBase {
2645
3154
  const maxUpdatedAt = this.extractMaxUpdatedAt(results[i].Results);
2646
3155
  cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount, this));
2647
3156
  }
2648
- else if (this.shouldAutoCache(params[i], results[i])) {
3157
+ else if (this.shouldAutoCache(params[i], results[i], contextUser)) {
2649
3158
  const maxUpdatedAt = this.extractMaxUpdatedAt(results[i].Results);
2650
3159
  cachePromises.push(LocalCacheManager.Instance.SetRunViewResult(fingerprint, params[i], results[i].Results, maxUpdatedAt, results[i].AggregateResults, results[i].TotalRowCount, this));
2651
3160
  LogStatusEx({ message: ` 📦 [Auto-Cache] RunViews "${params[i].EntityName || params[i].ViewName || 'unknown'}" — ${results[i].Results.length} rows auto-cached (small + unfiltered)`, verboseOnly: true });
@@ -2674,6 +3183,18 @@ export class ProviderBase {
2674
3183
  }
2675
3184
  }
2676
3185
  }
3186
+ // Field-level security on the batch cache-MISS results. Deliberately a separate loop
3187
+ // from the projection above rather than folded into it: that one is gated on
3188
+ // callerFieldsMap (present only when Fields were actually widened), while this must run
3189
+ // for every uncached result regardless of whether the caller narrowed Fields. Cache hits
3190
+ // are skipped because PreRunViews already projected them.
3191
+ for (let i = 0; i < results.length; i++) {
3192
+ if (preResult.cacheStatusMap?.get(i)?.status === 'hit' || !results[i].Success) {
3193
+ continue;
3194
+ }
3195
+ results[i].Results = this.ApplyFieldSecurityProjection(results[i].Results, params[i], contextUser);
3196
+ results[i].Results = this.ApplyRecordChangeFieldSecurityProjection(results[i].Results, params[i], contextUser);
3197
+ }
2677
3198
  // Transform results to entity objects AFTER caching plain objects.
2678
3199
  // Skip results that came from cache hits — they're already entity objects.
2679
3200
  const transformPromises = [];
@@ -2881,15 +3402,16 @@ export class ProviderBase {
2881
3402
  return false;
2882
3403
  return entity.TrustServerCacheCompletely !== false;
2883
3404
  }
2884
- shouldAutoCache(params, result) {
3405
+ shouldAutoCache(params, result, contextUser) {
2885
3406
  if (!this.TrustLocalCacheCompletely)
2886
3407
  return false;
2887
3408
  if (params.CacheLocal)
2888
3409
  return false; // already handled
2889
3410
  // Same eligibility predicate as the main cache path — covers BypassCache,
2890
- // AfterKey (keyset pages), count_only, and cache-disallowed entities. An
2891
- // auto-cached keyset page or count_only result would poison the
2892
- // entity+filter slot just like the main-path variants of those bugs.
3411
+ // AfterKey (keyset pages), count_only, cache-disallowed entities, and the
3412
+ // field-security entity_object exemption. An auto-cached keyset page or
3413
+ // count_only result would poison the entity+filter slot just like the
3414
+ // main-path variants of those bugs.
2893
3415
  if (!this.runViewCacheEligible(params))
2894
3416
  return false;
2895
3417
  if (!LocalCacheManager.Instance.IsInitialized)
@@ -3357,24 +3879,62 @@ export class ProviderBase {
3357
3879
  // first, make sure we reset the flag to false so that if another call to this function happens
3358
3880
  // while we are waiting for the async call to finish, we dont do it again
3359
3881
  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();
3882
+ // SINGLE-FLIGHT: at most one full metadata reload runs at a time. Without this, a
3883
+ // second refresh request arriving while a reload is still awaiting its queries starts
3884
+ // a CONCURRENT reload, and whichever finishes LAST wins the atomic swap — an older
3885
+ // snapshot can overwrite a newer one. A joiner must not simply await and return,
3886
+ // either: the in-flight reload's queries may predate the write that prompted the
3887
+ // joiner, so it flags ONE follow-up; the loop below reruns after the current pass,
3888
+ // guaranteeing the final swap comes from a read that started after the last request.
3889
+ if (this._metadataReloadInFlight) {
3890
+ this._metadataReloadQueued = true;
3891
+ await this._metadataReloadInFlight;
3892
+ return true;
3374
3893
  }
3375
- else {
3376
- // GetAllMetadata failed - log error but keep existing metadata
3377
- LogError('GetAllMetadata() returned undefined - metadata not updated');
3894
+ this._metadataReloadInFlight = (async () => {
3895
+ let effectiveHardRefresh = hardRefresh;
3896
+ do {
3897
+ this._metadataReloadQueued = false;
3898
+ // The local timestamps must describe the snapshot about to be loaded. On the
3899
+ // hard-refresh path the staleness check was SKIPPED, so the cached remote
3900
+ // timestamps predate this pass — copying them as-is would make the next
3901
+ // periodic check see a mismatch and reload once more for nothing. Re-read
3902
+ // them (one cheap status query, authoritative) BEFORE the load, not after:
3903
+ // a write landing DURING the load then leaves the stamped timestamps looking
3904
+ // stale and the next tick reloads — the safe direction. Reading after could
3905
+ // stamp the snapshot as containing a write it does not.
3906
+ if (effectiveHardRefresh) {
3907
+ await this.RefreshRemoteMetadataTimestamps(providerToUse);
3908
+ }
3909
+ // Fetch new metadata without clearing current metadata
3910
+ // This ensures readers always see valid data (old until new is ready)
3911
+ const start = new Date().getTime();
3912
+ const res = await this.GetAllMetadata(providerToUse, effectiveHardRefresh);
3913
+ const end = new Date().getTime();
3914
+ LogStatusEx({ message: `GetAllMetadata() took ${end - start} ms`, verboseOnly: true });
3915
+ if (res) {
3916
+ // Atomic swap via UpdateLocalMetadata: single property assignment is atomic in JavaScript
3917
+ // Readers now see new metadata instead of old
3918
+ // Uses UpdateLocalMetadata() to maintain consistency with LoadLocalMetadataFromStorage()
3919
+ // and allow potential subclass overrides for extensibility
3920
+ this.UpdateLocalMetadata(res);
3921
+ this._latestLocalMetadataTimestamps = this._latestRemoteMetadataTimestamps; // update this since we just used server to get all the stuff
3922
+ await this.SaveLocalMetadataToStorage();
3923
+ }
3924
+ else {
3925
+ // GetAllMetadata failed - log error but keep existing metadata
3926
+ LogError('GetAllMetadata() returned undefined - metadata not updated');
3927
+ }
3928
+ // A queued follow-up exists only because another refresh request arrived
3929
+ // mid-reload; rerun hard so the re-read cannot be served by any cache layer.
3930
+ effectiveHardRefresh = true;
3931
+ } while (this._metadataReloadQueued);
3932
+ })();
3933
+ try {
3934
+ await this._metadataReloadInFlight;
3935
+ }
3936
+ finally {
3937
+ this._metadataReloadInFlight = null;
3378
3938
  }
3379
3939
  }
3380
3940
  return true;
@@ -3402,6 +3962,7 @@ export class ProviderBase {
3402
3962
  }
3403
3963
  else {
3404
3964
  LogStatusEx({ message: `⚡ [Metadata Cache] Background check: metadata is current — no refresh needed`, verboseOnly: false });
3965
+ await this.RefreshCurrentUser();
3405
3966
  }
3406
3967
  }
3407
3968
  catch (e) {
@@ -3445,6 +4006,9 @@ export class ProviderBase {
3445
4006
  }
3446
4007
  else {
3447
4008
  LogStatusEx({ message: `⚡ [Metadata Cache] Pre-validation: metadata is current`, verboseOnly: false });
4009
+ // Even when entity metadata is current, refresh CurrentUser so user roles and permissions
4010
+ // stay in sync with the database without requiring a schema change or manual cache clear.
4011
+ await this.RefreshCurrentUser();
3448
4012
  }
3449
4013
  }
3450
4014
  catch (e) {
@@ -3594,13 +4158,17 @@ export class ProviderBase {
3594
4158
  if (d && d.Success) {
3595
4159
  // cache the dataset for anyone who wants to use it
3596
4160
  await this.CacheDataset(ProviderBase._mjMetadataDatasetName, null, d);
4161
+ // Record which entities this metadata is built from, so a write to any of them
4162
+ // schedules a debounced refresh of this provider's metadata (see
4163
+ // registerMetadataDatasetMembership — membership is the dataset definition, not a list).
4164
+ this.registerMetadataDatasetMembership(d);
3597
4165
  // got the results, let's build our response in the format we need
3598
4166
  const simpleMetadata = {};
3599
4167
  for (let r of d.Results) {
3600
4168
  simpleMetadata[r.Code] = r.Results;
3601
4169
  }
3602
4170
  // Post Process Entities because there's some special handling of the sub-objects
3603
- simpleMetadata.AllEntities = this.PostProcessEntityMetadata(simpleMetadata.Entities, simpleMetadata.EntityFields, simpleMetadata.EntityFieldValues, simpleMetadata.EntityPermissions, simpleMetadata.EntityRelationships, simpleMetadata.EntitySettings, simpleMetadata.EntityOrganicKeys, simpleMetadata.EntityOrganicKeyRelatedEntities);
4171
+ simpleMetadata.AllEntities = this.PostProcessEntityMetadata(simpleMetadata.Entities, simpleMetadata.EntityFields, simpleMetadata.EntityFieldValues, simpleMetadata.EntityPermissions, simpleMetadata.EntityRelationships, simpleMetadata.EntitySettings, simpleMetadata.EntityOrganicKeys, simpleMetadata.EntityOrganicKeyRelatedEntities, simpleMetadata.EntityFieldPermissions);
3604
4172
  // Post Process the Applications, because we want to handle the sub-objects properly.
3605
4173
  simpleMetadata.AllApplications = simpleMetadata.Applications.map((a) => {
3606
4174
  a.ApplicationEntities = simpleMetadata.ApplicationEntities.filter((ae) => UUIDsEqual(ae.ApplicationID, a.ID));
@@ -3623,6 +4191,25 @@ export class ProviderBase {
3623
4191
  LogError(e);
3624
4192
  }
3625
4193
  }
4194
+ /**
4195
+ * Refreshes the CurrentUser from the server and updates local metadata in place.
4196
+ * Useful on warm boot or when user roles/permissions change dynamically without
4197
+ * entity schema changes.
4198
+ */
4199
+ async RefreshCurrentUser() {
4200
+ try {
4201
+ const user = await this.GetCurrentUser();
4202
+ if (user && this._localMetadata) {
4203
+ this._localMetadata.CurrentUser = user;
4204
+ void this.SaveLocalMetadataToStorage();
4205
+ return user;
4206
+ }
4207
+ }
4208
+ catch (e) {
4209
+ LogError(`[Metadata Cache] RefreshCurrentUser failed: ${e instanceof Error ? e.message : String(e)}`);
4210
+ }
4211
+ return null;
4212
+ }
3626
4213
  /**
3627
4214
  * Post-processes entity metadata to establish relationships between entities and their child objects.
3628
4215
  * Links fields, permissions, relationships, and settings to their parent entities.
@@ -3652,17 +4239,30 @@ export class ProviderBase {
3652
4239
  }
3653
4240
  return map;
3654
4241
  }
3655
- PostProcessEntityMetadata(entities, fields, fieldValues, permissions, relationships, settings, organicKeys, organicKeyRelatedEntities) {
4242
+ PostProcessEntityMetadata(entities, fields, fieldValues, permissions, relationships, settings, organicKeys, organicKeyRelatedEntities, fieldPermissions) {
3656
4243
  const result = [];
3657
4244
  // Sort entities alphabetically by name to ensure deterministic ordering
3658
4245
  // This prevents non-deterministic output in CodeGen and other metadata consumers
3659
- const sortedEntities = entities.sort((a, b) => a.Name.localeCompare(b.Name));
4246
+ const sortedEntities = entities.sort((a, b) => ordinalCompare(a.Name, b.Name) || ordinalCompare(a.ID, b.ID));
3660
4247
  if (fieldValues && fieldValues.length > 0) {
3661
4248
  const fieldValuesByFieldId = this.groupByNormalizedUUID(fieldValues, fv => fv.EntityFieldID);
3662
4249
  for (const f of fields) {
3663
4250
  f.EntityFieldValues = fieldValuesByFieldId.get(NormalizeUUID(f.ID)) || [];
3664
4251
  }
3665
4252
  }
4253
+ // Link field-level security records to their fields. Optional and typically absent:
4254
+ // the dataset item is only present on databases that have it, and the array is empty
4255
+ // in every deployment that has not configured field security — so the whole pass is
4256
+ // skipped rather than assigning an empty array to thousands of field rows.
4257
+ if (fieldPermissions && fieldPermissions.length > 0) {
4258
+ const fieldPermissionsByFieldId = this.groupByNormalizedUUID(fieldPermissions, fp => fp.EntityFieldID);
4259
+ for (const f of fields) {
4260
+ const fieldPerms = fieldPermissionsByFieldId.get(NormalizeUUID(f.ID));
4261
+ if (fieldPerms) {
4262
+ f.EntityFieldPermissions = fieldPerms;
4263
+ }
4264
+ }
4265
+ }
3666
4266
  // Link organic key related entities to their parent organic keys
3667
4267
  if (organicKeys && organicKeyRelatedEntities && organicKeyRelatedEntities.length > 0) {
3668
4268
  const okreByOrganicKeyId = this.groupByNormalizedUUID(organicKeyRelatedEntities, okre => okre.EntityOrganicKeyID);
@@ -3680,7 +4280,7 @@ export class ProviderBase {
3680
4280
  for (const e of sortedEntities) {
3681
4281
  const entityIdKey = NormalizeUUID(e.ID);
3682
4282
  const entityFields = fieldsByEntityId.get(entityIdKey) || [];
3683
- e.EntityFields = entityFields.sort((a, b) => a.Sequence - b.Sequence);
4283
+ e.EntityFields = entityFields.sort((a, b) => (a.Sequence - b.Sequence) || ordinalCompare(a.Name, b.Name) || ordinalCompare(a.ID, b.ID));
3684
4284
  e.EntityPermissions = permissionsByEntityId.get(entityIdKey) || [];
3685
4285
  e.EntityRelationships = relationshipsByEntityId.get(entityIdKey) || [];
3686
4286
  e.EntitySettings = settingsByEntityId.get(entityIdKey) || [];
@@ -3867,13 +4467,18 @@ export class ProviderBase {
3867
4467
  /**
3868
4468
  * Checks if local metadata is out of date and needs refreshing.
3869
4469
  * Compares local timestamps with server timestamps.
4470
+ * @param bypassMinCheckInterval - When true, skips the {@link MinRefreshCheckIntervalMs}
4471
+ * throttle. Event-driven callers pass true: they hold positive evidence that a metadata
4472
+ * member entity was just written, and the throttle otherwise answers "fresh" for any check
4473
+ * arriving within the window of the previous one — which would silently drop the second of
4474
+ * two permission changes made less than the window apart.
3870
4475
  * @returns True if refresh is needed, false otherwise
3871
4476
  */
3872
- async CheckToSeeIfRefreshNeeded(providerToUse) {
4477
+ async CheckToSeeIfRefreshNeeded(providerToUse, bypassMinCheckInterval) {
3873
4478
  if (!this.AllowRefresh)
3874
4479
  return false;
3875
4480
  const now = Date.now();
3876
- if ((now - this._lastRefreshCheckAt) < ProviderBase.MinRefreshCheckIntervalMs) {
4481
+ if (!bypassMinCheckInterval && (now - this._lastRefreshCheckAt) < ProviderBase.MinRefreshCheckIntervalMs) {
3877
4482
  LogStatusEx({
3878
4483
  message: `[RefreshCheck] Skipped — last check was ${now - this._lastRefreshCheckAt}ms ago (min interval ${ProviderBase.MinRefreshCheckIntervalMs}ms)`,
3879
4484
  verboseOnly: true
@@ -3888,10 +4493,13 @@ export class ProviderBase {
3888
4493
  /**
3889
4494
  * Refreshes metadata only if needed based on timestamp comparison.
3890
4495
  * Combines check and refresh into a single operation.
4496
+ * @param bypassMinCheckInterval - Passed through to {@link CheckToSeeIfRefreshNeeded};
4497
+ * event-driven callers set true so the throttle cannot eat a check they have positive
4498
+ * evidence for.
3891
4499
  * @returns True if refresh was successful or not needed
3892
4500
  */
3893
- async RefreshIfNeeded(providerToUse) {
3894
- if (await this.CheckToSeeIfRefreshNeeded(providerToUse))
4501
+ async RefreshIfNeeded(providerToUse, bypassMinCheckInterval) {
4502
+ if (await this.CheckToSeeIfRefreshNeeded(providerToUse, bypassMinCheckInterval))
3895
4503
  return this.Refresh(providerToUse);
3896
4504
  else
3897
4505
  return true;
@@ -3943,6 +4551,11 @@ export class ProviderBase {
3943
4551
  // to a downstream `.constructor`/`.LoadFromData` crash.
3944
4552
  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
4553
  }
4554
+ // Always rebind. ClassFactory passes `(Entity, this)` into the constructor,
4555
+ // but a 1-arg subclass (`constructor(Entity) { super(Entity); }`) silently
4556
+ // drops the provider. Without this, ProviderToUse falls back to the global
4557
+ // host and a nested save on an independent instance deadlocks on FKs.
4558
+ newObject.BindProvider(this);
3946
4559
  await newObject.Config(actualContextUser);
3947
4560
  // Initialize IS-A parent entity composition chain before any data operations
3948
4561
  await newObject.InitializeParentEntity();
@@ -4361,13 +4974,31 @@ export class ProviderBase {
4361
4974
  const tsKey = this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey;
4362
4975
  const fmtKey = this.LocalStoragePrefix + ProviderBase.localStorageFormatKey;
4363
4976
  const dataKey = this.LocalStoragePrefix + ProviderBase.localStorageAllMetadataKey;
4977
+ const membershipKey = this.LocalStoragePrefix + ProviderBase.localStorageDatasetMembershipKey;
4364
4978
  const readStart = Date.now();
4365
- const all = await ls.GetItems([tsKey, fmtKey, dataKey]);
4979
+ const all = await ls.GetItems([tsKey, fmtKey, dataKey, membershipKey]);
4366
4980
  const readMs = Date.now() - readStart;
4367
4981
  const tsRaw = all.get(tsKey) ?? null;
4368
4982
  const format = all.get(fmtKey) ?? null;
4369
4983
  const raw = all.get(dataKey) ?? null;
4984
+ const membershipRaw = all.get(membershipKey) ?? null;
4370
4985
  this._latestLocalMetadataTimestamps = tsRaw ? JSON.parse(tsRaw) : null;
4986
+ // Restore the metadata-member entity set so the event-driven refresh works on a warm
4987
+ // boot that never calls GetAllMetadata (the stale-while-revalidate fast start). The
4988
+ // set is only stored after a successful dataset load, so restoring it is exactly as
4989
+ // trustworthy as restoring the metadata itself.
4990
+ if (membershipRaw) {
4991
+ try {
4992
+ const names = JSON.parse(membershipRaw);
4993
+ if (Array.isArray(names) && names.length > 0) {
4994
+ this._metadataDatasetEntityNames = new Set(names);
4995
+ this.ensureInflightViewInvalidation();
4996
+ }
4997
+ }
4998
+ catch (membershipErr) {
4999
+ LogError(`[Metadata Cache] Failed to restore dataset membership: ${membershipErr instanceof Error ? membershipErr.message : String(membershipErr)}`);
5000
+ }
5001
+ }
4371
5002
  if (!raw)
4372
5003
  return;
4373
5004
  // Decompress if stored in compressed format, otherwise parse directly
@@ -4413,10 +5044,12 @@ export class ProviderBase {
4413
5044
  static { this.localStorageTimestampsKey = this.localStorageRootKey + '_Timestamps'; }
4414
5045
  static { this.localStorageAllMetadataKey = this.localStorageRootKey + '_AllMetadata'; }
4415
5046
  static { this.localStorageFormatKey = this.localStorageRootKey + '_Format'; }
5047
+ static { this.localStorageDatasetMembershipKey = this.localStorageRootKey + '_DatasetMembership'; }
4416
5048
  static { this.localStorageKeys = [
4417
5049
  ProviderBase.localStorageTimestampsKey,
4418
5050
  ProviderBase.localStorageAllMetadataKey,
4419
5051
  ProviderBase.localStorageFormatKey,
5052
+ ProviderBase.localStorageDatasetMembershipKey,
4420
5053
  ]; }
4421
5054
  /**
4422
5055
  * Converts a base64-encoded string to an ArrayBuffer.
@@ -4460,46 +5093,74 @@ export class ProviderBase {
4460
5093
  if (!ls)
4461
5094
  return;
4462
5095
  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
5096
+ // Serialize the AllMetadata object FIRST. If this throws (or the payload write below
5097
+ // fails), nothing else has been written yet, so the previously stored timestamps still
5098
+ // describe the previously stored payload and LocalMetadataObsolete() will report
5099
+ // obsolete on the next boot — which is what makes the save retry. Writing timestamps
5100
+ // before the payload left the cache claiming freshness with no payload behind it, and
5101
+ // that state was never retried.
4468
5102
  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
- }
5103
+ const snapshot = await this.writeMetadataSnapshot(ls, jsonString);
5104
+ // Persist the metadata-member entity set beside the snapshot it belongs to, so the
5105
+ // event-driven refresh survives a warm boot (see LoadLocalMetadataFromStorage).
5106
+ if (this._metadataDatasetEntityNames?.size) {
5107
+ await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageDatasetMembershipKey, JSON.stringify([...this._metadataDatasetEntityNames]));
4491
5108
  }
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;
5109
+ // Timestamps LAST: they are the freshness claim for everything written above, so they
5110
+ // must be the final thing to land.
5111
+ await ls.SetItem(this.LocalStoragePrefix + ProviderBase.localStorageTimestampsKey, JSON.stringify(this._latestLocalMetadataTimestamps));
5112
+ this.logMetadataSaveComplete(Date.now() - start, jsonString.length, snapshot);
5113
+ }
5114
+ catch (e) {
5115
+ LogError(`[Metadata Cache] SaveLocalMetadataToStorage failed: ${e instanceof Error ? e.message : String(e)}`);
5116
+ }
5117
+ }
5118
+ /**
5119
+ * Writes the serialized metadata payload and its format marker. Compressed (gzip+base64) when
5120
+ * CompressionStream is available, otherwise plain JSON. The snapshot path intentionally uses
5121
+ * string storage so the compressed form round-trips cleanly through providers that don't
5122
+ * support binary natively. A compression failure falls back to the uncompressed write; a
5123
+ * failure of the write itself propagates to the caller so nothing after it is stored.
5124
+ */
5125
+ async writeMetadataSnapshot(ls, jsonString) {
5126
+ const dataKey = this.LocalStoragePrefix + ProviderBase.localStorageAllMetadataKey;
5127
+ const formatKey = this.LocalStoragePrefix + ProviderBase.localStorageFormatKey;
5128
+ // Attempt compressed storage using native CompressionStream (available in modern browsers and Node 18+)
5129
+ if (typeof CompressionStream !== 'undefined') {
5130
+ try {
5131
+ const blob = new Blob([jsonString]);
5132
+ const cs = new CompressionStream('gzip');
5133
+ const compressedStream = blob.stream().pipeThrough(cs);
5134
+ const compressedBuffer = await new Response(compressedStream).arrayBuffer();
5135
+ const base64 = ProviderBase.arrayBufferToBase64(compressedBuffer);
5136
+ await ls.SetItem(dataKey, base64);
5137
+ await ls.SetItem(formatKey, 'gzip');
5138
+ return { compressed: true, storedLength: base64.length };
5139
+ }
5140
+ catch (compressErr) {
5141
+ // Compression failed — fall through to uncompressed save
5142
+ LogError(`[Metadata Cache] Compression failed, falling back to uncompressed: ${compressErr instanceof Error ? compressErr.message : String(compressErr)}`);
5143
+ }
5144
+ }
5145
+ // Fallback: uncompressed save (older environments without CompressionStream)
5146
+ await ls.SetItem(dataKey, jsonString);
5147
+ await ls.SetItem(formatKey, 'json');
5148
+ return { compressed: false, storedLength: jsonString.length };
5149
+ }
5150
+ logMetadataSaveComplete(elapsedMs, rawLength, snapshot) {
5151
+ const mb = (n) => (n / 1024 / 1024).toFixed(1);
5152
+ if (snapshot.compressed) {
5153
+ const ratio = rawLength > 0 ? (snapshot.storedLength / rawLength * 100).toFixed(1) : '?';
4496
5154
  LogStatusEx({
4497
- message: `[Metadata Cache] Save complete (uncompressed): ${elapsed}ms, size=${(jsonString.length / 1024 / 1024).toFixed(1)}MB`,
5155
+ message: `[Metadata Cache] Save complete: ${elapsedMs}ms, raw=${mb(rawLength)}MB, compressed=${mb(snapshot.storedLength)}MB (${ratio}%)`,
4498
5156
  verboseOnly: true
4499
5157
  });
4500
5158
  }
4501
- catch (e) {
4502
- LogError(`[Metadata Cache] SaveLocalMetadataToStorage failed: ${e instanceof Error ? e.message : String(e)}`);
5159
+ else {
5160
+ LogStatusEx({
5161
+ message: `[Metadata Cache] Save complete (uncompressed): ${elapsedMs}ms, size=${mb(rawLength)}MB`,
5162
+ verboseOnly: true
5163
+ });
4503
5164
  }
4504
5165
  }
4505
5166
  /**