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