@memberjunction/core 6.2.0-edge.2 → 6.2.0-edge.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/generic/InMemoryLocalStorageProvider.d.ts +5 -0
  2. package/dist/generic/InMemoryLocalStorageProvider.d.ts.map +1 -1
  3. package/dist/generic/InMemoryLocalStorageProvider.js +5 -0
  4. package/dist/generic/InMemoryLocalStorageProvider.js.map +1 -1
  5. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +27 -0
  6. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
  7. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts +19 -0
  8. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts.map +1 -1
  9. package/dist/generic/RegisterForStartup.d.ts +44 -0
  10. package/dist/generic/RegisterForStartup.d.ts.map +1 -1
  11. package/dist/generic/RegisterForStartup.js +124 -48
  12. package/dist/generic/RegisterForStartup.js.map +1 -1
  13. package/dist/generic/baseEngine.d.ts +214 -3
  14. package/dist/generic/baseEngine.d.ts.map +1 -1
  15. package/dist/generic/baseEngine.js +460 -34
  16. package/dist/generic/baseEngine.js.map +1 -1
  17. package/dist/generic/baseEngineSweeper.d.ts +35 -0
  18. package/dist/generic/baseEngineSweeper.d.ts.map +1 -0
  19. package/dist/generic/baseEngineSweeper.js +97 -0
  20. package/dist/generic/baseEngineSweeper.js.map +1 -0
  21. package/dist/generic/baseEntity.d.ts +91 -7
  22. package/dist/generic/baseEntity.d.ts.map +1 -1
  23. package/dist/generic/baseEntity.js +161 -16
  24. package/dist/generic/baseEntity.js.map +1 -1
  25. package/dist/generic/databaseProviderBase.d.ts +54 -11
  26. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  27. package/dist/generic/databaseProviderBase.js +129 -35
  28. package/dist/generic/databaseProviderBase.js.map +1 -1
  29. package/dist/generic/entityConfiguration.d.ts +20 -0
  30. package/dist/generic/entityConfiguration.d.ts.map +1 -1
  31. package/dist/generic/entityConfiguration.js +34 -0
  32. package/dist/generic/entityConfiguration.js.map +1 -1
  33. package/dist/generic/entityEventBatch.d.ts +124 -0
  34. package/dist/generic/entityEventBatch.d.ts.map +1 -0
  35. package/dist/generic/entityEventBatch.js +194 -0
  36. package/dist/generic/entityEventBatch.js.map +1 -0
  37. package/dist/generic/entityInfo.d.ts +57 -6
  38. package/dist/generic/entityInfo.d.ts.map +1 -1
  39. package/dist/generic/entityInfo.js +72 -7
  40. package/dist/generic/entityInfo.js.map +1 -1
  41. package/dist/generic/entityRecordNameCache.d.ts +48 -0
  42. package/dist/generic/entityRecordNameCache.d.ts.map +1 -0
  43. package/dist/generic/entityRecordNameCache.js +92 -0
  44. package/dist/generic/entityRecordNameCache.js.map +1 -0
  45. package/dist/generic/interfaces.d.ts +151 -8
  46. package/dist/generic/interfaces.d.ts.map +1 -1
  47. package/dist/generic/interfaces.js.map +1 -1
  48. package/dist/generic/jsonFieldBinding.d.ts +196 -0
  49. package/dist/generic/jsonFieldBinding.d.ts.map +1 -0
  50. package/dist/generic/jsonFieldBinding.js +508 -0
  51. package/dist/generic/jsonFieldBinding.js.map +1 -0
  52. package/dist/generic/localCacheManager.d.ts +278 -16
  53. package/dist/generic/localCacheManager.d.ts.map +1 -1
  54. package/dist/generic/localCacheManager.js +737 -113
  55. package/dist/generic/localCacheManager.js.map +1 -1
  56. package/dist/generic/logging.d.ts +15 -0
  57. package/dist/generic/logging.d.ts.map +1 -1
  58. package/dist/generic/logging.js +17 -0
  59. package/dist/generic/logging.js.map +1 -1
  60. package/dist/generic/metadata.d.ts +1 -1
  61. package/dist/generic/metadata.d.ts.map +1 -1
  62. package/dist/generic/metadata.js.map +1 -1
  63. package/dist/generic/providerBase.d.ts +252 -35
  64. package/dist/generic/providerBase.d.ts.map +1 -1
  65. package/dist/generic/providerBase.js +503 -189
  66. package/dist/generic/providerBase.js.map +1 -1
  67. package/dist/generic/runQuery.d.ts +18 -1
  68. package/dist/generic/runQuery.d.ts.map +1 -1
  69. package/dist/generic/runQuery.js.map +1 -1
  70. package/dist/generic/runQuerySQLFilterImplementations.d.ts +7 -1
  71. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  72. package/dist/generic/runQuerySQLFilterImplementations.js +65 -140
  73. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  74. package/dist/generic/securityInfo.d.ts +5 -0
  75. package/dist/generic/securityInfo.d.ts.map +1 -1
  76. package/dist/generic/securityInfo.js +5 -0
  77. package/dist/generic/securityInfo.js.map +1 -1
  78. package/dist/generic/telemetryManager.d.ts +11 -0
  79. package/dist/generic/telemetryManager.d.ts.map +1 -1
  80. package/dist/generic/telemetryManager.js +43 -0
  81. package/dist/generic/telemetryManager.js.map +1 -1
  82. package/dist/generic/transactionGroup.d.ts +27 -0
  83. package/dist/generic/transactionGroup.d.ts.map +1 -1
  84. package/dist/generic/transactionGroup.js +58 -5
  85. package/dist/generic/transactionGroup.js.map +1 -1
  86. package/dist/generic/util.d.ts +6 -0
  87. package/dist/generic/util.d.ts.map +1 -1
  88. package/dist/generic/util.js +20 -0
  89. package/dist/generic/util.js.map +1 -1
  90. package/dist/index.d.ts +3 -0
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +3 -0
  93. package/dist/index.js.map +1 -1
  94. package/dist/views/runView.d.ts +24 -0
  95. package/dist/views/runView.d.ts.map +1 -1
  96. package/dist/views/runView.js +4 -0
  97. package/dist/views/runView.js.map +1 -1
  98. package/package.json +3 -3
  99. package/readme.md +29 -2
@@ -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";
@@ -43,6 +44,34 @@ export class BaseEnginePropertyConfig extends BaseInfo {
43
44
  Object.assign(this, init);
44
45
  }
45
46
  }
47
+ /**
48
+ * Timestamps closer than this are treated as equal, as the smart-cache check does: the database
49
+ * and JavaScript round datetimes differently.
50
+ */
51
+ const SWEEP_TIMESTAMP_TOLERANCE_MS = 1000;
52
+ /** Resolves on a later macrotask, after I/O callbacks already queued have run. Works in browsers too. */
53
+ function yieldToEventLoop() {
54
+ return new Promise(resolve => setTimeout(resolve, 0));
55
+ }
56
+ /**
57
+ * 64-bit FNV-1a over a list of strings, as hex. Synchronous and dependency-free so it runs in the
58
+ * browser too; for comparing snapshots, not for security.
59
+ * @internal
60
+ */
61
+ function hashStrings(values) {
62
+ let h1 = 0x811c9dc5;
63
+ let h2 = 0x01000193 ^ 0x5bd1e995;
64
+ for (const value of values) {
65
+ for (let i = 0; i < value.length; i++) {
66
+ const c = value.charCodeAt(i);
67
+ h1 = Math.imul(h1 ^ c, 0x01000193);
68
+ h2 = Math.imul(h2 ^ c, 0x01000193);
69
+ }
70
+ h1 = Math.imul(h1 ^ 0x0a, 0x01000193); // separator, so ['ab','c'] != ['a','bc']
71
+ h2 = Math.imul(h2 ^ 0x0b, 0x01000193);
72
+ }
73
+ return (h1 >>> 0).toString(16).padStart(8, '0') + (h2 >>> 0).toString(16).padStart(8, '0');
74
+ }
46
75
  /**
47
76
  * Thrown when engine data is accessed but was never loaded because the
48
77
  * current user lacks read permissions on the underlying entities.
@@ -163,6 +192,8 @@ export class BaseEngine extends BaseSingleton {
163
192
  this._configRefreshGeneration = new Map();
164
193
  this._entityEventDebounceTime = 1500; // Default debounce time in milliseconds (1.5 seconds)
165
194
  this._eventRefreshRetryTimers = new Map();
195
+ /** Configs waiting for the cache manager to initialize before their callbacks can register. */
196
+ this._deferredCacheCallbackConfigs = null;
166
197
  }
167
198
  /**
168
199
  * Observable that emits when any data property changes due to a refresh.
@@ -486,9 +517,9 @@ export class BaseEngine extends BaseSingleton {
486
517
  }
487
518
  else if (!BaseEngine._systemUserFallbackWarned.has(this.constructor.name)) {
488
519
  BaseEngine._systemUserFallbackWarned.add(this.constructor.name);
489
- LogStatus(`${this.constructor.name}: could not resolve the MJ system user for server-side loading — ` +
520
+ LogWarning(`${this.constructor.name}: could not resolve the MJ system user for server-side loading — ` +
490
521
  `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.`);
522
+ `and is cached process-wide, so a restricted caller can leave incomplete data cached for everyone.`, 'Cache');
492
523
  }
493
524
  }
494
525
  /**********************************************************************
@@ -1308,7 +1339,7 @@ export class BaseEngine extends BaseSingleton {
1308
1339
  if (config.CacheLocal) {
1309
1340
  this.syncLocalCacheForConfig(config, event).catch(e => {
1310
1341
  // 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}`);
1342
+ LogWarning(`BaseEngine: Failed to sync local cache for ${config.EntityName}: ${e}`, 'Cache');
1312
1343
  });
1313
1344
  }
1314
1345
  }
@@ -1351,6 +1382,14 @@ export class BaseEngine extends BaseSingleton {
1351
1382
  return;
1352
1383
  }
1353
1384
  const entity = event.baseEntity;
1385
+ // Inside a batch the cache manager records this change and rewrites every slot indexed for
1386
+ // the entity — this config's included — once, when the batch closes. The event is checked
1387
+ // as well as the provider because this runs after an await, by which time a batch that
1388
+ // took the event may already have closed.
1389
+ const cache = LocalCacheManager.Instance;
1390
+ if (cache.IsBatchingEntityEvents(entity?.ProviderToUse) || cache.WasTakenByEntityEventBatch(event)) {
1391
+ return;
1392
+ }
1354
1393
  // Get the connection string from the provider for fingerprint generation
1355
1394
  // The provider is needed because fingerprints include connection prefix
1356
1395
  const provider = this.ProviderToUse;
@@ -1474,21 +1513,9 @@ export class BaseEngine extends BaseSingleton {
1474
1513
  const user = contextUser || this.ProviderToUse?.CurrentUser;
1475
1514
  if (!user)
1476
1515
  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
- }
1516
+ const deniedEntities = this.findUnreadableEntities(configs, user);
1490
1517
  if (deniedEntities.length > 0) {
1491
- LogStatus(`${this.constructor.name}: Skipping ${configs.length} entity config(s) — user ${user.Email} lacks read permission on: ${deniedEntities.join(', ')}`);
1518
+ LogWarning(`${this.constructor.name}: Skipping ${configs.length} entity config(s) — user ${user.Email} lacks read permission on: ${deniedEntities.join(', ')}`, 'Cache');
1492
1519
  // Mark all entity configs as successfully loaded with empty data.
1493
1520
  // This is not a failure — it's expected behavior for limited-permission users.
1494
1521
  // Marking as successful allows the engine to set _loaded = true and avoid
@@ -1510,17 +1537,34 @@ export class BaseEngine extends BaseSingleton {
1510
1537
  }
1511
1538
  return configs;
1512
1539
  }
1540
+ /** The entity names among `configs` that `user` cannot read, per current metadata. */
1541
+ findUnreadableEntities(configs, user) {
1542
+ const md = this.ProviderToUse;
1543
+ const denied = [];
1544
+ for (const config of configs) {
1545
+ if (!config.EntityName)
1546
+ continue;
1547
+ const entityInfo = md.EntityByName(config.EntityName);
1548
+ if (!entityInfo)
1549
+ continue; // Entity not in metadata — let RunView handle it
1550
+ const perms = entityInfo.GetUserPermisions(user);
1551
+ if (!perms || !perms.CanRead) {
1552
+ denied.push(config.EntityName);
1553
+ }
1554
+ }
1555
+ return denied;
1556
+ }
1513
1557
  /**
1514
1558
  * Loads a single metadata configuration.
1515
1559
  * @param config - The metadata configuration to load
1516
1560
  * @param contextUser - The context user information
1517
1561
  * @param bypassCache - When true, bypasses server-side cache to get fresh data from the database
1518
1562
  */
1519
- async LoadSingleConfig(config, contextUser, bypassCache = false) {
1563
+ async LoadSingleConfig(config, contextUser, bypassCache = false, suppressEmit = false) {
1520
1564
  if (config.Type === 'dataset')
1521
1565
  return await this.LoadSingleDatasetConfig(config, contextUser, bypassCache);
1522
1566
  else
1523
- return await this.LoadSingleEntityConfig(config, contextUser, bypassCache);
1567
+ return await this.LoadSingleEntityConfig(config, contextUser, bypassCache, suppressEmit);
1524
1568
  }
1525
1569
  /**
1526
1570
  * Opens a new full-refresh "generation" for a property and returns its token. Each call
@@ -1541,12 +1585,31 @@ export class BaseEngine extends BaseSingleton {
1541
1585
  isLatestConfigRefresh(propertyName, generation) {
1542
1586
  return this._configRefreshGeneration.get(propertyName) === generation;
1543
1587
  }
1588
+ /**
1589
+ * Resolves {@link BaseEnginePropertyConfig.IncludeBinaryFields} for this engine's provider:
1590
+ * `'DatabaseProviderOnly'` becomes true on a database provider and false on any other.
1591
+ */
1592
+ ResolveConfigIncludeBinaryFields(config) {
1593
+ if (config.IncludeBinaryFields === 'DatabaseProviderOnly') {
1594
+ return this.ProviderToUse?.ProviderType === ProviderType.Database;
1595
+ }
1596
+ return config.IncludeBinaryFields === true;
1597
+ }
1544
1598
  /**
1545
1599
  * Builds the RunViewParams for an engine config. Used by LoadSingleEntityConfig,
1546
1600
  * LoadMultipleEntityConfigs, RegisterCacheChangeCallbacks, and syncLocalCacheForConfig
1547
1601
  * to ensure the fingerprint-affecting params (EntityName, ExtraFilter, OrderBy,
1548
- * IgnoreMaxRows) are always consistent — preventing cache key mismatches that break
1602
+ * IgnoreMaxRows, IncludeBinaryFields) are always consistent — preventing cache key mismatches that break
1549
1603
  * cross-server invalidation via Redis pub/sub and local cache upsert/remove operations.
1604
+ *
1605
+ * **Row-level security is not part of the fingerprints built from these params.** The engine
1606
+ * builds them with `GenerateRunViewFingerprint(params, connection)`, without the RLS clause the
1607
+ * provider adds for the reading user. On a server that is correct: engines load as the system
1608
+ * user, whose RLS clause is empty, so the provider's fingerprint for the same read matches. An
1609
+ * engine loaded by an RLS-scoped user (possible on a client) would compute an unscoped
1610
+ * fingerprint for a slot the provider stored under a scoped one, so its cache callbacks and
1611
+ * `syncLocalCacheForConfig` would target a different slot. Pass the RLS clause here if such an
1612
+ * engine is ever needed.
1550
1613
  */
1551
1614
  BuildRunViewParamsForConfig(config, bypassCache = false) {
1552
1615
  return {
@@ -1558,6 +1621,10 @@ export class BaseEngine extends BaseSingleton {
1558
1621
  _fromEngine: true, // Mark as engine-initiated to avoid false positive telemetry warnings
1559
1622
  CacheLocal: config.CacheLocal,
1560
1623
  CacheLocalTTL: config.CacheLocalTTL,
1624
+ // Present only when true. RunViewParams.Equals treats undefined and false alike, so
1625
+ // fingerprints are unaffected, and an engine that never asked for binary fields sends
1626
+ // the same request shape it always did (a network provider forwards any key that is set).
1627
+ ...(this.ResolveConfigIncludeBinaryFields(config) ? { IncludeBinaryFields: true } : {}),
1561
1628
  BypassCache: bypassCache
1562
1629
  };
1563
1630
  }
@@ -1567,7 +1634,7 @@ export class BaseEngine extends BaseSingleton {
1567
1634
  * @param contextUser
1568
1635
  * @param bypassCache - When true, bypasses server-side cache to get fresh data from the database
1569
1636
  */
1570
- async LoadSingleEntityConfig(config, contextUser, bypassCache = false) {
1637
+ async LoadSingleEntityConfig(config, contextUser, bypassCache = false, suppressEmit = false) {
1571
1638
  // Claim a refresh generation BEFORE the awaited RunView. If another full refresh for
1572
1639
  // this same property starts while our RunView is in flight, ours becomes stale and must
1573
1640
  // not commit — otherwise concurrent refreshes (the filtered/OrderBy path, e.g.
@@ -1586,7 +1653,12 @@ export class BaseEngine extends BaseSingleton {
1586
1653
  return;
1587
1654
  }
1588
1655
  this.HandleSingleViewResult(config, result, contextUser);
1589
- this.emitPropertyChange(config.PropertyName);
1656
+ if (!suppressEmit) {
1657
+ // A caller that rebuilds derived state afterwards emits itself, once, AFTER the
1658
+ // rebuild — subscribers must never observe a property whose derived state is still
1659
+ // the previous load's (the invariant stated on OnExternalCacheChange).
1660
+ this.emitPropertyChange(config.PropertyName);
1661
+ }
1590
1662
  }
1591
1663
  /**
1592
1664
  * Handles the result of a single view load.
@@ -1613,7 +1685,7 @@ export class BaseEngine extends BaseSingleton {
1613
1685
  // the user still gets no data. Logged at status level, not error, because
1614
1686
  // for a restricted role this is expected, not a fault.
1615
1687
  this.MarkConfigEmptyLoaded(config);
1616
- LogStatus(`BaseEngine: ${config.EntityName} not readable by current role — loaded empty (restricted-role degradation).`);
1688
+ LogWarning(`BaseEngine: ${config.EntityName} not readable by current role — loaded empty (restricted-role degradation).`, 'Cache');
1617
1689
  }
1618
1690
  else {
1619
1691
  // TRANSIENT failure (network, MJAPI restart, etc.) — leave loadedSuccessfully
@@ -1673,7 +1745,7 @@ export class BaseEngine extends BaseSingleton {
1673
1745
  if (config.AddToObject !== false) {
1674
1746
  this[config.PropertyName] = [];
1675
1747
  }
1676
- this._dataMap.set(config.PropertyName, { entityName: config.EntityName, data: [], loadedSuccessfully: true });
1748
+ this._dataMap.set(config.PropertyName, { entityName: config.EntityName, data: [], loadedSuccessfully: true, readDenied: true });
1677
1749
  this.NotifyDataChange(config, []);
1678
1750
  }
1679
1751
  /**
@@ -1796,17 +1868,35 @@ export class BaseEngine extends BaseSingleton {
1796
1868
  }
1797
1869
  this._cacheChangeUnsubscribers = [];
1798
1870
  if (!LocalCacheManager.Instance.IsInitialized) {
1871
+ this.deferCacheChangeRegistration(entityConfigs);
1799
1872
  return;
1800
1873
  }
1801
- const connectionPrefix = this.RunViewProviderToUse instanceof ProviderBase
1802
- ? this.RunViewProviderToUse.InstanceConnectionString
1803
- : undefined;
1804
1874
  for (const config of entityConfigs) {
1805
- const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(this.BuildRunViewParamsForConfig(config), connectionPrefix);
1875
+ const fingerprint = this.configFingerprint(config);
1806
1876
  const unsubscribe = LocalCacheManager.Instance.RegisterChangeCallback(fingerprint, (event) => this.OnExternalCacheChange(config, event));
1807
1877
  this._cacheChangeUnsubscribers.push(unsubscribe);
1808
1878
  }
1809
1879
  }
1880
+ /**
1881
+ * An engine that loads before `LocalCacheManager` is initialized (a custom host, a script that
1882
+ * touches an engine before `StartupManager` runs) would otherwise never hear another server's
1883
+ * cache changes: nothing registers its callbacks later except a forced reload. Registration is
1884
+ * deferred to initialization instead; only the latest config list is kept.
1885
+ */
1886
+ deferCacheChangeRegistration(entityConfigs) {
1887
+ const alreadyWaiting = this._deferredCacheCallbackConfigs !== null;
1888
+ this._deferredCacheCallbackConfigs = entityConfigs;
1889
+ if (alreadyWaiting) {
1890
+ return;
1891
+ }
1892
+ LocalCacheManager.Instance.WhenInitialized(() => {
1893
+ const configs = this._deferredCacheCallbackConfigs;
1894
+ this._deferredCacheCallbackConfigs = null;
1895
+ if (configs) {
1896
+ this.RegisterCacheChangeCallbacks(configs);
1897
+ }
1898
+ });
1899
+ }
1810
1900
  /**
1811
1901
  * Called when another server instance updates cached data that this engine
1812
1902
  * is tracking. Default behavior: reload the affected config from the database.
@@ -1818,16 +1908,35 @@ export class BaseEngine extends BaseSingleton {
1818
1908
  * @param event - The cache change event from the other server
1819
1909
  */
1820
1910
  async OnExternalCacheChange(config, event) {
1911
+ // A config the current user cannot read stays empty: neither a peer's rows nor a reload
1912
+ // may fill it. The per-record event path applies the same rule.
1913
+ if (this._isPermissionConstrained || this._dataMap.get(config.PropertyName)?.readDenied) {
1914
+ return;
1915
+ }
1821
1916
  // If the event includes the new data, try to apply it directly
1822
1917
  if (event.Data && event.Action === 'set') {
1823
1918
  try {
1824
1919
  const parsed = JSON.parse(event.Data);
1825
1920
  if (parsed?.results && Array.isArray(parsed.results)) {
1921
+ // A payload carrying exactly the rows this engine already holds tells it nothing.
1922
+ // Skipping it leaves the engine as if the event had not arrived: no generation is
1923
+ // claimed, so a reload already in flight still commits its (possibly newer) rows.
1924
+ if (this.payloadMatchesHeldRows(config, parsed.results)) {
1925
+ LogStatusEx({ message: `BaseEngine.OnExternalCacheChange: '${config.PropertyName}' already holds the payload's rows — skipped`, verboseOnly: true });
1926
+ return;
1927
+ }
1826
1928
  // Claim a refresh generation BEFORE the awaited materialization — the same
1827
1929
  // protocol LoadSingleConfig uses around its awaited RunView. Without it, two
1828
1930
  // overlapping cache events (or an event racing a full reload) can resolve out
1829
1931
  // of order and the stale result would be the one that assigns last.
1830
1932
  const generation = this.beginConfigRefresh(config.PropertyName);
1933
+ // A burst of payloads for one slot usually arrives together. Let the rest of the
1934
+ // burst claim its generations first, so only the newest payload is materialized
1935
+ // instead of every one of them (receive side).
1936
+ await yieldToEventLoop();
1937
+ if (!this.isLatestConfigRefresh(config.PropertyName, generation)) {
1938
+ return;
1939
+ }
1831
1940
  const rows = await this.materializeCacheEventRows(config, parsed.results);
1832
1941
  if (!this.isLatestConfigRefresh(config.PropertyName, generation)) {
1833
1942
  return; // superseded while materializing — the newer refresh owns the property
@@ -1865,15 +1974,323 @@ export class BaseEngine extends BaseSingleton {
1865
1974
  // materialization failures (e.g. an entity class that cannot construct in
1866
1975
  // this process), and without a trace the only symptom is every cache event
1867
1976
  // turning into a database reload.
1868
- LogStatus(`BaseEngine.OnExternalCacheChange: payload for '${config.PropertyName}' could not be applied (${e instanceof Error ? e.message : String(e)}) — falling back to full reload`);
1977
+ LogWarning(`BaseEngine.OnExternalCacheChange: payload for '${config.PropertyName}' could not be applied (${e instanceof Error ? e.message : String(e)}) — falling back to full reload`, 'Cache');
1869
1978
  }
1870
1979
  }
1871
- // Fallback: reload this config from the database. This also replaces the property,
1872
- // so the derived state has to be rebuilt here too.
1873
- await this.LoadSingleConfig(config, this._contextUser);
1980
+ // Fallback: reload this config from the database. This also replaces the property, so the
1981
+ // derived state has to be rebuilt here too — and the reload must NOT emit on its own, or a
1982
+ // subscriber wakes between the new rows and the rebuild and reads derived state still
1983
+ // pointing at the instances this reload discarded.
1984
+ await this.LoadSingleConfig(config, this._contextUser, /*bypassCache*/ false, /*suppressEmit*/ true);
1874
1985
  await this.RebuildDerivedState(this._contextUser);
1986
+ this.emitPropertyChange(config.PropertyName);
1987
+ }
1988
+ /**
1989
+ * True when `payloadRows` are the same row versions this config currently holds: the same
1990
+ * number of rows, and the same multiset of (primary key, `__mj_UpdatedAt`) pairs.
1991
+ *
1992
+ * Identity is derived from what the engine holds right now, never remembered, so no other
1993
+ * assignment path has to keep anything in step with it. (An earlier version remembered the last
1994
+ * payload applied and went stale whenever another path replaced the property.) Comparing rows,
1995
+ * not the writer's `maxUpdatedAt`, keeps it independent of how the writer stamped the slot, and
1996
+ * catches a delete paired with an insert.
1997
+ *
1998
+ * Returns false — apply the payload — whenever identity cannot be established: the config is not
1999
+ * loaded, the entity has no `__mj_UpdatedAt` column, or a timestamp cannot be read.
2000
+ * Known limit: a change that does not bump `__mj_UpdatedAt` (direct SQL) is invisible here, as it
2001
+ * is to the client smart-cache check.
2002
+ */
2003
+ payloadMatchesHeldRows(config, payloadRows) {
2004
+ try {
2005
+ return this.compareHeldRows(config, payloadRows);
2006
+ }
2007
+ catch (e) {
2008
+ // The skip is an optimization; failing to decide must never stop the payload applying.
2009
+ LogStatusEx({ message: `BaseEngine.payloadMatchesHeldRows: could not compare '${config.PropertyName}' (${e instanceof Error ? e.message : String(e)}) — applying the payload`, verboseOnly: true });
2010
+ return false;
2011
+ }
2012
+ }
2013
+ compareHeldRows(config, payloadRows) {
2014
+ const entry = this._dataMap.get(config.PropertyName);
2015
+ if (!entry?.loadedSuccessfully || !config.EntityName || !Array.isArray(entry.data)) {
2016
+ return false;
2017
+ }
2018
+ if (entry.data.length !== payloadRows.length) {
2019
+ return false;
2020
+ }
2021
+ const entityInfo = this.ProviderToUse?.EntityByName(config.EntityName);
2022
+ if (!entityInfo || !entityInfo.Fields.some(f => f.IsUpdatedAtField)) {
2023
+ return false;
2024
+ }
2025
+ const keyFields = entityInfo.PrimaryKeys.map(pk => pk.Name);
2026
+ const held = this.rowVersionKeys(entry.data, keyFields, (row, field) => row instanceof BaseEntity ? row.Get(field) : row[field]);
2027
+ const incoming = this.rowVersionKeys(payloadRows, keyFields, (row, field) => row[field]);
2028
+ if (!held || !incoming) {
2029
+ return false;
2030
+ }
2031
+ return held.every((key, i) => key === incoming[i]);
1875
2032
  }
1876
2033
  /**
2034
+ * Sorted `pk|epochMs` strings for a row set, or null when any row lacks a readable timestamp.
2035
+ * Key values are lower-cased so GUID casing differences between sources do not matter.
2036
+ */
2037
+ rowVersionKeys(rows, keyFields, read) {
2038
+ const keys = [];
2039
+ for (const row of rows) {
2040
+ const raw = read(row, EntityInfo.UpdatedAtFieldName);
2041
+ const stamp = raw instanceof Date ? raw.getTime() : new Date(String(raw)).getTime();
2042
+ if (raw == null || Number.isNaN(stamp)) {
2043
+ return null;
2044
+ }
2045
+ const pk = keyFields.map(f => String(read(row, f) ?? '').toLowerCase()).join('');
2046
+ keys.push(`${pk}${stamp}`);
2047
+ }
2048
+ return keys.sort();
2049
+ }
2050
+ // ========================================================================
2051
+ // RECONCILIATION WITH THE DATABASE (plan Phase 3.1)
2052
+ // ========================================================================
2053
+ /**
2054
+ * Compares each loaded entity config with the database — row count and newest
2055
+ * `__mj_UpdatedAt` — and reloads the ones that differ. Finds changes that never raised an MJ
2056
+ * event: direct SQL, other applications, restores. Everything else reaches engines through
2057
+ * events and does not need this.
2058
+ *
2059
+ * One batched status query for the whole engine. A reloaded config is written to the cache
2060
+ * once, so on a shared cache every other server adopts the fresh rows from that one write.
2061
+ * Derived state is rebuilt once, after all reloads.
2062
+ *
2063
+ * Limits: an UPDATE that does not change `__mj_UpdatedAt` or the row count is invisible, as it
2064
+ * is to the smart-cache check. Entities without `__mj_UpdatedAt` are compared by row count
2065
+ * only; dataset configs are not checked.
2066
+ *
2067
+ * @returns What was checked and reloaded. Never throws.
2068
+ */
2069
+ async SweepAgainstDatabase() {
2070
+ const result = { EngineClass: this.constructor.name, Checked: 0, Reloaded: [], Errors: [] };
2071
+ const provider = this.RunViewProviderToUse;
2072
+ const configs = this.sweepableConfigs();
2073
+ if (!this.Loaded || this._isPermissionConstrained || !provider?.GetRunViewsDatabaseStatus || configs.length === 0) {
2074
+ return result;
2075
+ }
2076
+ try {
2077
+ const statuses = await provider.GetRunViewsDatabaseStatus(configs.map(c => this.BuildRunViewParamsForConfig(c)), this._contextUser);
2078
+ const stale = this.staleConfigs(configs, statuses, result);
2079
+ await this.reloadStaleConfigs(stale, result);
2080
+ }
2081
+ catch (e) {
2082
+ result.Errors.push(e instanceof Error ? e.message : String(e));
2083
+ }
2084
+ if (result.Errors.length > 0) {
2085
+ LogWarning(`BaseEngine.SweepAgainstDatabase(${result.EngineClass}): ${result.Errors.join('; ')}`, 'Cache');
2086
+ }
2087
+ return result;
2088
+ }
2089
+ /**
2090
+ * Whether this engine holds any config a sweep could act on — an entity config, loaded and
2091
+ * readable, whose entity declares `TrustServerCacheCompletely = false`.
2092
+ *
2093
+ * Exposed so a caller can decide whether the engine is worth the cost of asking. The fleet
2094
+ * sweeper takes a cross-process lease per engine per interval; on an installation where nothing
2095
+ * declares out-of-band writes, every one of those leases would be claimed only to discover there
2096
+ * is nothing to compare. Reading this first is an in-memory check.
2097
+ */
2098
+ HasSweepableConfigs() {
2099
+ return this.sweepableConfigs().length > 0;
2100
+ }
2101
+ /** Entity configs holding a successful, readable load whose entity can drift outside MJ. */
2102
+ sweepableConfigs() {
2103
+ return this._metadataConfigs.filter(config => {
2104
+ const entry = this._dataMap.get(config.PropertyName);
2105
+ if (config.Type !== 'entity' || !entry?.loadedSuccessfully || entry.readDenied || entry.permissionDenied) {
2106
+ return false;
2107
+ }
2108
+ return this.entityMayChangeWithoutAnEvent(config.EntityName);
2109
+ });
2110
+ }
2111
+ /**
2112
+ * Whether this entity has **declared** that its rows can change without firing an event — the
2113
+ * only situation the sweep exists for.
2114
+ *
2115
+ * `Entity.TrustServerCacheCompletely` is that declaration, in its own words: when true (the
2116
+ * default) the cache trusts "that all mutations flow through BaseEntity.Save() which fires cache
2117
+ * invalidation events", and it is set false "for entities whose rows are created as side-effects
2118
+ * of other operations via raw SQL … since those inserts bypass BaseEntity and never trigger
2119
+ * cache invalidation". An entity that still trusts its cache has nothing for a sweep to find, so
2120
+ * querying it every interval buys nothing.
2121
+ *
2122
+ * It also subsumes the `AllowDirectSQLInsert`/`Update`/`Delete` flags: a database CHECK requires
2123
+ * `TrustServerCacheCompletely = false` whenever any of those is set, so this one flag already
2124
+ * covers every entity that sanctions writes outside `BaseEntity`.
2125
+ *
2126
+ * **Why gate at all.** The sweep is a periodic database query, and a periodic query is not free:
2127
+ * on Azure SQL serverless it prevents auto-pause outright, which several installs pay for, and
2128
+ * the interval does not help — auto-pause needs sustained inactivity, so a long interval is no
2129
+ * better than a short one. Gating here makes the cost proportional to the declared risk: an
2130
+ * engine whose entities are all trusted is never swept and issues no queries. Operators who do
2131
+ * write out of band mark those entities — which they must do anyway for the cache to be correct
2132
+ * at all — and get the backstop exactly there.
2133
+ *
2134
+ * An entity absent from metadata answers false: the sweep cannot establish that it drifts, and
2135
+ * guessing in favour of a recurring query is the expensive guess.
2136
+ */
2137
+ entityMayChangeWithoutAnEvent(entityName) {
2138
+ if (!entityName) {
2139
+ return false;
2140
+ }
2141
+ return this.ProviderToUse?.EntityByName(entityName)?.TrustServerCacheCompletely === false;
2142
+ }
2143
+ /** The configs whose held rows disagree with what the database reports. */
2144
+ staleConfigs(configs, statuses, result) {
2145
+ const stale = [];
2146
+ configs.forEach((config, i) => {
2147
+ const status = statuses[i];
2148
+ if (!status?.Success) {
2149
+ result.Errors.push(`${config.PropertyName}: ${status?.ErrorMessage ?? 'no status returned'}`);
2150
+ return;
2151
+ }
2152
+ result.Checked++;
2153
+ if (!this.heldRowsMatchDatabase(config, status)) {
2154
+ stale.push(config);
2155
+ }
2156
+ });
2157
+ return stale;
2158
+ }
2159
+ /**
2160
+ * Row count and newest `__mj_UpdatedAt` of the held rows against the database's.
2161
+ *
2162
+ * The held stamp is computed the way SQL's `MAX` computes the database's — **ignoring rows
2163
+ * whose timestamp is null** — rather than taken from the census, whose stamp is null unless
2164
+ * EVERY row carries one (it also backs an identity hash, which needs them all). Comparing a
2165
+ * census stamp with a SQL `MAX` made one un-stamped row in a config mean "stale" on every
2166
+ * sweep, forever: a reload every interval, each one rewriting the shared slot and making every
2167
+ * peer reload too. Two sides that both have no stamp at all agree; one with
2168
+ * and one without is a real difference.
2169
+ */
2170
+ heldRowsMatchDatabase(config, status) {
2171
+ const entry = this._dataMap.get(config.PropertyName);
2172
+ if (!entry) {
2173
+ return true;
2174
+ }
2175
+ const rows = Array.isArray(entry.data) ? entry.data : [];
2176
+ if (rows.length !== status.RowCount) {
2177
+ return false;
2178
+ }
2179
+ const entityInfo = this.ProviderToUse?.EntityByName(config.EntityName);
2180
+ if (!entityInfo?.Fields.some(f => f.IsUpdatedAtField) || rows.length === 0) {
2181
+ return true; // nothing to compare beyond the count
2182
+ }
2183
+ const heldMax = LocalCacheManager.MaxUpdatedAtOfRows(rows.map(row => row instanceof BaseEntity ? row.GetAll() : row));
2184
+ if (!heldMax && !status.MaxUpdatedAt) {
2185
+ return true; // neither side has a usable stamp; the counts already agreed
2186
+ }
2187
+ if (!heldMax || !status.MaxUpdatedAt) {
2188
+ return false;
2189
+ }
2190
+ return Math.abs(Date.parse(heldMax) - Date.parse(status.MaxUpdatedAt)) < SWEEP_TIMESTAMP_TOLERANCE_MS;
2191
+ }
2192
+ /** Reloads each stale config from the database, stores it once, then rebuilds derived state. */
2193
+ async reloadStaleConfigs(stale, result) {
2194
+ for (const config of stale) {
2195
+ await this.LoadSingleConfig(config, this._contextUser, true, /*suppressEmit*/ true);
2196
+ if (!this._dataMap.get(config.PropertyName)?.loadedSuccessfully) {
2197
+ result.Errors.push(`${config.PropertyName}: reload failed`);
2198
+ continue;
2199
+ }
2200
+ await this.storeConfigRows(config);
2201
+ result.Reloaded.push(config.PropertyName);
2202
+ }
2203
+ if (result.Reloaded.length === 0) {
2204
+ return;
2205
+ }
2206
+ await this.RebuildDerivedState(this._contextUser);
2207
+ for (const propertyName of result.Reloaded) {
2208
+ this.emitPropertyChange(propertyName);
2209
+ }
2210
+ LogStatusEx({ message: `BaseEngine.SweepAgainstDatabase(${result.EngineClass}): reloaded ${result.Reloaded.join(', ')}`, verboseOnly: false });
2211
+ }
2212
+ /**
2213
+ * Writes a config's rows to the cache slot the engine reads and listens on. The reload
2214
+ * bypassed the cache, so without this the slot — and every server reading it — would keep the
2215
+ * old rows.
2216
+ */
2217
+ async storeConfigRows(config) {
2218
+ const cache = LocalCacheManager.Instance;
2219
+ const rows = this._dataMap.get(config.PropertyName)?.data;
2220
+ if (!cache.IsInitialized || !Array.isArray(rows)) {
2221
+ return;
2222
+ }
2223
+ const plainRows = rows.map(row => row instanceof BaseEntity ? row.GetAll() : row);
2224
+ const params = this.BuildRunViewParamsForConfig(config);
2225
+ // Never the clock: a stamp the database cannot reproduce makes the slot permanently
2226
+ // "stale" — every currency check sees a different value and refetches, and the next sweep
2227
+ // stamps it again with a newer one. `''` is what the database's own MAX reports for rows
2228
+ // with no timestamp, and what the other two write funnels store.
2229
+ const maxUpdatedAt = LocalCacheManager.MaxUpdatedAtOfRows(plainRows) ?? '';
2230
+ // Under the slot's cross-process lock: a peer may be part-way through its own
2231
+ // read-modify-write of this slot, and an unlocked replace would clobber it.
2232
+ await cache.ReplaceRunViewResultLocked(this.configFingerprint(config), params, plainRows, maxUpdatedAt, plainRows.length, this.ProviderToUse);
2233
+ }
2234
+ /** The cache fingerprint of a config's slot, as {@link RegisterCacheChangeCallbacks} computes it. */
2235
+ configFingerprint(config) {
2236
+ const provider = this.RunViewProviderToUse;
2237
+ const connectionPrefix = provider instanceof ProviderBase ? provider.InstanceConnectionString : undefined;
2238
+ return LocalCacheManager.Instance.GenerateRunViewFingerprint(this.BuildRunViewParamsForConfig(config), connectionPrefix);
2239
+ }
2240
+ /**
2241
+ * Snapshot of what this engine holds: per property, the row count, newest `__mj_UpdatedAt`,
2242
+ * and a hash of the row versions; plus the engine's own derived-state counts. Read-only and
2243
+ * cheap enough for diagnostics and startup checks (O(rows)).
2244
+ */
2245
+ GetStateCensus() {
2246
+ const properties = [];
2247
+ for (const [propertyName, entry] of this._dataMap) {
2248
+ properties.push(this.censusForProperty(propertyName, entry));
2249
+ }
2250
+ properties.sort((a, b) => a.PropertyName.localeCompare(b.PropertyName));
2251
+ return {
2252
+ EngineClass: this.constructor.name,
2253
+ Loaded: this.Loaded,
2254
+ PermissionConstrained: this._isPermissionConstrained,
2255
+ Properties: properties,
2256
+ Derived: this.GetDerivedStateCensus(),
2257
+ };
2258
+ }
2259
+ /**
2260
+ * Counts that describe the state a subclass derives in {@link AdditionalLoading} — grouped
2261
+ * child collections, lookup maps. Override to expose them; the default reports nothing.
2262
+ * Must be read-only and must not throw.
2263
+ */
2264
+ GetDerivedStateCensus() {
2265
+ return {};
2266
+ }
2267
+ censusForProperty(propertyName, entry) {
2268
+ const rows = Array.isArray(entry.data) ? entry.data : [];
2269
+ const census = {
2270
+ PropertyName: propertyName,
2271
+ EntityName: entry.entityName,
2272
+ DatasetName: entry.datasetName,
2273
+ RowCount: rows.length,
2274
+ MaxUpdatedAt: null,
2275
+ IdentityHash: null,
2276
+ LoadedSuccessfully: entry.loadedSuccessfully,
2277
+ PermissionDenied: entry.permissionDenied === true || entry.readDenied === true,
2278
+ };
2279
+ const entityInfo = entry.entityName ? this.ProviderToUse?.EntityByName(entry.entityName) : undefined;
2280
+ if (!entityInfo) {
2281
+ return census;
2282
+ }
2283
+ const read = (row, field) => row instanceof BaseEntity ? row.Get(field) : row?.[field];
2284
+ const keys = this.rowVersionKeys(rows, entityInfo.PrimaryKeys.map(pk => pk.Name), read);
2285
+ if (keys) {
2286
+ census.IdentityHash = hashStrings(keys);
2287
+ const newest = keys.reduce((max, k) => Math.max(max, Number(k.slice(k.lastIndexOf('\u0002') + 1))), Number.NEGATIVE_INFINITY);
2288
+ census.MaxUpdatedAt = Number.isFinite(newest) ? new Date(newest).toISOString() : null;
2289
+ }
2290
+ return census;
2291
+ }
2292
+ /**
2293
+ * Converts rows from a cache-change payload into the shape this config's property expects. /**
1877
2294
  * Converts rows from a cache-change payload into the shape this config's property expects.
1878
2295
  *
1879
2296
  * Cache payloads are JSON, so their rows are plain objects with string dates. A config loaded
@@ -1932,12 +2349,21 @@ export class BaseEngine extends BaseSingleton {
1932
2349
  }
1933
2350
  /**
1934
2351
  * Refreshes a specific item.
2352
+ *
2353
+ * Rebuilds derived state before notifying subscribers: this replaces the property's array, so
2354
+ * anything a subclass derived from the previous instances — grouped child collections, memoized
2355
+ * lookups — refers to objects the engine has just discarded. This is also the callback the
2356
+ * expiration timer fires, so without the rebuild a config with an expiry quietly served stale
2357
+ * derived state until something else happened to rebuild it.
2358
+ *
1935
2359
  * @param propertyName - The name of the property to refresh
1936
2360
  */
1937
2361
  async RefreshItem(propertyName) {
1938
2362
  const config = this._metadataConfigs.find(c => c.PropertyName === propertyName) || this._dynamicConfigs.get(propertyName);
1939
2363
  if (config) {
1940
- await this.LoadSingleConfig(config, this._contextUser);
2364
+ await this.LoadSingleConfig(config, this._contextUser, /*bypassCache*/ false, /*suppressEmit*/ true);
2365
+ await this.RebuildDerivedState(this._contextUser);
2366
+ this.emitPropertyChange(config.PropertyName);
1941
2367
  }
1942
2368
  }
1943
2369
  /**