@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.
- package/dist/generic/EntityFieldRules.d.ts.map +1 -1
- package/dist/generic/EntityFieldRules.js +1 -0
- package/dist/generic/EntityFieldRules.js.map +1 -1
- package/dist/generic/baseEngine.d.ts +118 -8
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +528 -82
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +63 -4
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +625 -55
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/baseRemotableOperation.d.ts +95 -6
- package/dist/generic/baseRemotableOperation.d.ts.map +1 -1
- package/dist/generic/baseRemotableOperation.js +112 -8
- package/dist/generic/baseRemotableOperation.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts +37 -0
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +222 -40
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +6 -0
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +18 -12
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/externalDataSourceReadRouter.d.ts +10 -0
- package/dist/generic/externalDataSourceReadRouter.d.ts.map +1 -1
- package/dist/generic/externalDataSourceReadRouter.js +12 -0
- package/dist/generic/externalDataSourceReadRouter.js.map +1 -1
- package/dist/generic/fieldSecuritySQLGate.d.ts +11 -2
- package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -1
- package/dist/generic/fieldSecuritySQLGate.js +6 -2
- package/dist/generic/fieldSecuritySQLGate.js.map +1 -1
- package/dist/generic/interfaces.d.ts +50 -1
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/listMembershipFilter.d.ts +5 -0
- package/dist/generic/listMembershipFilter.d.ts.map +1 -0
- package/dist/generic/listMembershipFilter.js +23 -0
- package/dist/generic/listMembershipFilter.js.map +1 -0
- package/dist/generic/localCacheManager.d.ts +48 -5
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +146 -17
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/providerBase.d.ts +155 -2
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +362 -124
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/relatedRecordCollection.d.ts +5 -0
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
- package/dist/generic/relatedRecordCollection.js +11 -2
- package/dist/generic/relatedRecordCollection.js.map +1 -1
- package/dist/generic/remoteOperationDispatch.d.ts.map +1 -1
- package/dist/generic/remoteOperationDispatch.js +14 -2
- package/dist/generic/remoteOperationDispatch.js.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.d.ts +33 -6
- package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.js +81 -27
- package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
- package/dist/generic/transportError.d.ts +41 -0
- package/dist/generic/transportError.d.ts.map +1 -0
- package/dist/generic/transportError.js +167 -0
- package/dist/generic/transportError.js.map +1 -0
- package/dist/generic/util.d.ts +12 -0
- package/dist/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +52 -3
- package/dist/generic/util.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +23 -10
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +5 -1
- package/dist/views/runView.js.map +1 -1
- 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}
|
|
203
|
-
*
|
|
204
|
-
*
|
|
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.
|
|
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 (
|
|
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.
|
|
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
|
|
1557
|
-
//
|
|
1558
|
-
//
|
|
1559
|
-
|
|
1560
|
-
const entityConfigsToLoad =
|
|
1561
|
-
|
|
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}
|
|
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
|
-
|
|
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}
|
|
1640
|
-
// Mark all
|
|
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
|
-
|
|
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
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
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
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
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
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
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
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
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
|
|
2205
|
-
* arrives while a reload for the same property is running asks for one more reload after
|
|
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
|
-
/**
|
|
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
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
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.
|