@memberjunction/core 6.2.0-edge.6 → 6.2.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 (74) hide show
  1. package/dist/generic/EntityFieldRules.d.ts.map +1 -1
  2. package/dist/generic/EntityFieldRules.js +1 -0
  3. package/dist/generic/EntityFieldRules.js.map +1 -1
  4. package/dist/generic/baseEngine.d.ts +118 -8
  5. package/dist/generic/baseEngine.d.ts.map +1 -1
  6. package/dist/generic/baseEngine.js +528 -82
  7. package/dist/generic/baseEngine.js.map +1 -1
  8. package/dist/generic/baseEntity.d.ts +63 -4
  9. package/dist/generic/baseEntity.d.ts.map +1 -1
  10. package/dist/generic/baseEntity.js +625 -55
  11. package/dist/generic/baseEntity.js.map +1 -1
  12. package/dist/generic/baseRemotableOperation.d.ts +95 -6
  13. package/dist/generic/baseRemotableOperation.d.ts.map +1 -1
  14. package/dist/generic/baseRemotableOperation.js +112 -8
  15. package/dist/generic/baseRemotableOperation.js.map +1 -1
  16. package/dist/generic/databaseProviderBase.d.ts +37 -0
  17. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  18. package/dist/generic/databaseProviderBase.js +222 -40
  19. package/dist/generic/databaseProviderBase.js.map +1 -1
  20. package/dist/generic/entityInfo.d.ts +6 -0
  21. package/dist/generic/entityInfo.d.ts.map +1 -1
  22. package/dist/generic/entityInfo.js +18 -12
  23. package/dist/generic/entityInfo.js.map +1 -1
  24. package/dist/generic/externalDataSourceReadRouter.d.ts +10 -0
  25. package/dist/generic/externalDataSourceReadRouter.d.ts.map +1 -1
  26. package/dist/generic/externalDataSourceReadRouter.js +12 -0
  27. package/dist/generic/externalDataSourceReadRouter.js.map +1 -1
  28. package/dist/generic/fieldSecuritySQLGate.d.ts +11 -2
  29. package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -1
  30. package/dist/generic/fieldSecuritySQLGate.js +6 -2
  31. package/dist/generic/fieldSecuritySQLGate.js.map +1 -1
  32. package/dist/generic/interfaces.d.ts +50 -1
  33. package/dist/generic/interfaces.d.ts.map +1 -1
  34. package/dist/generic/interfaces.js.map +1 -1
  35. package/dist/generic/listMembershipFilter.d.ts +5 -0
  36. package/dist/generic/listMembershipFilter.d.ts.map +1 -0
  37. package/dist/generic/listMembershipFilter.js +23 -0
  38. package/dist/generic/listMembershipFilter.js.map +1 -0
  39. package/dist/generic/localCacheManager.d.ts +48 -5
  40. package/dist/generic/localCacheManager.d.ts.map +1 -1
  41. package/dist/generic/localCacheManager.js +146 -17
  42. package/dist/generic/localCacheManager.js.map +1 -1
  43. package/dist/generic/providerBase.d.ts +155 -2
  44. package/dist/generic/providerBase.d.ts.map +1 -1
  45. package/dist/generic/providerBase.js +362 -124
  46. package/dist/generic/providerBase.js.map +1 -1
  47. package/dist/generic/relatedRecordCollection.d.ts +5 -0
  48. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  49. package/dist/generic/relatedRecordCollection.js +11 -2
  50. package/dist/generic/relatedRecordCollection.js.map +1 -1
  51. package/dist/generic/remoteOperationDispatch.d.ts.map +1 -1
  52. package/dist/generic/remoteOperationDispatch.js +14 -2
  53. package/dist/generic/remoteOperationDispatch.js.map +1 -1
  54. package/dist/generic/runQuerySQLFilterImplementations.d.ts +33 -6
  55. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  56. package/dist/generic/runQuerySQLFilterImplementations.js +81 -27
  57. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  58. package/dist/generic/transportError.d.ts +41 -0
  59. package/dist/generic/transportError.d.ts.map +1 -0
  60. package/dist/generic/transportError.js +167 -0
  61. package/dist/generic/transportError.js.map +1 -0
  62. package/dist/generic/util.d.ts +12 -0
  63. package/dist/generic/util.d.ts.map +1 -1
  64. package/dist/generic/util.js +52 -3
  65. package/dist/generic/util.js.map +1 -1
  66. package/dist/index.d.ts +2 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +2 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/views/runView.d.ts +23 -10
  71. package/dist/views/runView.d.ts.map +1 -1
  72. package/dist/views/runView.js +5 -1
  73. package/dist/views/runView.js.map +1 -1
  74. package/package.json +3 -3
@@ -14,7 +14,7 @@ import { BaseEngineRegistry } from "./baseEngineRegistry.js";
14
14
  import { LocalCacheManager } from "./localCacheManager.js";
15
15
  import { ProviderBase } from "./providerBase.js";
16
16
  import { DatabaseProviderBase } from "./databaseProviderBase.js";
17
- import { TransformSimpleObjectToEntityObject } from "./util.js";
17
+ import { TransformSimpleObjectToEntityObject, CanonicalPrimaryKeyValue } from "./util.js";
18
18
  import { WellKnownUserSource } from "./wellKnownUserSource.js";
19
19
  /**
20
20
  * Property configuration for the BaseEngine class to automatically load/set properties on the class.
@@ -127,6 +127,21 @@ export class BaseEngine extends BaseSingleton {
127
127
  });
128
128
  return this._derivedStateRebuild;
129
129
  }
130
+ /**
131
+ * The least time, in milliseconds, between the end of one reload from
132
+ * {@link reloadAfterExternalChange} and the start of the next for the same property. A change
133
+ * that arrives after this long since the last reload reloads at once; one that arrives sooner
134
+ * (including while a reload is running) waits out the rest of it, and every change that arrives
135
+ * meanwhile shares that one reload.
136
+ *
137
+ * Without it, a steady stream of changes (a server writing rows of one entity a few times a
138
+ * second) keeps a property reloading back to back. Each full reload costs time proportional to
139
+ * the table, so the process spends most of its time reloading and the cost of the stream grows
140
+ * with the square of its length. With it, a property reloads at most once per this delay plus
141
+ * one reload's duration, and its data is never more than that far behind the last change.
142
+ * 0 restores back-to-back reloads.
143
+ */
144
+ static { this.ExternalChangeFollowUpDelayMs = 1000; }
130
145
  /**
131
146
  * Returns an Observable for a specific engine array property. Subscribers receive the
132
147
  * current array immediately (BehaviorSubject semantics), then re-receive the same array
@@ -199,11 +214,25 @@ export class BaseEngine extends BaseSingleton {
199
214
  */
200
215
  this._configRefreshGeneration = new Map();
201
216
  /**
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}.
217
+ * Reloads started by {@link OnExternalCacheChange} and by the no-row fallback of
218
+ * {@link HandleRemoteInvalidateEvent}, keyed by config PropertyName. While one is running,
219
+ * further events for that property set `Again` instead of starting another, so a burst of
220
+ * events costs at most two reloads per property. See {@link reloadAfterExternalChange}.
205
221
  */
206
222
  this._externalChangeReloads = new Map();
223
+ /**
224
+ * Per-property count of in-flight full refreshes (`LoadSingleEntityConfig` or full `'set'`
225
+ * cache payload materialization). When positive, incoming `row_delta` events defer to a
226
+ * coalesced full reload rather than racing an in-progress snapshot replacement.
227
+ */
228
+ this._inFlightFullRefreshCount = new Map();
229
+ /**
230
+ * Per-property promise chain serializing `row_delta` applications so back-to-back row deltas
231
+ * for distinct rows apply in arrival order without dropping earlier mutations.
232
+ */
233
+ this._rowDeltaQueues = new Map();
234
+ /** When the last reload from {@link reloadAfterExternalChange} ended, per config PropertyName (epoch ms). */
235
+ this._externalChangeReloadEndedAt = new Map();
207
236
  this._entityEventDebounceTime = 1500; // Default debounce time in milliseconds (1.5 seconds)
208
237
  this._eventRefreshRetryTimers = new Map();
209
238
  /**
@@ -769,9 +798,7 @@ export class BaseEngine extends BaseSingleton {
769
798
  if (!entityName) {
770
799
  return true; // No entity name, nothing to do
771
800
  }
772
- const matchingConfigs = this._metadataConfigs.filter((config) => {
773
- return config.AutoRefresh && config.EntityName && config.EntityName.trim().toLowerCase() === entityName;
774
- });
801
+ const matchingConfigs = this.AutoRefreshConfigsForEntity(entityName);
775
802
  if (matchingConfigs.length === 0) {
776
803
  return true;
777
804
  }
@@ -794,22 +821,9 @@ export class BaseEngine extends BaseSingleton {
794
821
  }
795
822
  // Fall through to server fetch if direct delete failed
796
823
  }
797
- // Fallback: re-fetch from server (missing data or apply failure). Bypass the cache
798
- // for the same reason as the local-event path: this fetch runs BECAUSE a
799
- // (cross-server) write signaled the cache stale, so reading back through it would
800
- // re-sync the pre-write snapshot.
801
- let refreshCount = 0;
824
+ // Fallback: re-fetch from the server (no row, or it could not be applied).
802
825
  for (const config of matchingConfigs) {
803
- await this.LoadSingleConfig(config, this._contextUser, /*bypassCache*/ true);
804
- if (!this.configLoadedSuccessfully(config.PropertyName)) {
805
- // Same one-shot hazard as the debounced path: the invalidation event is
806
- // consumed, so a transient failure here needs a bounded retry too.
807
- this.scheduleEventRefreshRetry(config, 1);
808
- }
809
- refreshCount++;
810
- }
811
- if (refreshCount > 0) {
812
- await this.AdditionalLoading(this._contextUser);
826
+ await this.reloadAfterRemoteInvalidate(config);
813
827
  }
814
828
  return true;
815
829
  }
@@ -818,6 +832,38 @@ export class BaseEngine extends BaseSingleton {
818
832
  return false;
819
833
  }
820
834
  }
835
+ /**
836
+ * Reloads one config because a remote-invalidate event said it changed but carried no row to
837
+ * apply. A burst of such events shares reloads (see {@link reloadAfterExternalChange}), so a
838
+ * server that saves many rows of one entity costs this engine at most two reloads per burst
839
+ * instead of one per row, and the reload that runs last began after the last event arrived.
840
+ *
841
+ * The reload reads past the cache: it runs because a write elsewhere made the cached copy
842
+ * stale, and reading back through the cache would restore the pre-write snapshot. A transient
843
+ * failure gets the bounded retry, since the event that asked for this reload is consumed.
844
+ */
845
+ async reloadAfterRemoteInvalidate(config) {
846
+ let succeeded;
847
+ try {
848
+ await this.reloadAfterExternalChange(config, /*bypassCache*/ true);
849
+ succeeded = this.configLoadedSuccessfully(config.PropertyName);
850
+ }
851
+ catch (e) {
852
+ LogError(e);
853
+ succeeded = false;
854
+ }
855
+ if (!succeeded) {
856
+ this.scheduleEventRefreshRetry(config, 1);
857
+ }
858
+ }
859
+ /**
860
+ * The entity configs that refresh automatically when a record of `entityName` changes.
861
+ * Matching ignores case and surrounding whitespace.
862
+ */
863
+ AutoRefreshConfigsForEntity(entityName) {
864
+ const name = entityName.trim().toLowerCase();
865
+ return this._metadataConfigs.filter((config) => config.AutoRefresh && config.EntityName && config.EntityName.trim().toLowerCase() === name);
866
+ }
821
867
  /**
822
868
  * Applies record data from a remote-invalidate event directly to the engine's in-memory arrays.
823
869
  * Creates a BaseEntity instance, loads the JSON data into it, then updates the matching
@@ -1549,16 +1595,15 @@ export class BaseEngine extends BaseSingleton {
1549
1595
  */
1550
1596
  async LoadConfigs(configs, contextUser, bypassCache = false) {
1551
1597
  this._metadataConfigs = configs.map(c => this.UpgradeObjectToConfig(c));
1552
- // now, break up the configs into two chunks, datasets and views of entities so we can load all the views in a single network call via RunViews()
1553
1598
  const entityConfigs = this._metadataConfigs.filter(c => c.Type === 'entity');
1554
- const datasetConfigs = this._metadataConfigs.filter(c => c.Type === 'dataset');
1555
1599
  this.WarnAboutFieldSecuredEntities(entityConfigs);
1556
- // All-or-nothing permission check: if the user lacks read access on ANY
1557
- // entity config, skip ALL entity configs for this engine. Returns the
1558
- // original array unchanged when permissions pass, or an empty array when
1559
- // any entity is denied (marking the engine as permission-constrained).
1560
- const entityConfigsToLoad = this.CheckPermissionsOrSkipAll(entityConfigs, contextUser);
1561
- await Promise.all([...datasetConfigs.map(c => this.LoadSingleDatasetConfig(c, contextUser, bypassCache)),
1600
+ // All-or-nothing permission check across all configs (both entity configs and
1601
+ // dataset configs that declare their constituent Entities). If the user lacks
1602
+ // read access on ANY configured entity, skip ALL configs for this engine.
1603
+ const configsToLoad = this.CheckPermissionsOrSkipAll(this._metadataConfigs, contextUser);
1604
+ const entityConfigsToLoad = configsToLoad.filter(c => c.Type === 'entity');
1605
+ const datasetConfigsToLoad = configsToLoad.filter(c => c.Type === 'dataset');
1606
+ await Promise.all([...datasetConfigsToLoad.map(c => this.LoadSingleDatasetConfig(c, contextUser, bypassCache)),
1562
1607
  this.LoadMultipleEntityConfigs(entityConfigsToLoad, contextUser, bypassCache)]);
1563
1608
  // Register cross-server cache change callbacks for entity configs
1564
1609
  this.RegisterCacheChangeCallbacks(entityConfigs);
@@ -1613,16 +1658,14 @@ export class BaseEngine extends BaseSingleton {
1613
1658
  const missingEntities = this.findMissingEntities(configs);
1614
1659
  if (missingEntities.length > 0) {
1615
1660
  LogStatusEx({
1616
- message: `${this.constructor.name}: Skipping ${configs.length} entity config(s) — entity metadata not installed for: ${missingEntities.join(', ')}`,
1661
+ message: `${this.constructor.name}: Skipping ${configs.length} config(s) — entity metadata not installed for: ${missingEntities.join(', ')}`,
1617
1662
  verboseOnly: true,
1618
1663
  category: 'Cache',
1619
1664
  });
1620
1665
  for (const config of configs) {
1621
- if (config.AddToObject !== false) {
1622
- this[config.PropertyName] = [];
1623
- }
1666
+ this.initializeSkippedConfigProperty(config);
1624
1667
  this._dataMap.set(config.PropertyName, {
1625
- entityName: config.EntityName,
1668
+ ...(config.Type === 'dataset' ? { datasetName: config.DatasetName } : { entityName: config.EntityName }),
1626
1669
  data: [],
1627
1670
  loadedSuccessfully: true,
1628
1671
  });
@@ -1636,17 +1679,15 @@ export class BaseEngine extends BaseSingleton {
1636
1679
  return configs; // Can't check without a user — proceed with normal loading
1637
1680
  const deniedEntities = this.findUnreadableEntities(configs, user);
1638
1681
  if (deniedEntities.length > 0) {
1639
- LogWarning(`${this.constructor.name}: Skipping ${configs.length} entity config(s) — user ${user.Email} lacks read permission on: ${deniedEntities.join(', ')}`, 'Cache');
1640
- // Mark all entity configs as successfully loaded with empty data.
1682
+ LogWarning(`${this.constructor.name}: Skipping ${configs.length} config(s) — user ${user.Email} lacks read permission on: ${deniedEntities.join(', ')}`, 'Cache');
1683
+ // Mark all configs as successfully loaded with empty data.
1641
1684
  // This is not a failure — it's expected behavior for limited-permission users.
1642
1685
  // Marking as successful allows the engine to set _loaded = true and avoid
1643
1686
  // endless retry loops from EnsureLoaded().
1644
1687
  for (const config of configs) {
1645
- if (config.AddToObject !== false) {
1646
- this[config.PropertyName] = [];
1647
- }
1688
+ this.initializeSkippedConfigProperty(config);
1648
1689
  this._dataMap.set(config.PropertyName, {
1649
- entityName: config.EntityName,
1690
+ ...(config.Type === 'dataset' ? { datasetName: config.DatasetName } : { entityName: config.EntityName }),
1650
1691
  data: [],
1651
1692
  loadedSuccessfully: true,
1652
1693
  permissionDenied: true,
@@ -1654,10 +1695,42 @@ export class BaseEngine extends BaseSingleton {
1654
1695
  }
1655
1696
  this._isPermissionConstrained = true;
1656
1697
  this._deniedEntityNames = deniedEntities;
1657
- return []; // Return empty — don't attempt any RunView calls
1698
+ return []; // Return empty — don't attempt any RunView or dataset calls
1658
1699
  }
1659
1700
  return configs;
1660
1701
  }
1702
+ /**
1703
+ * Initializes the target property on `this` when a config is skipped before loading.
1704
+ * Entity configs receive `[]`; `single_property` dataset configs receive `{}` only when
1705
+ * the subclass has not already initialized the property to a default container.
1706
+ */
1707
+ initializeSkippedConfigProperty(config) {
1708
+ if (config.AddToObject === false) {
1709
+ return;
1710
+ }
1711
+ if (config.Type === 'dataset') {
1712
+ if (config.DatasetResultHandling === 'single_property' &&
1713
+ this[config.PropertyName] == null) {
1714
+ this[config.PropertyName] = {};
1715
+ }
1716
+ }
1717
+ else {
1718
+ this[config.PropertyName] = [];
1719
+ }
1720
+ }
1721
+ /**
1722
+ * Returns the entity name(s) governed by `config`: `Entities` (or `EntityName`) for
1723
+ * dataset configs, and `EntityName` for entity configs.
1724
+ */
1725
+ configEntityNames(config) {
1726
+ if (config.Type === 'dataset') {
1727
+ if (config.Entities && config.Entities.length > 0) {
1728
+ return config.Entities;
1729
+ }
1730
+ return config.EntityName ? [config.EntityName] : [];
1731
+ }
1732
+ return config.EntityName ? [config.EntityName] : [];
1733
+ }
1661
1734
  /**
1662
1735
  * Returns entity names among `configs` that do not exist in the loaded metadata provider.
1663
1736
  * Only checks when the metadata provider actually has entities loaded (`md.Entities.length > 0`)
@@ -1670,10 +1743,10 @@ export class BaseEngine extends BaseSingleton {
1670
1743
  }
1671
1744
  const missing = [];
1672
1745
  for (const config of configs) {
1673
- if (!config.EntityName)
1674
- continue;
1675
- if (!md.EntityByName(config.EntityName)) {
1676
- missing.push(config.EntityName);
1746
+ for (const entityName of this.configEntityNames(config)) {
1747
+ if (!md.EntityByName(entityName) && !missing.includes(entityName)) {
1748
+ missing.push(entityName);
1749
+ }
1677
1750
  }
1678
1751
  }
1679
1752
  return missing;
@@ -1683,14 +1756,14 @@ export class BaseEngine extends BaseSingleton {
1683
1756
  const md = this.ProviderToUse;
1684
1757
  const denied = [];
1685
1758
  for (const config of configs) {
1686
- if (!config.EntityName)
1687
- continue;
1688
- const entityInfo = md.EntityByName(config.EntityName);
1689
- if (!entityInfo)
1690
- continue; // Entity not in metadata — let RunView handle it
1691
- const perms = entityInfo.GetUserPermisions(user);
1692
- if (!perms || !perms.CanRead) {
1693
- denied.push(config.EntityName);
1759
+ for (const entityName of this.configEntityNames(config)) {
1760
+ const entityInfo = md.EntityByName(entityName);
1761
+ if (!entityInfo)
1762
+ continue; // Entity not in metadata — let RunView/dataset handle it
1763
+ const perms = entityInfo.GetUserPermisions(user);
1764
+ if ((!perms || !perms.CanRead) && !denied.includes(entityName)) {
1765
+ denied.push(entityName);
1766
+ }
1694
1767
  }
1695
1768
  }
1696
1769
  return denied;
@@ -1777,9 +1850,16 @@ export class BaseEngine extends BaseSingleton {
1777
1850
  // UserInfoEngine's per-user '_UserApplications') apply out of initiation-order and the
1778
1851
  // cache ends up "one operation behind". See _configRefreshGeneration.
1779
1852
  const generation = this.beginConfigRefresh(config.PropertyName);
1780
- const p = this.RunViewProviderToUse;
1781
- const rv = new RunView(p);
1782
- const result = await rv.RunView(this.BuildRunViewParamsForConfig(config, bypassCache), contextUser);
1853
+ this.incrementInFlightFullRefresh(config.PropertyName);
1854
+ let result;
1855
+ try {
1856
+ const p = this.RunViewProviderToUse;
1857
+ const rv = new RunView(p);
1858
+ result = await rv.RunView(this.BuildRunViewParamsForConfig(config, bypassCache), contextUser);
1859
+ }
1860
+ finally {
1861
+ this.decrementInFlightFullRefresh(config.PropertyName);
1862
+ }
1783
1863
  // A newer full refresh superseded us while we awaited — drop this (staler) snapshot
1784
1864
  // rather than clobber the newer one. The newer refresh owns the assignment, the
1785
1865
  // observer notification, and (on failure) the retry, so bailing here leaves no
@@ -1884,6 +1964,35 @@ export class BaseEngine extends BaseSingleton {
1884
1964
  this._dataMap.set(config.PropertyName, { entityName: config.EntityName, data: [], loadedSuccessfully: true, readDenied: true });
1885
1965
  this.NotifyDataChange(config, []);
1886
1966
  }
1967
+ /**
1968
+ * Returns true when a failed dataset load was caused by the current user lacking Read
1969
+ * permission on one of the dataset's constituent entities — either per local metadata
1970
+ * (`config.Entities` / `config.EntityName`) or per the server's dataset status message
1971
+ * (`does not have read permissions on ...`).
1972
+ */
1973
+ IsDatasetReadDenied(config, result, contextUser) {
1974
+ for (const entityName of this.configEntityNames(config)) {
1975
+ if (!this.ContextUserCanReadConfigEntity(entityName, contextUser)) {
1976
+ return true;
1977
+ }
1978
+ }
1979
+ return typeof result.Status === 'string' && /does not have read permissions? on/i.test(result.Status);
1980
+ }
1981
+ /**
1982
+ * Records a dataset config as successfully loaded in an empty state after a permanent
1983
+ * permission denial, so `BaseEngine.Load` marks the engine loaded and does not loop on
1984
+ * `EnsureLoaded()` / `Config()`.
1985
+ */
1986
+ MarkDatasetConfigEmptyLoaded(config) {
1987
+ this.initializeSkippedConfigProperty(config);
1988
+ this._dataMap.set(config.PropertyName, {
1989
+ datasetName: config.DatasetName,
1990
+ data: [],
1991
+ loadedSuccessfully: true,
1992
+ readDenied: true,
1993
+ });
1994
+ this.NotifyDataChange(config, []);
1995
+ }
1887
1996
  /**
1888
1997
  * Handles the process of loading multiple entity configs in a single network call via RunViews()
1889
1998
  * @param configs
@@ -1892,13 +2001,33 @@ export class BaseEngine extends BaseSingleton {
1892
2001
  */
1893
2002
  async LoadMultipleEntityConfigs(configs, contextUser, bypassCache = false) {
1894
2003
  if (configs && configs.length > 0) {
1895
- const p = this.RunViewProviderToUse;
1896
- const rv = new RunView(p);
1897
- const viewConfigs = configs.map(c => this.BuildRunViewParamsForConfig(c, bypassCache));
1898
- const results = await rv.RunViews(viewConfigs, contextUser);
2004
+ // Same protocol as LoadSingleEntityConfig, per property: claim a refresh generation and
2005
+ // mark a full refresh in flight BEFORE the awaited RunViews. Without it a `row_delta`
2006
+ // that arrives mid-flight is applied to the held array and then overwritten by this
2007
+ // (pre-delta) snapshot when the batch lands; with it the delta path defers to a
2008
+ // coalesced reload instead, and a newer per-property refresh supersedes our slot.
2009
+ const generations = configs.map(c => this.beginConfigRefresh(c.PropertyName));
2010
+ for (const c of configs) {
2011
+ this.incrementInFlightFullRefresh(c.PropertyName);
2012
+ }
2013
+ let results;
2014
+ try {
2015
+ const p = this.RunViewProviderToUse;
2016
+ const rv = new RunView(p);
2017
+ const viewConfigs = configs.map(c => this.BuildRunViewParamsForConfig(c, bypassCache));
2018
+ results = await rv.RunViews(viewConfigs, contextUser);
2019
+ }
2020
+ finally {
2021
+ for (const c of configs) {
2022
+ this.decrementInFlightFullRefresh(c.PropertyName);
2023
+ }
2024
+ }
1899
2025
  // Process results and record entity loads for redundancy detection
1900
2026
  const entityNames = [];
1901
2027
  for (let i = 0; i < configs.length; i++) {
2028
+ if (!this.isLatestConfigRefresh(configs[i].PropertyName, generations[i])) {
2029
+ continue; // a newer refresh of this property owns the assignment
2030
+ }
1902
2031
  this.HandleSingleViewResult(configs[i], results[i], contextUser);
1903
2032
  this.emitPropertyChange(configs[i].PropertyName);
1904
2033
  if (configs[i].EntityName) {
@@ -1981,7 +2110,9 @@ export class BaseEngine extends BaseSingleton {
1981
2110
  let result;
1982
2111
  if (bypassCache) {
1983
2112
  result = await p.GetDatasetByName(config.DatasetName, config.DatasetItemFilters, contextUser, undefined, true);
1984
- await p.CacheDataset(config.DatasetName, config.DatasetItemFilters, result);
2113
+ if (result?.Success) {
2114
+ await p.CacheDataset(config.DatasetName, config.DatasetItemFilters, result);
2115
+ }
1985
2116
  }
1986
2117
  else {
1987
2118
  result = await p.GetAndCacheDatasetByName(config.DatasetName, config.DatasetItemFilters, contextUser);
@@ -1998,6 +2129,11 @@ export class BaseEngine extends BaseSingleton {
1998
2129
  return;
1999
2130
  }
2000
2131
  if (!result.Success) {
2132
+ if (this.IsDatasetReadDenied(config, result, contextUser)) {
2133
+ this.MarkDatasetConfigEmptyLoaded(config);
2134
+ LogWarning(`BaseEngine: Dataset ${config.DatasetName} not readable by current role — loaded empty (restricted-role degradation).`, 'Cache');
2135
+ return;
2136
+ }
2001
2137
  const errorMsg = result.Status || `Dataset "${config.DatasetName}" load returned Success=false`;
2002
2138
  LogError(`BaseEngine: Failed to load dataset ${config.DatasetName} into ${config.PropertyName}: ${errorMsg}`);
2003
2139
  this._dataMap.set(config.PropertyName, {
@@ -2139,14 +2275,21 @@ export class BaseEngine extends BaseSingleton {
2139
2275
  // overlapping cache events (or an event racing a full reload) can resolve out
2140
2276
  // of order and the stale result would be the one that assigns last.
2141
2277
  const generation = this.beginConfigRefresh(config.PropertyName);
2142
- // A burst of payloads for one slot usually arrives together. Let the rest of the
2143
- // burst claim its generations first, so only the newest payload is materialized
2144
- // instead of every one of them (receive side).
2145
- await yieldToEventLoop();
2146
- if (!this.isLatestConfigRefresh(config.PropertyName, generation)) {
2147
- return;
2278
+ this.incrementInFlightFullRefresh(config.PropertyName);
2279
+ let rows = null;
2280
+ try {
2281
+ // A burst of payloads for one slot usually arrives together. Let the rest of the
2282
+ // burst claim its generations first, so only the newest payload is materialized
2283
+ // instead of every one of them (receive side).
2284
+ await yieldToEventLoop();
2285
+ if (!this.isLatestConfigRefresh(config.PropertyName, generation)) {
2286
+ return;
2287
+ }
2288
+ rows = await this.materializeCacheEventRows(config, parsed.results);
2289
+ }
2290
+ finally {
2291
+ this.decrementInFlightFullRefresh(config.PropertyName);
2148
2292
  }
2149
- const rows = await this.materializeCacheEventRows(config, parsed.results);
2150
2293
  if (!this.isLatestConfigRefresh(config.PropertyName, generation)) {
2151
2294
  return; // superseded while materializing — the newer refresh owns the property
2152
2295
  }
@@ -2186,8 +2329,270 @@ export class BaseEngine extends BaseSingleton {
2186
2329
  LogWarning(`BaseEngine.OnExternalCacheChange: payload for '${config.PropertyName}' could not be applied (${e instanceof Error ? e.message : String(e)}) — falling back to full reload`, 'Cache');
2187
2330
  }
2188
2331
  }
2332
+ else if (event.Data && event.Action === 'row_delta') {
2333
+ const outcome = await this.applyExternalRowDelta(config, event.Data);
2334
+ if (outcome === 'applied' || outcome === 'superseded') {
2335
+ return;
2336
+ }
2337
+ }
2189
2338
  await this.reloadAfterExternalChange(config);
2190
2339
  }
2340
+ incrementInFlightFullRefresh(propertyName) {
2341
+ this._inFlightFullRefreshCount.set(propertyName, (this._inFlightFullRefreshCount.get(propertyName) ?? 0) + 1);
2342
+ }
2343
+ decrementInFlightFullRefresh(propertyName) {
2344
+ const next = (this._inFlightFullRefreshCount.get(propertyName) ?? 1) - 1;
2345
+ if (next <= 0) {
2346
+ this._inFlightFullRefreshCount.delete(propertyName);
2347
+ }
2348
+ else {
2349
+ this._inFlightFullRefreshCount.set(propertyName, next);
2350
+ }
2351
+ }
2352
+ /**
2353
+ * Applies a compact `row_delta` pub/sub event in $O(\Delta)$ without re-materializing
2354
+ * untouched sibling rows. Falls back to `'reload'` whenever schema hash, ordering, or
2355
+ * row-count invariants indicate the engine cannot prove local delta equivalence.
2356
+ */
2357
+ async applyExternalRowDelta(config, rawDeltaJson) {
2358
+ let delta;
2359
+ try {
2360
+ delta = JSON.parse(rawDeltaJson);
2361
+ }
2362
+ catch (e) {
2363
+ LogWarning(`BaseEngine.OnExternalCacheChange: row_delta payload for '${config.PropertyName}' could not be parsed (${e instanceof Error ? e.message : String(e)}) — falling back to reload`, 'Cache');
2364
+ return 'reload';
2365
+ }
2366
+ if (!delta ||
2367
+ !Array.isArray(delta.upsertedRows) ||
2368
+ !Array.isArray(delta.deletedPrimaryKeys) ||
2369
+ !Array.isArray(delta.primaryKeyFields) ||
2370
+ delta.primaryKeyFields.length === 0 ||
2371
+ typeof delta.rowCount !== 'number') {
2372
+ return 'reload';
2373
+ }
2374
+ if (this.payloadSchemaDiffers(config, delta.schemaHash)) {
2375
+ LogStatusEx({ message: `BaseEngine.OnExternalCacheChange: row_delta for '${config.PropertyName}' was written under a different schema — reloading instead`, verboseOnly: true });
2376
+ return 'reload';
2377
+ }
2378
+ // Ordered configs rely on database collation ordering; reload from the warm Redis slot (0 SQL).
2379
+ if (config.OrderBy && delta.upsertedRows.length > 0) {
2380
+ return 'reload';
2381
+ }
2382
+ let stepOutcome = 'reload';
2383
+ const priorQueue = this._rowDeltaQueues.get(config.PropertyName) ?? Promise.resolve();
2384
+ const currentStep = priorQueue.then(async () => {
2385
+ try {
2386
+ stepOutcome = await this.applyExternalRowDeltaSerialized(config, delta);
2387
+ }
2388
+ catch (e) {
2389
+ LogWarning(`BaseEngine.OnExternalCacheChange: row_delta application for '${config.PropertyName}' failed (${e instanceof Error ? e.message : String(e)}) — falling back to reload`, 'Cache');
2390
+ stepOutcome = 'reload';
2391
+ }
2392
+ });
2393
+ const settled = currentStep.catch(() => undefined);
2394
+ this._rowDeltaQueues.set(config.PropertyName, settled);
2395
+ await currentStep;
2396
+ if (this._rowDeltaQueues.get(config.PropertyName) === settled) {
2397
+ this._rowDeltaQueues.delete(config.PropertyName);
2398
+ }
2399
+ return stepOutcome;
2400
+ }
2401
+ async applyExternalRowDeltaSerialized(config, delta) {
2402
+ if ((this._inFlightFullRefreshCount.get(config.PropertyName) ?? 0) > 0) {
2403
+ return 'reload';
2404
+ }
2405
+ const heldRows = this.getHeldRowsForDelta(config);
2406
+ if (!heldRows) {
2407
+ return 'reload';
2408
+ }
2409
+ const entityInfo = config.EntityName ? this.ProviderToUse?.EntityByName?.(config.EntityName) : undefined;
2410
+ const pkFields = delta.primaryKeyFields;
2411
+ const isUuidPk = pkFields.map(fieldName => Boolean(entityInfo?.FieldByName(fieldName)?.IsUniqueIdentifier));
2412
+ const heldIndexByKey = this.indexHeldRowsForDelta(heldRows, pkFields, isUuidPk);
2413
+ if (!heldIndexByKey) {
2414
+ return 'reload';
2415
+ }
2416
+ const keyPlan = this.planRowDeltaKeys(delta, heldRows.length, heldIndexByKey, pkFields, isUuidPk);
2417
+ if (!keyPlan) {
2418
+ return 'reload';
2419
+ }
2420
+ // Echo / duplicate delta suppression: when no rows are added or removed and every
2421
+ // upserted row's __mj_UpdatedAt matches the held row, skip materialization & emit churn.
2422
+ if (keyPlan.matchedDeleteKeys.size === 0 &&
2423
+ keyPlan.netNewInserts === 0 &&
2424
+ this.upsertsMatchHeldTimestamps(heldRows, heldIndexByKey, delta.upsertedRows, keyPlan.upsertKeys, entityInfo)) {
2425
+ LogStatusEx({ message: `BaseEngine.OnExternalCacheChange: '${config.PropertyName}' already holds row_delta versions — skipped`, verboseOnly: true });
2426
+ return 'applied';
2427
+ }
2428
+ const generationBefore = this._configRefreshGeneration.get(config.PropertyName) ?? 0;
2429
+ const materializedUpserts = delta.upsertedRows.length > 0
2430
+ ? await this.materializeCacheEventRows(config, delta.upsertedRows)
2431
+ : [];
2432
+ if (materializedUpserts === null) {
2433
+ return 'reload';
2434
+ }
2435
+ if ((this._configRefreshGeneration.get(config.PropertyName) ?? 0) !== generationBefore ||
2436
+ (this._inFlightFullRefreshCount.get(config.PropertyName) ?? 0) > 0) {
2437
+ return 'superseded';
2438
+ }
2439
+ const nextRows = this.mergeMaterializedDeltaRows(heldRows, pkFields, isUuidPk, keyPlan.matchedDeleteKeys, keyPlan.upsertKeys, materializedUpserts);
2440
+ this.HandleSingleViewResult(config, {
2441
+ Success: true,
2442
+ Results: nextRows,
2443
+ RowCount: nextRows.length,
2444
+ TotalRowCount: delta.totalRowCount ?? nextRows.length,
2445
+ ExecutionTime: 0,
2446
+ ErrorMessage: '',
2447
+ UserViewRunID: '',
2448
+ });
2449
+ await this.RebuildDerivedState(this._contextUser);
2450
+ this.emitPropertyChange(config.PropertyName);
2451
+ return 'applied';
2452
+ }
2453
+ indexHeldRowsForDelta(heldRows, pkFields, isUuidPk) {
2454
+ const heldIndexByKey = new Map();
2455
+ for (let i = 0; i < heldRows.length; i++) {
2456
+ const key = this.canonicalHeldRowPkKey(heldRows[i], pkFields, isUuidPk);
2457
+ if (key === null) {
2458
+ return null;
2459
+ }
2460
+ heldIndexByKey.set(key, i);
2461
+ }
2462
+ return heldIndexByKey.size === heldRows.length ? heldIndexByKey : null;
2463
+ }
2464
+ planRowDeltaKeys(delta, heldRowCount, heldIndexByKey, pkFields, isUuidPk) {
2465
+ const matchedDeleteKeys = new Set();
2466
+ for (const delPk of delta.deletedPrimaryKeys) {
2467
+ const key = this.canonicalPlainRecordPkKey(delPk, pkFields, isUuidPk);
2468
+ if (key === null) {
2469
+ return null;
2470
+ }
2471
+ if (heldIndexByKey.has(key)) {
2472
+ matchedDeleteKeys.add(key);
2473
+ }
2474
+ }
2475
+ const upsertKeys = [];
2476
+ const seenUpsertKeys = new Set();
2477
+ let netNewInserts = 0;
2478
+ for (const upRow of delta.upsertedRows) {
2479
+ const key = this.canonicalPlainRecordPkKey(upRow, pkFields, isUuidPk);
2480
+ if (key === null || seenUpsertKeys.has(key)) {
2481
+ return null;
2482
+ }
2483
+ seenUpsertKeys.add(key);
2484
+ upsertKeys.push(key);
2485
+ if (!heldIndexByKey.has(key) || matchedDeleteKeys.has(key)) {
2486
+ netNewInserts++;
2487
+ }
2488
+ }
2489
+ // Strict cardinality verification: detects uninitialized state, missed pub/sub messages,
2490
+ // or filtered-slot membership divergence and falls back to reading the warm cache slot.
2491
+ const expectedNextLength = heldRowCount - matchedDeleteKeys.size + netNewInserts;
2492
+ if (expectedNextLength !== delta.rowCount) {
2493
+ return null;
2494
+ }
2495
+ return { matchedDeleteKeys, upsertKeys, netNewInserts };
2496
+ }
2497
+ mergeMaterializedDeltaRows(heldRows, pkFields, isUuidPk, matchedDeleteKeys, upsertKeys, materializedUpserts) {
2498
+ const upsertByKey = new Map();
2499
+ for (let i = 0; i < upsertKeys.length; i++) {
2500
+ upsertByKey.set(upsertKeys[i], materializedUpserts[i]);
2501
+ }
2502
+ const consumedUpsertKeys = new Set();
2503
+ const nextRows = [];
2504
+ for (let i = 0; i < heldRows.length; i++) {
2505
+ const existingRow = heldRows[i];
2506
+ const key = this.canonicalHeldRowPkKey(existingRow, pkFields, isUuidPk);
2507
+ if (upsertByKey.has(key)) {
2508
+ nextRows.push(upsertByKey.get(key));
2509
+ consumedUpsertKeys.add(key);
2510
+ }
2511
+ else if (!matchedDeleteKeys.has(key)) {
2512
+ nextRows.push(existingRow);
2513
+ }
2514
+ }
2515
+ for (let i = 0; i < upsertKeys.length; i++) {
2516
+ const key = upsertKeys[i];
2517
+ if (!consumedUpsertKeys.has(key)) {
2518
+ nextRows.push(upsertByKey.get(key));
2519
+ }
2520
+ }
2521
+ return nextRows;
2522
+ }
2523
+ getHeldRowsForDelta(config) {
2524
+ const entry = this._dataMap.get(config.PropertyName);
2525
+ if (entry && entry.loadedSuccessfully === false) {
2526
+ return null;
2527
+ }
2528
+ if (entry?.loadedSuccessfully && Array.isArray(entry.data)) {
2529
+ return entry.data;
2530
+ }
2531
+ const propVal = Reflect.get(this, config.PropertyName);
2532
+ if (Array.isArray(propVal) && (propVal.length > 0 || Boolean(entry))) {
2533
+ return propVal;
2534
+ }
2535
+ return null;
2536
+ }
2537
+ readHeldRowField(row, fieldName) {
2538
+ if (row instanceof BaseEntity) {
2539
+ return row.Get(fieldName);
2540
+ }
2541
+ if (fieldName in row) {
2542
+ return row[fieldName];
2543
+ }
2544
+ const nestedData = row['data'];
2545
+ if (typeof nestedData === 'object' && nestedData !== null && fieldName in nestedData) {
2546
+ return nestedData[fieldName];
2547
+ }
2548
+ return undefined;
2549
+ }
2550
+ canonicalHeldRowPkKey(row, pkFields, isUuidPk) {
2551
+ const parts = [];
2552
+ for (let i = 0; i < pkFields.length; i++) {
2553
+ const val = this.readHeldRowField(row, pkFields[i]);
2554
+ if (val === null || val === undefined) {
2555
+ return null;
2556
+ }
2557
+ const str = String(val);
2558
+ parts.push(CanonicalPrimaryKeyValue(str, isUuidPk[i]));
2559
+ }
2560
+ return parts.join('\u001f');
2561
+ }
2562
+ canonicalPlainRecordPkKey(record, pkFields, isUuidPk) {
2563
+ const parts = [];
2564
+ for (let i = 0; i < pkFields.length; i++) {
2565
+ const val = record[pkFields[i]];
2566
+ if (val === null || val === undefined) {
2567
+ return null;
2568
+ }
2569
+ const str = String(val);
2570
+ parts.push(CanonicalPrimaryKeyValue(str, isUuidPk[i]));
2571
+ }
2572
+ return parts.join('\u001f');
2573
+ }
2574
+ upsertsMatchHeldTimestamps(heldRows, heldIndexByKey, upsertedRows, upsertKeys, entityInfo) {
2575
+ if (!entityInfo || !entityInfo.Fields.some(f => f.IsUpdatedAtField)) {
2576
+ return false;
2577
+ }
2578
+ for (let i = 0; i < upsertedRows.length; i++) {
2579
+ const heldIdx = heldIndexByKey.get(upsertKeys[i]);
2580
+ if (heldIdx === undefined) {
2581
+ return false;
2582
+ }
2583
+ const heldRaw = this.readHeldRowField(heldRows[heldIdx], EntityInfo.UpdatedAtFieldName);
2584
+ const incomingRaw = upsertedRows[i][EntityInfo.UpdatedAtFieldName];
2585
+ if (heldRaw == null || incomingRaw == null) {
2586
+ return false;
2587
+ }
2588
+ const heldMs = heldRaw instanceof Date ? heldRaw.getTime() : new Date(String(heldRaw)).getTime();
2589
+ const incomingMs = incomingRaw instanceof Date ? incomingRaw.getTime() : new Date(String(incomingRaw)).getTime();
2590
+ if (Number.isNaN(heldMs) || Number.isNaN(incomingMs) || heldMs !== incomingMs) {
2591
+ return false;
2592
+ }
2593
+ }
2594
+ return true;
2595
+ }
2191
2596
  /**
2192
2597
  * True when a cache-change payload carries a schema hash and it differs from this engine's
2193
2598
  * hash for the config's entity. A payload without a hash, or an entity this process cannot
@@ -2201,40 +2606,81 @@ export class BaseEngine extends BaseSingleton {
2201
2606
  return ownHash !== undefined && ownHash !== payloadSchemaHash;
2202
2607
  }
2203
2608
  /**
2204
- * Reloads a config after a peer's cache change, coalescing overlapping requests: an event that
2205
- * arrives while a reload for the same property is running asks for one more reload after it
2206
- * instead of starting its own. Every caller's promise settles only after a reload that began
2609
+ * Reloads a config after a change made elsewhere, coalescing overlapping requests: an event
2610
+ * that arrives while a reload for the same property is running asks for one more reload after
2611
+ * it instead of starting its own. Every caller's promise settles only after a reload that began
2207
2612
  * after its event, so no change is missed, but N events in a burst cost at most two reloads.
2613
+ * Successive reloads of one property are at least {@link ExternalChangeFollowUpDelayMs} apart.
2614
+ * Requests for different properties never share a reload.
2615
+ *
2616
+ * @param bypassCache - Read past the cache. When any request folded into a reload asks for
2617
+ * it, that reload bypasses the cache.
2208
2618
  */
2209
- reloadAfterExternalChange(config) {
2619
+ reloadAfterExternalChange(config, bypassCache = false) {
2210
2620
  const running = this._externalChangeReloads.get(config.PropertyName);
2211
2621
  if (running) {
2212
2622
  running.Again = true;
2623
+ running.BypassCache = running.BypassCache || bypassCache;
2213
2624
  return running.Done;
2214
2625
  }
2215
- const reload = { Again: false, Done: Promise.resolve() };
2626
+ const reload = { Again: false, BypassCache: bypassCache, Done: Promise.resolve() };
2216
2627
  this._externalChangeReloads.set(config.PropertyName, reload);
2217
2628
  reload.Done = this.runExternalChangeReloads(config, reload);
2218
2629
  return reload.Done;
2219
2630
  }
2220
- /** Runs reloads for {@link reloadAfterExternalChange} until no further event asked for one. */
2631
+ /**
2632
+ * Runs reloads for {@link reloadAfterExternalChange} until no further event asked for one.
2633
+ * A reload that throws does not end the loop while another is pending, so an event that
2634
+ * arrived during a failed reload is still served; the error surfaces only when the last
2635
+ * reload is the one that threw.
2636
+ */
2221
2637
  async runExternalChangeReloads(config, reload) {
2222
2638
  try {
2639
+ let lastError = null;
2223
2640
  do {
2641
+ const wait = this.reloadSpacingRemainingMs(config.PropertyName);
2642
+ if (wait > 0) {
2643
+ // Requests that arrive during the wait join the reload that follows it.
2644
+ await new Promise(resolve => setTimeout(resolve, wait));
2645
+ }
2646
+ const bypassCache = reload.BypassCache;
2224
2647
  reload.Again = false;
2225
- // The reload replaces the property, so the derived state has to be rebuilt too —
2226
- // and the reload must NOT emit on its own, or a subscriber wakes between the new
2227
- // rows and the rebuild and reads derived state still pointing at the instances
2228
- // this reload discarded.
2229
- await this.LoadSingleConfig(config, this._contextUser, /*bypassCache*/ false, /*suppressEmit*/ true);
2230
- await this.RebuildDerivedState(this._contextUser);
2231
- this.emitPropertyChange(config.PropertyName);
2648
+ reload.BypassCache = false;
2649
+ lastError = await this.runOneExternalChangeReload(config, bypassCache);
2650
+ this._externalChangeReloadEndedAt.set(config.PropertyName, Date.now());
2651
+ if (lastError && reload.Again) {
2652
+ LogError(lastError);
2653
+ }
2232
2654
  } while (reload.Again);
2655
+ if (lastError) {
2656
+ throw lastError;
2657
+ }
2233
2658
  }
2234
2659
  finally {
2235
2660
  this._externalChangeReloads.delete(config.PropertyName);
2236
2661
  }
2237
2662
  }
2663
+ /** How long the next reload of the property must still wait for {@link ExternalChangeFollowUpDelayMs}; 0 when it may start now. */
2664
+ reloadSpacingRemainingMs(propertyName) {
2665
+ const endedAt = this._externalChangeReloadEndedAt.get(propertyName);
2666
+ return endedAt === undefined ? 0 : Math.max(0, endedAt + BaseEngine.ExternalChangeFollowUpDelayMs - Date.now());
2667
+ }
2668
+ /** One reload for {@link runExternalChangeReloads}. Returns the error it threw, or null. */
2669
+ async runOneExternalChangeReload(config, bypassCache) {
2670
+ try {
2671
+ // The reload replaces the property, so the derived state has to be rebuilt too —
2672
+ // and the reload must NOT emit on its own, or a subscriber wakes between the new
2673
+ // rows and the rebuild and reads derived state still pointing at the instances
2674
+ // this reload discarded.
2675
+ await this.LoadSingleConfig(config, this._contextUser, bypassCache, /*suppressEmit*/ true);
2676
+ await this.RebuildDerivedState(this._contextUser);
2677
+ this.emitPropertyChange(config.PropertyName);
2678
+ return null;
2679
+ }
2680
+ catch (e) {
2681
+ return e instanceof Error ? e : new Error(String(e));
2682
+ }
2683
+ }
2238
2684
  /**
2239
2685
  * True when `payloadRows` are the same row versions this config currently holds: the same
2240
2686
  * number of rows, and the same multiset of (primary key, `__mj_UpdatedAt`) pairs.