@memberjunction/core 6.2.0-edge.3 → 6.2.0-edge.5

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 (99) hide show
  1. package/dist/generic/BaseEntitySaveQueue.d.ts +7 -0
  2. package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
  3. package/dist/generic/BaseEntitySaveQueue.js +22 -4
  4. package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
  5. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +33 -0
  6. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
  7. package/dist/generic/RegisterForStartup.d.ts +44 -0
  8. package/dist/generic/RegisterForStartup.d.ts.map +1 -1
  9. package/dist/generic/RegisterForStartup.js +124 -48
  10. package/dist/generic/RegisterForStartup.js.map +1 -1
  11. package/dist/generic/baseEngine.d.ts +222 -7
  12. package/dist/generic/baseEngine.d.ts.map +1 -1
  13. package/dist/generic/baseEngine.js +555 -37
  14. package/dist/generic/baseEngine.js.map +1 -1
  15. package/dist/generic/baseEngineSweeper.d.ts +35 -0
  16. package/dist/generic/baseEngineSweeper.d.ts.map +1 -0
  17. package/dist/generic/baseEngineSweeper.js +97 -0
  18. package/dist/generic/baseEngineSweeper.js.map +1 -0
  19. package/dist/generic/baseEntity.d.ts +45 -1
  20. package/dist/generic/baseEntity.d.ts.map +1 -1
  21. package/dist/generic/baseEntity.js +308 -187
  22. package/dist/generic/baseEntity.js.map +1 -1
  23. package/dist/generic/baseInfo.d.ts +31 -3
  24. package/dist/generic/baseInfo.d.ts.map +1 -1
  25. package/dist/generic/baseInfo.js +138 -40
  26. package/dist/generic/baseInfo.js.map +1 -1
  27. package/dist/generic/databaseProviderBase.d.ts +57 -19
  28. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  29. package/dist/generic/databaseProviderBase.js +203 -80
  30. package/dist/generic/databaseProviderBase.js.map +1 -1
  31. package/dist/generic/entityConfiguration.d.ts +8 -2
  32. package/dist/generic/entityConfiguration.d.ts.map +1 -1
  33. package/dist/generic/entityConfiguration.js +9 -0
  34. package/dist/generic/entityConfiguration.js.map +1 -1
  35. package/dist/generic/entityEventBatch.d.ts +124 -0
  36. package/dist/generic/entityEventBatch.d.ts.map +1 -0
  37. package/dist/generic/entityEventBatch.js +194 -0
  38. package/dist/generic/entityEventBatch.js.map +1 -0
  39. package/dist/generic/entityInfo.d.ts +103 -8
  40. package/dist/generic/entityInfo.d.ts.map +1 -1
  41. package/dist/generic/entityInfo.js +376 -60
  42. package/dist/generic/entityInfo.js.map +1 -1
  43. package/dist/generic/infoProviderResolution.d.ts +16 -0
  44. package/dist/generic/infoProviderResolution.d.ts.map +1 -0
  45. package/dist/generic/infoProviderResolution.js +17 -0
  46. package/dist/generic/infoProviderResolution.js.map +1 -0
  47. package/dist/generic/interfaces.d.ts +143 -3
  48. package/dist/generic/interfaces.d.ts.map +1 -1
  49. package/dist/generic/interfaces.js.map +1 -1
  50. package/dist/generic/localCacheManager.d.ts +505 -56
  51. package/dist/generic/localCacheManager.d.ts.map +1 -1
  52. package/dist/generic/localCacheManager.js +1185 -175
  53. package/dist/generic/localCacheManager.js.map +1 -1
  54. package/dist/generic/logging.d.ts +15 -0
  55. package/dist/generic/logging.d.ts.map +1 -1
  56. package/dist/generic/logging.js +17 -0
  57. package/dist/generic/logging.js.map +1 -1
  58. package/dist/generic/metadata.d.ts +1 -1
  59. package/dist/generic/metadata.d.ts.map +1 -1
  60. package/dist/generic/metadata.js.map +1 -1
  61. package/dist/generic/mjapiUrl.d.ts +21 -0
  62. package/dist/generic/mjapiUrl.d.ts.map +1 -0
  63. package/dist/generic/mjapiUrl.js +25 -0
  64. package/dist/generic/mjapiUrl.js.map +1 -0
  65. package/dist/generic/providerBase.d.ts +301 -5
  66. package/dist/generic/providerBase.d.ts.map +1 -1
  67. package/dist/generic/providerBase.js +750 -168
  68. package/dist/generic/providerBase.js.map +1 -1
  69. package/dist/generic/queryInfo.d.ts +10 -9
  70. package/dist/generic/queryInfo.d.ts.map +1 -1
  71. package/dist/generic/queryInfo.js +60 -33
  72. package/dist/generic/queryInfo.js.map +1 -1
  73. package/dist/generic/scoring/ReciprocalRankFusion.d.ts.map +1 -1
  74. package/dist/generic/scoring/ReciprocalRankFusion.js +10 -3
  75. package/dist/generic/scoring/ReciprocalRankFusion.js.map +1 -1
  76. package/dist/generic/securityInfo.d.ts +11 -6
  77. package/dist/generic/securityInfo.d.ts.map +1 -1
  78. package/dist/generic/securityInfo.js +15 -10
  79. package/dist/generic/securityInfo.js.map +1 -1
  80. package/dist/generic/telemetryManager.d.ts.map +1 -1
  81. package/dist/generic/telemetryManager.js +4 -1
  82. package/dist/generic/telemetryManager.js.map +1 -1
  83. package/dist/generic/transactionGroup.d.ts +27 -0
  84. package/dist/generic/transactionGroup.d.ts.map +1 -1
  85. package/dist/generic/transactionGroup.js +58 -5
  86. package/dist/generic/transactionGroup.js.map +1 -1
  87. package/dist/generic/util.d.ts.map +1 -1
  88. package/dist/generic/util.js +3 -0
  89. package/dist/generic/util.js.map +1 -1
  90. package/dist/generic/wellKnownUserSource.d.ts +3 -2
  91. package/dist/generic/wellKnownUserSource.d.ts.map +1 -1
  92. package/dist/generic/wellKnownUserSource.js +18 -9
  93. package/dist/generic/wellKnownUserSource.js.map +1 -1
  94. package/dist/index.d.ts +2 -0
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +2 -0
  97. package/dist/index.js.map +1 -1
  98. package/package.json +3 -3
  99. package/readme.md +29 -4
@@ -3,7 +3,8 @@ import { TelemetryManager } from "./telemetryManager.js";
3
3
  import { BehaviorSubject, Subject } from "rxjs";
4
4
  import { buffer, debounceTime, filter } from "rxjs/operators";
5
5
  import { RunView } from "../views/runView.js";
6
- import { LogError, LogStatus } from "./logging.js";
6
+ import { LogError, LogStatus, LogStatusEx, LogWarning } from "./logging.js";
7
+ import { EntityInfo } from "./entityInfo.js";
7
8
  import { Metadata } from "./metadata.js";
8
9
  import { ProviderType } from "./interfaces.js";
9
10
  import { BaseInfo } from "./baseInfo.js";
@@ -38,11 +39,47 @@ export class BaseEnginePropertyConfig extends BaseInfo {
38
39
  * environment, and the same is true for MJAPI/Server based environments. If you need network based notification, additional infrastructure will be needed to implement that.
39
40
  */
40
41
  this.AutoRefresh = true;
42
+ /**
43
+ * When set to true, the engine will use the LocalCacheManager to cache results locally.
44
+ * On subsequent loads (unless forceRefresh is true), cached results will be validated
45
+ * via smart cache check and returned from local storage if still current.
46
+ *
47
+ * @default false
48
+ */
49
+ this.CacheLocal = false;
41
50
  // now copy the values from init to this object
42
51
  if (init)
43
52
  Object.assign(this, init);
44
53
  }
45
54
  }
55
+ /**
56
+ * Timestamps closer than this are treated as equal, as the smart-cache check does: the database
57
+ * and JavaScript round datetimes differently.
58
+ */
59
+ const SWEEP_TIMESTAMP_TOLERANCE_MS = 1000;
60
+ /** Resolves on a later macrotask, after I/O callbacks already queued have run. Works in browsers too. */
61
+ function yieldToEventLoop() {
62
+ return new Promise(resolve => setTimeout(resolve, 0));
63
+ }
64
+ /**
65
+ * 64-bit FNV-1a over a list of strings, as hex. Synchronous and dependency-free so it runs in the
66
+ * browser too; for comparing snapshots, not for security.
67
+ * @internal
68
+ */
69
+ function hashStrings(values) {
70
+ let h1 = 0x811c9dc5;
71
+ let h2 = 0x01000193 ^ 0x5bd1e995;
72
+ for (const value of values) {
73
+ for (let i = 0; i < value.length; i++) {
74
+ const c = value.charCodeAt(i);
75
+ h1 = Math.imul(h1 ^ c, 0x01000193);
76
+ h2 = Math.imul(h2 ^ c, 0x01000193);
77
+ }
78
+ h1 = Math.imul(h1 ^ 0x0a, 0x01000193); // separator, so ['ab','c'] != ['a','bc']
79
+ h2 = Math.imul(h2 ^ 0x0b, 0x01000193);
80
+ }
81
+ return (h1 >>> 0).toString(16).padStart(8, '0') + (h2 >>> 0).toString(16).padStart(8, '0');
82
+ }
46
83
  /**
47
84
  * Thrown when engine data is accessed but was never loaded because the
48
85
  * current user lacks read permissions on the underlying entities.
@@ -161,8 +198,16 @@ export class BaseEngine extends BaseSingleton {
161
198
  * reconciles it. Keyed by config PropertyName. See {@link beginConfigRefresh}.
162
199
  */
163
200
  this._configRefreshGeneration = new Map();
201
+ /**
202
+ * Reloads started by {@link OnExternalCacheChange}, keyed by config PropertyName. While one is
203
+ * running, further events for that property set `Again` instead of starting another, so a
204
+ * burst of peer events costs at most two reloads per property. See {@link reloadAfterExternalChange}.
205
+ */
206
+ this._externalChangeReloads = new Map();
164
207
  this._entityEventDebounceTime = 1500; // Default debounce time in milliseconds (1.5 seconds)
165
208
  this._eventRefreshRetryTimers = new Map();
209
+ /** Configs waiting for the cache manager to initialize before their callbacks can register. */
210
+ this._deferredCacheCallbackConfigs = null;
166
211
  }
167
212
  /**
168
213
  * Observable that emits when any data property changes due to a refresh.
@@ -486,9 +531,9 @@ export class BaseEngine extends BaseSingleton {
486
531
  }
487
532
  else if (!BaseEngine._systemUserFallbackWarned.has(this.constructor.name)) {
488
533
  BaseEngine._systemUserFallbackWarned.add(this.constructor.name);
489
- LogStatus(`${this.constructor.name}: could not resolve the MJ system user for server-side loading — ` +
534
+ LogWarning(`${this.constructor.name}: could not resolve the MJ system user for server-side loading — ` +
490
535
  `falling back to the calling user. This engine's data will reflect that user's permissions ` +
491
- `and is cached process-wide, so a restricted caller can leave incomplete data cached for everyone.`);
536
+ `and is cached process-wide, so a restricted caller can leave incomplete data cached for everyone.`, 'Cache');
492
537
  }
493
538
  }
494
539
  /**********************************************************************
@@ -628,7 +673,7 @@ export class BaseEngine extends BaseSingleton {
628
673
  }
629
674
  if (event.type === 'delete' || event.type === 'save') {
630
675
  const eName = event.baseEntity.EntityInfo.Name.toLowerCase().trim();
631
- const matchingConfigs = this.Configs.filter((config) => {
676
+ const matchingConfigs = this._metadataConfigs.filter((config) => {
632
677
  return config.AutoRefresh && config.EntityName && config.EntityName.trim().toLowerCase() === eName;
633
678
  });
634
679
  if (matchingConfigs.length === 0) {
@@ -695,7 +740,7 @@ export class BaseEngine extends BaseSingleton {
695
740
  if (!entityName) {
696
741
  return true; // No entity name, nothing to do
697
742
  }
698
- const matchingConfigs = this.Configs.filter((config) => {
743
+ const matchingConfigs = this._metadataConfigs.filter((config) => {
699
744
  return config.AutoRefresh && config.EntityName && config.EntityName.trim().toLowerCase() === entityName;
700
745
  });
701
746
  if (matchingConfigs.length === 0) {
@@ -844,7 +889,7 @@ export class BaseEngine extends BaseSingleton {
844
889
  const entityName = event.baseEntity.EntityInfo.Name.toLowerCase().trim();
845
890
  if (!this._entityEventSubjects.has(entityName)) {
846
891
  // Find matching config to get custom debounce time
847
- const matchingConfig = this.Configs.find(c => c.EntityName?.trim().toLowerCase() === entityName);
892
+ const matchingConfig = this._metadataConfigs.find(c => c.EntityName?.trim().toLowerCase() === entityName);
848
893
  // Use config-specific debounce time or fall back to default
849
894
  const debounceTimeValue = matchingConfig?.DebounceTime ?? this.EntityEventDebounceTime;
850
895
  const subject = new Subject();
@@ -916,7 +961,7 @@ export class BaseEngine extends BaseSingleton {
916
961
  try {
917
962
  const entityName = events[0].baseEntity.EntityInfo.Name.toLowerCase().trim();
918
963
  let refreshCount = 0;
919
- for (const config of this.Configs) {
964
+ for (const config of this._metadataConfigs) {
920
965
  if (config.AutoRefresh && config.Type === 'entity' && config.EntityName?.trim().toLowerCase() === entityName) {
921
966
  const classified = events.map(e => ({ event: e, disposition: this.classifyEventForConfig(config, e) }));
922
967
  const needingWork = classified.filter(c => c.disposition.action === 'refresh');
@@ -1308,7 +1353,7 @@ export class BaseEngine extends BaseSingleton {
1308
1353
  if (config.CacheLocal) {
1309
1354
  this.syncLocalCacheForConfig(config, event).catch(e => {
1310
1355
  // Log status but don't fail - cache will self-correct on next fetch
1311
- LogStatus(`BaseEngine: Failed to sync local cache for ${config.EntityName}: ${e}`);
1356
+ LogWarning(`BaseEngine: Failed to sync local cache for ${config.EntityName}: ${e}`, 'Cache');
1312
1357
  });
1313
1358
  }
1314
1359
  }
@@ -1351,6 +1396,14 @@ export class BaseEngine extends BaseSingleton {
1351
1396
  return;
1352
1397
  }
1353
1398
  const entity = event.baseEntity;
1399
+ // Inside a batch the cache manager records this change and rewrites every slot indexed for
1400
+ // the entity — this config's included — once, when the batch closes. The event is checked
1401
+ // as well as the provider because this runs after an await, by which time a batch that
1402
+ // took the event may already have closed.
1403
+ const cache = LocalCacheManager.Instance;
1404
+ if (cache.IsBatchingEntityEvents(entity?.ProviderToUse) || cache.WasTakenByEntityEventBatch(event)) {
1405
+ return;
1406
+ }
1354
1407
  // Get the connection string from the provider for fingerprint generation
1355
1408
  // The provider is needed because fingerprints include connection prefix
1356
1409
  const provider = this.ProviderToUse;
@@ -1469,26 +1522,37 @@ export class BaseEngine extends BaseSingleton {
1469
1522
  CheckPermissionsOrSkipAll(configs, contextUser) {
1470
1523
  if (configs.length === 0)
1471
1524
  return configs;
1525
+ // First check whether any configured entity is missing from metadata altogether
1526
+ // (e.g., an optional OpenApp engine registered in the bundle whose database schema
1527
+ // is not installed in the target environment). Skip cleanly instead of firing N
1528
+ // guaranteed-to-fail RunView calls and logging N error stack traces at startup.
1529
+ const missingEntities = this.findMissingEntities(configs);
1530
+ if (missingEntities.length > 0) {
1531
+ LogStatusEx({
1532
+ message: `${this.constructor.name}: Skipping ${configs.length} entity config(s) — entity metadata not installed for: ${missingEntities.join(', ')}`,
1533
+ verboseOnly: true,
1534
+ category: 'Cache',
1535
+ });
1536
+ for (const config of configs) {
1537
+ if (config.AddToObject !== false) {
1538
+ this[config.PropertyName] = [];
1539
+ }
1540
+ this._dataMap.set(config.PropertyName, {
1541
+ entityName: config.EntityName,
1542
+ data: [],
1543
+ loadedSuccessfully: true,
1544
+ });
1545
+ }
1546
+ return [];
1547
+ }
1472
1548
  // Determine the user to check permissions for — on the client side contextUser
1473
1549
  // may be undefined, so fall back to the current logged-in user from Metadata.
1474
1550
  const user = contextUser || this.ProviderToUse?.CurrentUser;
1475
1551
  if (!user)
1476
1552
  return configs; // Can't check without a user — proceed with normal loading
1477
- const md = this.ProviderToUse;
1478
- const deniedEntities = [];
1479
- for (const config of configs) {
1480
- if (!config.EntityName)
1481
- continue;
1482
- const entityInfo = md.EntityByName(config.EntityName);
1483
- if (!entityInfo)
1484
- continue; // Entity not in metadata — let RunView handle it
1485
- const perms = entityInfo.GetUserPermisions(user);
1486
- if (!perms || !perms.CanRead) {
1487
- deniedEntities.push(config.EntityName);
1488
- }
1489
- }
1553
+ const deniedEntities = this.findUnreadableEntities(configs, user);
1490
1554
  if (deniedEntities.length > 0) {
1491
- LogStatus(`${this.constructor.name}: Skipping ${configs.length} entity config(s) — user ${user.Email} lacks read permission on: ${deniedEntities.join(', ')}`);
1555
+ LogWarning(`${this.constructor.name}: Skipping ${configs.length} entity config(s) — user ${user.Email} lacks read permission on: ${deniedEntities.join(', ')}`, 'Cache');
1492
1556
  // Mark all entity configs as successfully loaded with empty data.
1493
1557
  // This is not a failure — it's expected behavior for limited-permission users.
1494
1558
  // Marking as successful allows the engine to set _loaded = true and avoid
@@ -1510,17 +1574,54 @@ export class BaseEngine extends BaseSingleton {
1510
1574
  }
1511
1575
  return configs;
1512
1576
  }
1577
+ /**
1578
+ * Returns entity names among `configs` that do not exist in the loaded metadata provider.
1579
+ * Only checks when the metadata provider actually has entities loaded (`md.Entities.length > 0`)
1580
+ * so unit tests with empty mock providers are unaffected.
1581
+ */
1582
+ findMissingEntities(configs) {
1583
+ const md = this.ProviderToUse;
1584
+ if (!md?.Entities || md.Entities.length === 0) {
1585
+ return [];
1586
+ }
1587
+ const missing = [];
1588
+ for (const config of configs) {
1589
+ if (!config.EntityName)
1590
+ continue;
1591
+ if (!md.EntityByName(config.EntityName)) {
1592
+ missing.push(config.EntityName);
1593
+ }
1594
+ }
1595
+ return missing;
1596
+ }
1597
+ /** The entity names among `configs` that `user` cannot read, per current metadata. */
1598
+ findUnreadableEntities(configs, user) {
1599
+ const md = this.ProviderToUse;
1600
+ const denied = [];
1601
+ for (const config of configs) {
1602
+ if (!config.EntityName)
1603
+ continue;
1604
+ const entityInfo = md.EntityByName(config.EntityName);
1605
+ if (!entityInfo)
1606
+ continue; // Entity not in metadata — let RunView handle it
1607
+ const perms = entityInfo.GetUserPermisions(user);
1608
+ if (!perms || !perms.CanRead) {
1609
+ denied.push(config.EntityName);
1610
+ }
1611
+ }
1612
+ return denied;
1613
+ }
1513
1614
  /**
1514
1615
  * Loads a single metadata configuration.
1515
1616
  * @param config - The metadata configuration to load
1516
1617
  * @param contextUser - The context user information
1517
1618
  * @param bypassCache - When true, bypasses server-side cache to get fresh data from the database
1518
1619
  */
1519
- async LoadSingleConfig(config, contextUser, bypassCache = false) {
1620
+ async LoadSingleConfig(config, contextUser, bypassCache = false, suppressEmit = false) {
1520
1621
  if (config.Type === 'dataset')
1521
1622
  return await this.LoadSingleDatasetConfig(config, contextUser, bypassCache);
1522
1623
  else
1523
- return await this.LoadSingleEntityConfig(config, contextUser, bypassCache);
1624
+ return await this.LoadSingleEntityConfig(config, contextUser, bypassCache, suppressEmit);
1524
1625
  }
1525
1626
  /**
1526
1627
  * Opens a new full-refresh "generation" for a property and returns its token. Each call
@@ -1557,6 +1658,15 @@ export class BaseEngine extends BaseSingleton {
1557
1658
  * to ensure the fingerprint-affecting params (EntityName, ExtraFilter, OrderBy,
1558
1659
  * IgnoreMaxRows, IncludeBinaryFields) are always consistent — preventing cache key mismatches that break
1559
1660
  * cross-server invalidation via Redis pub/sub and local cache upsert/remove operations.
1661
+ *
1662
+ * **Row-level security is not part of the fingerprints built from these params.** The engine
1663
+ * builds them with `GenerateRunViewFingerprint(params, connection)`, without the RLS clause the
1664
+ * provider adds for the reading user. On a server that is correct: engines load as the system
1665
+ * user, whose RLS clause is empty, so the provider's fingerprint for the same read matches. An
1666
+ * engine loaded by an RLS-scoped user (possible on a client) would compute an unscoped
1667
+ * fingerprint for a slot the provider stored under a scoped one, so its cache callbacks and
1668
+ * `syncLocalCacheForConfig` would target a different slot. Pass the RLS clause here if such an
1669
+ * engine is ever needed.
1560
1670
  */
1561
1671
  BuildRunViewParamsForConfig(config, bypassCache = false) {
1562
1672
  return {
@@ -1581,7 +1691,7 @@ export class BaseEngine extends BaseSingleton {
1581
1691
  * @param contextUser
1582
1692
  * @param bypassCache - When true, bypasses server-side cache to get fresh data from the database
1583
1693
  */
1584
- async LoadSingleEntityConfig(config, contextUser, bypassCache = false) {
1694
+ async LoadSingleEntityConfig(config, contextUser, bypassCache = false, suppressEmit = false) {
1585
1695
  // Claim a refresh generation BEFORE the awaited RunView. If another full refresh for
1586
1696
  // this same property starts while our RunView is in flight, ours becomes stale and must
1587
1697
  // not commit — otherwise concurrent refreshes (the filtered/OrderBy path, e.g.
@@ -1600,7 +1710,12 @@ export class BaseEngine extends BaseSingleton {
1600
1710
  return;
1601
1711
  }
1602
1712
  this.HandleSingleViewResult(config, result, contextUser);
1603
- this.emitPropertyChange(config.PropertyName);
1713
+ if (!suppressEmit) {
1714
+ // A caller that rebuilds derived state afterwards emits itself, once, AFTER the
1715
+ // rebuild — subscribers must never observe a property whose derived state is still
1716
+ // the previous load's (the invariant stated on OnExternalCacheChange).
1717
+ this.emitPropertyChange(config.PropertyName);
1718
+ }
1604
1719
  }
1605
1720
  /**
1606
1721
  * Handles the result of a single view load.
@@ -1627,7 +1742,7 @@ export class BaseEngine extends BaseSingleton {
1627
1742
  // the user still gets no data. Logged at status level, not error, because
1628
1743
  // for a restricted role this is expected, not a fault.
1629
1744
  this.MarkConfigEmptyLoaded(config);
1630
- LogStatus(`BaseEngine: ${config.EntityName} not readable by current role — loaded empty (restricted-role degradation).`);
1745
+ LogWarning(`BaseEngine: ${config.EntityName} not readable by current role — loaded empty (restricted-role degradation).`, 'Cache');
1631
1746
  }
1632
1747
  else {
1633
1748
  // TRANSIENT failure (network, MJAPI restart, etc.) — leave loadedSuccessfully
@@ -1687,7 +1802,7 @@ export class BaseEngine extends BaseSingleton {
1687
1802
  if (config.AddToObject !== false) {
1688
1803
  this[config.PropertyName] = [];
1689
1804
  }
1690
- this._dataMap.set(config.PropertyName, { entityName: config.EntityName, data: [], loadedSuccessfully: true });
1805
+ this._dataMap.set(config.PropertyName, { entityName: config.EntityName, data: [], loadedSuccessfully: true, readDenied: true });
1691
1806
  this.NotifyDataChange(config, []);
1692
1807
  }
1693
1808
  /**
@@ -1810,17 +1925,35 @@ export class BaseEngine extends BaseSingleton {
1810
1925
  }
1811
1926
  this._cacheChangeUnsubscribers = [];
1812
1927
  if (!LocalCacheManager.Instance.IsInitialized) {
1928
+ this.deferCacheChangeRegistration(entityConfigs);
1813
1929
  return;
1814
1930
  }
1815
- const connectionPrefix = this.RunViewProviderToUse instanceof ProviderBase
1816
- ? this.RunViewProviderToUse.InstanceConnectionString
1817
- : undefined;
1818
1931
  for (const config of entityConfigs) {
1819
- const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(this.BuildRunViewParamsForConfig(config), connectionPrefix);
1932
+ const fingerprint = this.configFingerprint(config);
1820
1933
  const unsubscribe = LocalCacheManager.Instance.RegisterChangeCallback(fingerprint, (event) => this.OnExternalCacheChange(config, event));
1821
1934
  this._cacheChangeUnsubscribers.push(unsubscribe);
1822
1935
  }
1823
1936
  }
1937
+ /**
1938
+ * An engine that loads before `LocalCacheManager` is initialized (a custom host, a script that
1939
+ * touches an engine before `StartupManager` runs) would otherwise never hear another server's
1940
+ * cache changes: nothing registers its callbacks later except a forced reload. Registration is
1941
+ * deferred to initialization instead; only the latest config list is kept.
1942
+ */
1943
+ deferCacheChangeRegistration(entityConfigs) {
1944
+ const alreadyWaiting = this._deferredCacheCallbackConfigs !== null;
1945
+ this._deferredCacheCallbackConfigs = entityConfigs;
1946
+ if (alreadyWaiting) {
1947
+ return;
1948
+ }
1949
+ LocalCacheManager.Instance.WhenInitialized(() => {
1950
+ const configs = this._deferredCacheCallbackConfigs;
1951
+ this._deferredCacheCallbackConfigs = null;
1952
+ if (configs) {
1953
+ this.RegisterCacheChangeCallbacks(configs);
1954
+ }
1955
+ });
1956
+ }
1824
1957
  /**
1825
1958
  * Called when another server instance updates cached data that this engine
1826
1959
  * is tracking. Default behavior: reload the affected config from the database.
@@ -1832,16 +1965,43 @@ export class BaseEngine extends BaseSingleton {
1832
1965
  * @param event - The cache change event from the other server
1833
1966
  */
1834
1967
  async OnExternalCacheChange(config, event) {
1968
+ // A config the current user cannot read stays empty: neither a peer's rows nor a reload
1969
+ // may fill it. The per-record event path applies the same rule.
1970
+ if (this._isPermissionConstrained || this._dataMap.get(config.PropertyName)?.readDenied) {
1971
+ return;
1972
+ }
1835
1973
  // If the event includes the new data, try to apply it directly
1836
1974
  if (event.Data && event.Action === 'set') {
1837
1975
  try {
1838
1976
  const parsed = JSON.parse(event.Data);
1839
1977
  if (parsed?.results && Array.isArray(parsed.results)) {
1978
+ // A payload carrying exactly the rows this engine already holds tells it nothing.
1979
+ // Skipping it leaves the engine as if the event had not arrived: no generation is
1980
+ // claimed, so a reload already in flight still commits its (possibly newer) rows.
1981
+ if (this.payloadMatchesHeldRows(config, parsed.results)) {
1982
+ LogStatusEx({ message: `BaseEngine.OnExternalCacheChange: '${config.PropertyName}' already holds the payload's rows — skipped`, verboseOnly: true });
1983
+ return;
1984
+ }
1985
+ // Rows written under a different field list (a server on the other side of a
1986
+ // migration) lack columns this process has, or carry ones it does not. Loading
1987
+ // them would blank the missing columns on every row, so read the rows instead.
1988
+ if (this.payloadSchemaDiffers(config, parsed.schemaHash)) {
1989
+ LogStatusEx({ message: `BaseEngine.OnExternalCacheChange: payload for '${config.PropertyName}' was written under a different schema — reloading instead`, verboseOnly: true });
1990
+ await this.reloadAfterExternalChange(config);
1991
+ return;
1992
+ }
1840
1993
  // Claim a refresh generation BEFORE the awaited materialization — the same
1841
1994
  // protocol LoadSingleConfig uses around its awaited RunView. Without it, two
1842
1995
  // overlapping cache events (or an event racing a full reload) can resolve out
1843
1996
  // of order and the stale result would be the one that assigns last.
1844
1997
  const generation = this.beginConfigRefresh(config.PropertyName);
1998
+ // A burst of payloads for one slot usually arrives together. Let the rest of the
1999
+ // burst claim its generations first, so only the newest payload is materialized
2000
+ // instead of every one of them (receive side).
2001
+ await yieldToEventLoop();
2002
+ if (!this.isLatestConfigRefresh(config.PropertyName, generation)) {
2003
+ return;
2004
+ }
1845
2005
  const rows = await this.materializeCacheEventRows(config, parsed.results);
1846
2006
  if (!this.isLatestConfigRefresh(config.PropertyName, generation)) {
1847
2007
  return; // superseded while materializing — the newer refresh owns the property
@@ -1879,15 +2039,364 @@ export class BaseEngine extends BaseSingleton {
1879
2039
  // materialization failures (e.g. an entity class that cannot construct in
1880
2040
  // this process), and without a trace the only symptom is every cache event
1881
2041
  // turning into a database reload.
1882
- LogStatus(`BaseEngine.OnExternalCacheChange: payload for '${config.PropertyName}' could not be applied (${e instanceof Error ? e.message : String(e)}) — falling back to full reload`);
2042
+ LogWarning(`BaseEngine.OnExternalCacheChange: payload for '${config.PropertyName}' could not be applied (${e instanceof Error ? e.message : String(e)}) — falling back to full reload`, 'Cache');
1883
2043
  }
1884
2044
  }
1885
- // Fallback: reload this config from the database. This also replaces the property,
1886
- // so the derived state has to be rebuilt here too.
1887
- await this.LoadSingleConfig(config, this._contextUser);
2045
+ await this.reloadAfterExternalChange(config);
2046
+ }
2047
+ /**
2048
+ * True when a cache-change payload carries a schema hash and it differs from this engine's
2049
+ * hash for the config's entity. A payload without a hash, or an entity this process cannot
2050
+ * resolve, does not count as differing.
2051
+ */
2052
+ payloadSchemaDiffers(config, payloadSchemaHash) {
2053
+ if (typeof payloadSchemaHash !== 'string' || !payloadSchemaHash || !config.EntityName) {
2054
+ return false;
2055
+ }
2056
+ const ownHash = LocalCacheManager.Instance.ComputeSchemaHash(this.ProviderToUse, config.EntityName);
2057
+ return ownHash !== undefined && ownHash !== payloadSchemaHash;
2058
+ }
2059
+ /**
2060
+ * Reloads a config after a peer's cache change, coalescing overlapping requests: an event that
2061
+ * arrives while a reload for the same property is running asks for one more reload after it
2062
+ * instead of starting its own. Every caller's promise settles only after a reload that began
2063
+ * after its event, so no change is missed, but N events in a burst cost at most two reloads.
2064
+ */
2065
+ reloadAfterExternalChange(config) {
2066
+ const running = this._externalChangeReloads.get(config.PropertyName);
2067
+ if (running) {
2068
+ running.Again = true;
2069
+ return running.Done;
2070
+ }
2071
+ const reload = { Again: false, Done: Promise.resolve() };
2072
+ this._externalChangeReloads.set(config.PropertyName, reload);
2073
+ reload.Done = this.runExternalChangeReloads(config, reload);
2074
+ return reload.Done;
2075
+ }
2076
+ /** Runs reloads for {@link reloadAfterExternalChange} until no further event asked for one. */
2077
+ async runExternalChangeReloads(config, reload) {
2078
+ try {
2079
+ do {
2080
+ reload.Again = false;
2081
+ // The reload replaces the property, so the derived state has to be rebuilt too —
2082
+ // and the reload must NOT emit on its own, or a subscriber wakes between the new
2083
+ // rows and the rebuild and reads derived state still pointing at the instances
2084
+ // this reload discarded.
2085
+ await this.LoadSingleConfig(config, this._contextUser, /*bypassCache*/ false, /*suppressEmit*/ true);
2086
+ await this.RebuildDerivedState(this._contextUser);
2087
+ this.emitPropertyChange(config.PropertyName);
2088
+ } while (reload.Again);
2089
+ }
2090
+ finally {
2091
+ this._externalChangeReloads.delete(config.PropertyName);
2092
+ }
2093
+ }
2094
+ /**
2095
+ * True when `payloadRows` are the same row versions this config currently holds: the same
2096
+ * number of rows, and the same multiset of (primary key, `__mj_UpdatedAt`) pairs.
2097
+ *
2098
+ * Identity is derived from what the engine holds right now, never remembered, so no other
2099
+ * assignment path has to keep anything in step with it. (An earlier version remembered the last
2100
+ * payload applied and went stale whenever another path replaced the property.) Comparing rows,
2101
+ * not the writer's `maxUpdatedAt`, keeps it independent of how the writer stamped the slot, and
2102
+ * catches a delete paired with an insert.
2103
+ *
2104
+ * Returns false — apply the payload — whenever identity cannot be established: the config is not
2105
+ * loaded, the entity has no `__mj_UpdatedAt` column, or a timestamp cannot be read.
2106
+ * Known limit: a change that does not bump `__mj_UpdatedAt` (direct SQL) is invisible here, as it
2107
+ * is to the client smart-cache check.
2108
+ */
2109
+ payloadMatchesHeldRows(config, payloadRows) {
2110
+ try {
2111
+ return this.compareHeldRows(config, payloadRows);
2112
+ }
2113
+ catch (e) {
2114
+ // The skip is an optimization; failing to decide must never stop the payload applying.
2115
+ LogStatusEx({ message: `BaseEngine.payloadMatchesHeldRows: could not compare '${config.PropertyName}' (${e instanceof Error ? e.message : String(e)}) — applying the payload`, verboseOnly: true });
2116
+ return false;
2117
+ }
2118
+ }
2119
+ compareHeldRows(config, payloadRows) {
2120
+ const entry = this._dataMap.get(config.PropertyName);
2121
+ if (!entry?.loadedSuccessfully || !config.EntityName || !Array.isArray(entry.data)) {
2122
+ return false;
2123
+ }
2124
+ if (entry.data.length !== payloadRows.length) {
2125
+ return false;
2126
+ }
2127
+ const entityInfo = this.ProviderToUse?.EntityByName(config.EntityName);
2128
+ if (!entityInfo || !entityInfo.Fields.some(f => f.IsUpdatedAtField)) {
2129
+ return false;
2130
+ }
2131
+ const keyFields = entityInfo.PrimaryKeys.map(pk => pk.Name);
2132
+ const held = this.rowVersionKeys(entry.data, keyFields, (row, field) => row instanceof BaseEntity ? row.Get(field) : row[field]);
2133
+ const incoming = this.rowVersionKeys(payloadRows, keyFields, (row, field) => row[field]);
2134
+ if (!held || !incoming) {
2135
+ return false;
2136
+ }
2137
+ return held.every((key, i) => key === incoming[i]);
2138
+ }
2139
+ /**
2140
+ * Sorted `pk|epochMs` strings for a row set, or null when any row lacks a readable timestamp.
2141
+ * Key values are lower-cased so GUID casing differences between sources do not matter.
2142
+ */
2143
+ rowVersionKeys(rows, keyFields, read) {
2144
+ const keys = [];
2145
+ for (const row of rows) {
2146
+ const raw = read(row, EntityInfo.UpdatedAtFieldName);
2147
+ const stamp = raw instanceof Date ? raw.getTime() : new Date(String(raw)).getTime();
2148
+ if (raw == null || Number.isNaN(stamp)) {
2149
+ return null;
2150
+ }
2151
+ const pk = keyFields.map(f => String(read(row, f) ?? '').toLowerCase()).join('');
2152
+ keys.push(`${pk}${stamp}`);
2153
+ }
2154
+ return keys.sort();
2155
+ }
2156
+ // ========================================================================
2157
+ // RECONCILIATION WITH THE DATABASE (plan Phase 3.1)
2158
+ // ========================================================================
2159
+ /**
2160
+ * Compares each loaded entity config with the database — row count and newest
2161
+ * `__mj_UpdatedAt` — and reloads the ones that differ. Finds changes that never raised an MJ
2162
+ * event: direct SQL, other applications, restores. Everything else reaches engines through
2163
+ * events and does not need this.
2164
+ *
2165
+ * One batched status query for the whole engine. A reloaded config is written to the cache
2166
+ * once, so on a shared cache every other server adopts the fresh rows from that one write.
2167
+ * Derived state is rebuilt once, after all reloads.
2168
+ *
2169
+ * Limits: an UPDATE that does not change `__mj_UpdatedAt` or the row count is invisible, as it
2170
+ * is to the smart-cache check. Entities without `__mj_UpdatedAt` are compared by row count
2171
+ * only; dataset configs are not checked.
2172
+ *
2173
+ * @returns What was checked and reloaded. Never throws.
2174
+ */
2175
+ async SweepAgainstDatabase() {
2176
+ const result = { EngineClass: this.constructor.name, Checked: 0, Reloaded: [], Errors: [] };
2177
+ const provider = this.RunViewProviderToUse;
2178
+ const configs = this.sweepableConfigs();
2179
+ if (!this.Loaded || this._isPermissionConstrained || !provider?.GetRunViewsDatabaseStatus || configs.length === 0) {
2180
+ return result;
2181
+ }
2182
+ try {
2183
+ const statuses = await provider.GetRunViewsDatabaseStatus(configs.map(c => this.BuildRunViewParamsForConfig(c)), this._contextUser);
2184
+ const stale = this.staleConfigs(configs, statuses, result);
2185
+ await this.reloadStaleConfigs(stale, result);
2186
+ }
2187
+ catch (e) {
2188
+ result.Errors.push(e instanceof Error ? e.message : String(e));
2189
+ }
2190
+ if (result.Errors.length > 0) {
2191
+ LogWarning(`BaseEngine.SweepAgainstDatabase(${result.EngineClass}): ${result.Errors.join('; ')}`, 'Cache');
2192
+ }
2193
+ return result;
2194
+ }
2195
+ /**
2196
+ * Whether this engine holds any config a sweep could act on — an entity config, loaded and
2197
+ * readable, whose entity declares `TrustServerCacheCompletely = false`.
2198
+ *
2199
+ * Exposed so a caller can decide whether the engine is worth the cost of asking. The fleet
2200
+ * sweeper takes a cross-process lease per engine per interval; on an installation where nothing
2201
+ * declares out-of-band writes, every one of those leases would be claimed only to discover there
2202
+ * is nothing to compare. Reading this first is an in-memory check.
2203
+ */
2204
+ HasSweepableConfigs() {
2205
+ return this.sweepableConfigs().length > 0;
2206
+ }
2207
+ /** Entity configs holding a successful, readable load whose entity can drift outside MJ. */
2208
+ sweepableConfigs() {
2209
+ return this._metadataConfigs.filter(config => {
2210
+ const entry = this._dataMap.get(config.PropertyName);
2211
+ if (config.Type !== 'entity' || !entry?.loadedSuccessfully || entry.readDenied || entry.permissionDenied) {
2212
+ return false;
2213
+ }
2214
+ return this.entityMayChangeWithoutAnEvent(config.EntityName);
2215
+ });
2216
+ }
2217
+ /**
2218
+ * Whether this entity has **declared** that its rows can change without firing an event — the
2219
+ * only situation the sweep exists for.
2220
+ *
2221
+ * `Entity.TrustServerCacheCompletely` is that declaration, in its own words: when true (the
2222
+ * default) the cache trusts "that all mutations flow through BaseEntity.Save() which fires cache
2223
+ * invalidation events", and it is set false "for entities whose rows are created as side-effects
2224
+ * of other operations via raw SQL … since those inserts bypass BaseEntity and never trigger
2225
+ * cache invalidation". An entity that still trusts its cache has nothing for a sweep to find, so
2226
+ * querying it every interval buys nothing.
2227
+ *
2228
+ * It also subsumes the `AllowDirectSQLInsert`/`Update`/`Delete` flags: a database CHECK requires
2229
+ * `TrustServerCacheCompletely = false` whenever any of those is set, so this one flag already
2230
+ * covers every entity that sanctions writes outside `BaseEntity`.
2231
+ *
2232
+ * **Why gate at all.** The sweep is a periodic database query, and a periodic query is not free:
2233
+ * on Azure SQL serverless it prevents auto-pause outright, which several installs pay for, and
2234
+ * the interval does not help — auto-pause needs sustained inactivity, so a long interval is no
2235
+ * better than a short one. Gating here makes the cost proportional to the declared risk: an
2236
+ * engine whose entities are all trusted is never swept and issues no queries. Operators who do
2237
+ * write out of band mark those entities — which they must do anyway for the cache to be correct
2238
+ * at all — and get the backstop exactly there.
2239
+ *
2240
+ * An entity absent from metadata answers false: the sweep cannot establish that it drifts, and
2241
+ * guessing in favour of a recurring query is the expensive guess.
2242
+ */
2243
+ entityMayChangeWithoutAnEvent(entityName) {
2244
+ if (!entityName) {
2245
+ return false;
2246
+ }
2247
+ return this.ProviderToUse?.EntityByName(entityName)?.TrustServerCacheCompletely === false;
2248
+ }
2249
+ /** The configs whose held rows disagree with what the database reports. */
2250
+ staleConfigs(configs, statuses, result) {
2251
+ const stale = [];
2252
+ configs.forEach((config, i) => {
2253
+ const status = statuses[i];
2254
+ if (!status?.Success) {
2255
+ result.Errors.push(`${config.PropertyName}: ${status?.ErrorMessage ?? 'no status returned'}`);
2256
+ return;
2257
+ }
2258
+ result.Checked++;
2259
+ if (!this.heldRowsMatchDatabase(config, status)) {
2260
+ stale.push(config);
2261
+ }
2262
+ });
2263
+ return stale;
2264
+ }
2265
+ /**
2266
+ * Row count and newest `__mj_UpdatedAt` of the held rows against the database's.
2267
+ *
2268
+ * The held stamp is computed the way SQL's `MAX` computes the database's — **ignoring rows
2269
+ * whose timestamp is null** — rather than taken from the census, whose stamp is null unless
2270
+ * EVERY row carries one (it also backs an identity hash, which needs them all). Comparing a
2271
+ * census stamp with a SQL `MAX` made one un-stamped row in a config mean "stale" on every
2272
+ * sweep, forever: a reload every interval, each one rewriting the shared slot and making every
2273
+ * peer reload too. Two sides that both have no stamp at all agree; one with
2274
+ * and one without is a real difference.
2275
+ */
2276
+ heldRowsMatchDatabase(config, status) {
2277
+ const entry = this._dataMap.get(config.PropertyName);
2278
+ if (!entry) {
2279
+ return true;
2280
+ }
2281
+ const rows = Array.isArray(entry.data) ? entry.data : [];
2282
+ if (rows.length !== status.RowCount) {
2283
+ return false;
2284
+ }
2285
+ const entityInfo = this.ProviderToUse?.EntityByName(config.EntityName);
2286
+ if (!entityInfo?.Fields.some(f => f.IsUpdatedAtField) || rows.length === 0) {
2287
+ return true; // nothing to compare beyond the count
2288
+ }
2289
+ const heldMax = LocalCacheManager.MaxUpdatedAtOfRows(rows.map(row => row instanceof BaseEntity ? row.GetAll() : row));
2290
+ if (!heldMax && !status.MaxUpdatedAt) {
2291
+ return true; // neither side has a usable stamp; the counts already agreed
2292
+ }
2293
+ if (!heldMax || !status.MaxUpdatedAt) {
2294
+ return false;
2295
+ }
2296
+ return Math.abs(Date.parse(heldMax) - Date.parse(status.MaxUpdatedAt)) < SWEEP_TIMESTAMP_TOLERANCE_MS;
2297
+ }
2298
+ /** Reloads each stale config from the database, stores it once, then rebuilds derived state. */
2299
+ async reloadStaleConfigs(stale, result) {
2300
+ for (const config of stale) {
2301
+ await this.LoadSingleConfig(config, this._contextUser, true, /*suppressEmit*/ true);
2302
+ if (!this._dataMap.get(config.PropertyName)?.loadedSuccessfully) {
2303
+ result.Errors.push(`${config.PropertyName}: reload failed`);
2304
+ continue;
2305
+ }
2306
+ await this.storeConfigRows(config);
2307
+ result.Reloaded.push(config.PropertyName);
2308
+ }
2309
+ if (result.Reloaded.length === 0) {
2310
+ return;
2311
+ }
1888
2312
  await this.RebuildDerivedState(this._contextUser);
2313
+ for (const propertyName of result.Reloaded) {
2314
+ this.emitPropertyChange(propertyName);
2315
+ }
2316
+ LogStatusEx({ message: `BaseEngine.SweepAgainstDatabase(${result.EngineClass}): reloaded ${result.Reloaded.join(', ')}`, verboseOnly: false });
2317
+ }
2318
+ /**
2319
+ * Writes a config's rows to the cache slot the engine reads and listens on. The reload
2320
+ * bypassed the cache, so without this the slot — and every server reading it — would keep the
2321
+ * old rows.
2322
+ */
2323
+ async storeConfigRows(config) {
2324
+ const cache = LocalCacheManager.Instance;
2325
+ const rows = this._dataMap.get(config.PropertyName)?.data;
2326
+ if (!cache.IsInitialized || !Array.isArray(rows)) {
2327
+ return;
2328
+ }
2329
+ const plainRows = rows.map(row => row instanceof BaseEntity ? row.GetAll() : row);
2330
+ const params = this.BuildRunViewParamsForConfig(config);
2331
+ // Never the clock: a stamp the database cannot reproduce makes the slot permanently
2332
+ // "stale" — every currency check sees a different value and refetches, and the next sweep
2333
+ // stamps it again with a newer one. `''` is what the database's own MAX reports for rows
2334
+ // with no timestamp, and what the other two write funnels store.
2335
+ const maxUpdatedAt = LocalCacheManager.MaxUpdatedAtOfRows(plainRows) ?? '';
2336
+ // Under the slot's cross-process lock: a peer may be part-way through its own
2337
+ // read-modify-write of this slot, and an unlocked replace would clobber it.
2338
+ await cache.ReplaceRunViewResultLocked(this.configFingerprint(config), params, plainRows, maxUpdatedAt, plainRows.length, this.ProviderToUse);
2339
+ }
2340
+ /** The cache fingerprint of a config's slot, as {@link RegisterCacheChangeCallbacks} computes it. */
2341
+ configFingerprint(config) {
2342
+ const provider = this.RunViewProviderToUse;
2343
+ const connectionPrefix = provider instanceof ProviderBase ? provider.InstanceConnectionString : undefined;
2344
+ return LocalCacheManager.Instance.GenerateRunViewFingerprint(this.BuildRunViewParamsForConfig(config), connectionPrefix);
2345
+ }
2346
+ /**
2347
+ * Snapshot of what this engine holds: per property, the row count, newest `__mj_UpdatedAt`,
2348
+ * and a hash of the row versions; plus the engine's own derived-state counts. Read-only and
2349
+ * cheap enough for diagnostics and startup checks (O(rows)).
2350
+ */
2351
+ GetStateCensus() {
2352
+ const properties = [];
2353
+ for (const [propertyName, entry] of this._dataMap) {
2354
+ properties.push(this.censusForProperty(propertyName, entry));
2355
+ }
2356
+ properties.sort((a, b) => a.PropertyName.localeCompare(b.PropertyName));
2357
+ return {
2358
+ EngineClass: this.constructor.name,
2359
+ Loaded: this.Loaded,
2360
+ PermissionConstrained: this._isPermissionConstrained,
2361
+ Properties: properties,
2362
+ Derived: this.GetDerivedStateCensus(),
2363
+ };
1889
2364
  }
1890
2365
  /**
2366
+ * Counts that describe the state a subclass derives in {@link AdditionalLoading} — grouped
2367
+ * child collections, lookup maps. Override to expose them; the default reports nothing.
2368
+ * Must be read-only and must not throw.
2369
+ */
2370
+ GetDerivedStateCensus() {
2371
+ return {};
2372
+ }
2373
+ censusForProperty(propertyName, entry) {
2374
+ const rows = Array.isArray(entry.data) ? entry.data : [];
2375
+ const census = {
2376
+ PropertyName: propertyName,
2377
+ EntityName: entry.entityName,
2378
+ DatasetName: entry.datasetName,
2379
+ RowCount: rows.length,
2380
+ MaxUpdatedAt: null,
2381
+ IdentityHash: null,
2382
+ LoadedSuccessfully: entry.loadedSuccessfully,
2383
+ PermissionDenied: entry.permissionDenied === true || entry.readDenied === true,
2384
+ };
2385
+ const entityInfo = entry.entityName ? this.ProviderToUse?.EntityByName(entry.entityName) : undefined;
2386
+ if (!entityInfo) {
2387
+ return census;
2388
+ }
2389
+ const read = (row, field) => row instanceof BaseEntity ? row.Get(field) : row?.[field];
2390
+ const keys = this.rowVersionKeys(rows, entityInfo.PrimaryKeys.map(pk => pk.Name), read);
2391
+ if (keys) {
2392
+ census.IdentityHash = hashStrings(keys);
2393
+ const newest = keys.reduce((max, k) => Math.max(max, Number(k.slice(k.lastIndexOf('\u0002') + 1))), Number.NEGATIVE_INFINITY);
2394
+ census.MaxUpdatedAt = Number.isFinite(newest) ? new Date(newest).toISOString() : null;
2395
+ }
2396
+ return census;
2397
+ }
2398
+ /**
2399
+ * Converts rows from a cache-change payload into the shape this config's property expects. /**
1891
2400
  * Converts rows from a cache-change payload into the shape this config's property expects.
1892
2401
  *
1893
2402
  * Cache payloads are JSON, so their rows are plain objects with string dates. A config loaded
@@ -1946,12 +2455,21 @@ export class BaseEngine extends BaseSingleton {
1946
2455
  }
1947
2456
  /**
1948
2457
  * Refreshes a specific item.
2458
+ *
2459
+ * Rebuilds derived state before notifying subscribers: this replaces the property's array, so
2460
+ * anything a subclass derived from the previous instances — grouped child collections, memoized
2461
+ * lookups — refers to objects the engine has just discarded. This is also the callback the
2462
+ * expiration timer fires, so without the rebuild a config with an expiry quietly served stale
2463
+ * derived state until something else happened to rebuild it.
2464
+ *
1949
2465
  * @param propertyName - The name of the property to refresh
1950
2466
  */
1951
2467
  async RefreshItem(propertyName) {
1952
2468
  const config = this._metadataConfigs.find(c => c.PropertyName === propertyName) || this._dynamicConfigs.get(propertyName);
1953
2469
  if (config) {
1954
- await this.LoadSingleConfig(config, this._contextUser);
2470
+ await this.LoadSingleConfig(config, this._contextUser, /*bypassCache*/ false, /*suppressEmit*/ true);
2471
+ await this.RebuildDerivedState(this._contextUser);
2472
+ this.emitPropertyChange(config.PropertyName);
1955
2473
  }
1956
2474
  }
1957
2475
  /**