@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.
- package/dist/generic/InMemoryLocalStorageProvider.d.ts +5 -0
- package/dist/generic/InMemoryLocalStorageProvider.d.ts.map +1 -1
- package/dist/generic/InMemoryLocalStorageProvider.js +5 -0
- package/dist/generic/InMemoryLocalStorageProvider.js.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +27 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts +19 -0
- package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts.map +1 -1
- package/dist/generic/RegisterForStartup.d.ts +44 -0
- package/dist/generic/RegisterForStartup.d.ts.map +1 -1
- package/dist/generic/RegisterForStartup.js +124 -48
- package/dist/generic/RegisterForStartup.js.map +1 -1
- package/dist/generic/baseEngine.d.ts +214 -3
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +460 -34
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEngineSweeper.d.ts +35 -0
- package/dist/generic/baseEngineSweeper.d.ts.map +1 -0
- package/dist/generic/baseEngineSweeper.js +97 -0
- package/dist/generic/baseEngineSweeper.js.map +1 -0
- package/dist/generic/baseEntity.d.ts +91 -7
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +161 -16
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts +54 -11
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +129 -35
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityConfiguration.d.ts +20 -0
- package/dist/generic/entityConfiguration.d.ts.map +1 -1
- package/dist/generic/entityConfiguration.js +34 -0
- package/dist/generic/entityConfiguration.js.map +1 -1
- package/dist/generic/entityEventBatch.d.ts +124 -0
- package/dist/generic/entityEventBatch.d.ts.map +1 -0
- package/dist/generic/entityEventBatch.js +194 -0
- package/dist/generic/entityEventBatch.js.map +1 -0
- package/dist/generic/entityInfo.d.ts +57 -6
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +72 -7
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/entityRecordNameCache.d.ts +48 -0
- package/dist/generic/entityRecordNameCache.d.ts.map +1 -0
- package/dist/generic/entityRecordNameCache.js +92 -0
- package/dist/generic/entityRecordNameCache.js.map +1 -0
- package/dist/generic/interfaces.d.ts +151 -8
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/jsonFieldBinding.d.ts +196 -0
- package/dist/generic/jsonFieldBinding.d.ts.map +1 -0
- package/dist/generic/jsonFieldBinding.js +508 -0
- package/dist/generic/jsonFieldBinding.js.map +1 -0
- package/dist/generic/localCacheManager.d.ts +278 -16
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +737 -113
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/logging.d.ts +15 -0
- package/dist/generic/logging.d.ts.map +1 -1
- package/dist/generic/logging.js +17 -0
- package/dist/generic/logging.js.map +1 -1
- package/dist/generic/metadata.d.ts +1 -1
- package/dist/generic/metadata.d.ts.map +1 -1
- package/dist/generic/metadata.js.map +1 -1
- package/dist/generic/providerBase.d.ts +252 -35
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +503 -189
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/runQuery.d.ts +18 -1
- package/dist/generic/runQuery.d.ts.map +1 -1
- package/dist/generic/runQuery.js.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.d.ts +7 -1
- package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.js +65 -140
- package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
- package/dist/generic/securityInfo.d.ts +5 -0
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +5 -0
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/generic/telemetryManager.d.ts +11 -0
- package/dist/generic/telemetryManager.d.ts.map +1 -1
- package/dist/generic/telemetryManager.js +43 -0
- package/dist/generic/telemetryManager.js.map +1 -1
- package/dist/generic/transactionGroup.d.ts +27 -0
- package/dist/generic/transactionGroup.d.ts.map +1 -1
- package/dist/generic/transactionGroup.js +58 -5
- package/dist/generic/transactionGroup.js.map +1 -1
- package/dist/generic/util.d.ts +6 -0
- package/dist/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +20 -0
- package/dist/generic/util.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +24 -0
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +4 -0
- package/dist/views/runView.js.map +1 -1
- package/package.json +3 -3
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
1873
|
-
|
|
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
|
/**
|