@memberjunction/core 6.2.0-edge.6 → 6.2.0-edge.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generic/EntityFieldRules.d.ts.map +1 -1
- package/dist/generic/EntityFieldRules.js +1 -0
- package/dist/generic/EntityFieldRules.js.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +32 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
- package/dist/generic/baseEngine.d.ts +165 -12
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +622 -98
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEngineRegistry.d.ts +10 -2
- package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
- package/dist/generic/baseEngineRegistry.js +33 -5
- package/dist/generic/baseEngineRegistry.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +121 -6
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +1144 -64
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/baseRemotableOperation.d.ts +95 -6
- package/dist/generic/baseRemotableOperation.d.ts.map +1 -1
- package/dist/generic/baseRemotableOperation.js +112 -8
- package/dist/generic/baseRemotableOperation.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts +37 -0
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +262 -46
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +25 -1
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +70 -12
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/externalDataSourceReadRouter.d.ts +10 -0
- package/dist/generic/externalDataSourceReadRouter.d.ts.map +1 -1
- package/dist/generic/externalDataSourceReadRouter.js +12 -0
- package/dist/generic/externalDataSourceReadRouter.js.map +1 -1
- package/dist/generic/fieldSecuritySQLGate.d.ts +11 -2
- package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -1
- package/dist/generic/fieldSecuritySQLGate.js +6 -2
- package/dist/generic/fieldSecuritySQLGate.js.map +1 -1
- package/dist/generic/interfaces.d.ts +50 -1
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/listMembershipFilter.d.ts +5 -0
- package/dist/generic/listMembershipFilter.d.ts.map +1 -0
- package/dist/generic/listMembershipFilter.js +23 -0
- package/dist/generic/listMembershipFilter.js.map +1 -0
- package/dist/generic/localCacheManager.d.ts +69 -5
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +253 -18
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/logging.d.ts +105 -38
- package/dist/generic/logging.d.ts.map +1 -1
- package/dist/generic/logging.js +196 -5
- package/dist/generic/logging.js.map +1 -1
- package/dist/generic/providerBase.d.ts +198 -3
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +721 -223
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/relatedRecordCollection.d.ts +5 -0
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
- package/dist/generic/relatedRecordCollection.js +11 -2
- package/dist/generic/relatedRecordCollection.js.map +1 -1
- package/dist/generic/remoteOperationDispatch.d.ts.map +1 -1
- package/dist/generic/remoteOperationDispatch.js +14 -2
- package/dist/generic/remoteOperationDispatch.js.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.d.ts +33 -6
- package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.js +81 -27
- package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
- package/dist/generic/sharedLoad.d.ts +42 -0
- package/dist/generic/sharedLoad.d.ts.map +1 -0
- package/dist/generic/sharedLoad.js +74 -0
- package/dist/generic/sharedLoad.js.map +1 -0
- package/dist/generic/telemetryManager.d.ts +31 -0
- package/dist/generic/telemetryManager.d.ts.map +1 -1
- package/dist/generic/telemetryManager.js +85 -9
- package/dist/generic/telemetryManager.js.map +1 -1
- package/dist/generic/transactionGroup.d.ts +19 -0
- package/dist/generic/transactionGroup.d.ts.map +1 -1
- package/dist/generic/transactionGroup.js +55 -7
- package/dist/generic/transactionGroup.js.map +1 -1
- package/dist/generic/transportError.d.ts +41 -0
- package/dist/generic/transportError.d.ts.map +1 -0
- package/dist/generic/transportError.js +167 -0
- package/dist/generic/transportError.js.map +1 -0
- package/dist/generic/util.d.ts +13 -1
- package/dist/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +53 -4
- 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 +23 -10
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +5 -1
- package/dist/views/runView.js.map +1 -1
- package/package.json +3 -3
|
@@ -14,7 +14,7 @@ import { BaseEngineRegistry } from "./baseEngineRegistry.js";
|
|
|
14
14
|
import { LocalCacheManager } from "./localCacheManager.js";
|
|
15
15
|
import { ProviderBase } from "./providerBase.js";
|
|
16
16
|
import { DatabaseProviderBase } from "./databaseProviderBase.js";
|
|
17
|
-
import { TransformSimpleObjectToEntityObject } from "./util.js";
|
|
17
|
+
import { TransformSimpleObjectToEntityObject, CanonicalPrimaryKeyValue } from "./util.js";
|
|
18
18
|
import { WellKnownUserSource } from "./wellKnownUserSource.js";
|
|
19
19
|
/**
|
|
20
20
|
* Property configuration for the BaseEngine class to automatically load/set properties on the class.
|
|
@@ -127,6 +127,21 @@ export class BaseEngine extends BaseSingleton {
|
|
|
127
127
|
});
|
|
128
128
|
return this._derivedStateRebuild;
|
|
129
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* The least time, in milliseconds, between the end of one reload from
|
|
132
|
+
* {@link reloadAfterExternalChange} and the start of the next for the same property. A change
|
|
133
|
+
* that arrives after this long since the last reload reloads at once; one that arrives sooner
|
|
134
|
+
* (including while a reload is running) waits out the rest of it, and every change that arrives
|
|
135
|
+
* meanwhile shares that one reload.
|
|
136
|
+
*
|
|
137
|
+
* Without it, a steady stream of changes (a server writing rows of one entity a few times a
|
|
138
|
+
* second) keeps a property reloading back to back. Each full reload costs time proportional to
|
|
139
|
+
* the table, so the process spends most of its time reloading and the cost of the stream grows
|
|
140
|
+
* with the square of its length. With it, a property reloads at most once per this delay plus
|
|
141
|
+
* one reload's duration, and its data is never more than that far behind the last change.
|
|
142
|
+
* 0 restores back-to-back reloads.
|
|
143
|
+
*/
|
|
144
|
+
static { this.ExternalChangeFollowUpDelayMs = 1000; }
|
|
130
145
|
/**
|
|
131
146
|
* Returns an Observable for a specific engine array property. Subscribers receive the
|
|
132
147
|
* current array immediately (BehaviorSubject semantics), then re-receive the same array
|
|
@@ -165,6 +180,11 @@ export class BaseEngine extends BaseSingleton {
|
|
|
165
180
|
super();
|
|
166
181
|
this._loaded = false;
|
|
167
182
|
this._loadingSubject = new BehaviorSubject(false);
|
|
183
|
+
/**
|
|
184
|
+
* The forced reload queued behind the load that is running, shared by every `Load(…, true)`
|
|
185
|
+
* call that arrives before it starts. Null when none is queued. See {@link Load}.
|
|
186
|
+
*/
|
|
187
|
+
this._forcedReloadAfterCurrent = null;
|
|
168
188
|
/**
|
|
169
189
|
* True once {@link ResolveContextUser} has put this engine on the MJ system user. Makes the
|
|
170
190
|
* server-side identity sticky (a later caller cannot displace it) and memoizes the lookup so
|
|
@@ -192,18 +212,40 @@ export class BaseEngine extends BaseSingleton {
|
|
|
192
212
|
* against overlapping full refreshes clobbering each other: when several full-refresh
|
|
193
213
|
* RunViews for the same property are in flight at once (e.g. a burst of saves each
|
|
194
214
|
* landing in its own debounce window because the round-trip exceeds the DebounceTime),
|
|
195
|
-
*
|
|
215
|
+
* an earlier-INITIATED refresh may never commit over a later one. Without this, whichever
|
|
196
216
|
* RunView happens to RESOLVE last wins — which can be an earlier-initiated request that
|
|
197
217
|
* read a staler snapshot, leaving the cache "one operation behind" until a full reload
|
|
198
|
-
* reconciles it. Keyed by config PropertyName. See {@link beginConfigRefresh}
|
|
218
|
+
* reconciles it. Keyed by config PropertyName. See {@link beginConfigRefresh} and
|
|
219
|
+
* {@link mayCommitConfigRefresh}.
|
|
199
220
|
*/
|
|
200
221
|
this._configRefreshGeneration = new Map();
|
|
201
222
|
/**
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
223
|
+
* Per-property generation of the newest full refresh that has committed rows. A failed read
|
|
224
|
+
* commits none, so it never counts. A refresh that is no longer the latest started may still
|
|
225
|
+
* commit while nothing newer has, so a caller awaiting it never gets back rows older than its
|
|
226
|
+
* own read. See {@link mayCommitConfigRefresh}.
|
|
227
|
+
*/
|
|
228
|
+
this._configCommittedGeneration = new Map();
|
|
229
|
+
/**
|
|
230
|
+
* Reloads started by {@link OnExternalCacheChange} and by the no-row fallback of
|
|
231
|
+
* {@link HandleRemoteInvalidateEvent}, keyed by config PropertyName. While one is running,
|
|
232
|
+
* further events for that property set `Again` instead of starting another, so a burst of
|
|
233
|
+
* events costs at most two reloads per property. See {@link reloadAfterExternalChange}.
|
|
205
234
|
*/
|
|
206
235
|
this._externalChangeReloads = new Map();
|
|
236
|
+
/**
|
|
237
|
+
* Per-property count of in-flight full refreshes (`LoadSingleEntityConfig` or full `'set'`
|
|
238
|
+
* cache payload materialization). When positive, incoming `row_delta` events defer to a
|
|
239
|
+
* coalesced full reload rather than racing an in-progress snapshot replacement.
|
|
240
|
+
*/
|
|
241
|
+
this._inFlightFullRefreshCount = new Map();
|
|
242
|
+
/**
|
|
243
|
+
* Per-property promise chain serializing `row_delta` applications so back-to-back row deltas
|
|
244
|
+
* for distinct rows apply in arrival order without dropping earlier mutations.
|
|
245
|
+
*/
|
|
246
|
+
this._rowDeltaQueues = new Map();
|
|
247
|
+
/** When the last reload from {@link reloadAfterExternalChange} ended, per config PropertyName (epoch ms). */
|
|
248
|
+
this._externalChangeReloadEndedAt = new Map();
|
|
207
249
|
this._entityEventDebounceTime = 1500; // Default debounce time in milliseconds (1.5 seconds)
|
|
208
250
|
this._eventRefreshRetryTimers = new Map();
|
|
209
251
|
/**
|
|
@@ -296,6 +338,15 @@ export class BaseEngine extends BaseSingleton {
|
|
|
296
338
|
const entry = this._dataMap.get(propertyName);
|
|
297
339
|
return entry?.permissionDenied === true;
|
|
298
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* True when the named config property has finished loading with valid data (not failed and
|
|
343
|
+
* not skipped due to permission denial), even if other configs on the same engine are still
|
|
344
|
+
* loading.
|
|
345
|
+
*/
|
|
346
|
+
IsPropertyLoaded(propertyName) {
|
|
347
|
+
const entry = this._dataMap.get(propertyName);
|
|
348
|
+
return entry?.loadedSuccessfully === true && !entry.readDenied && !entry.permissionDenied;
|
|
349
|
+
}
|
|
299
350
|
/**
|
|
300
351
|
* List of entity names that were skipped due to permission denial.
|
|
301
352
|
* Empty if not permission-constrained. Useful for logging/diagnostics.
|
|
@@ -405,6 +456,11 @@ export class BaseEngine extends BaseSingleton {
|
|
|
405
456
|
/**
|
|
406
457
|
* This method should be called by sub-classes to load up their specific metadata requirements. For more complex metadata
|
|
407
458
|
* loading or for post-processing of metadata loading done here, overide the AdditionalLoading method to add your logic.
|
|
459
|
+
*
|
|
460
|
+
* When a load is already running, a call without `forceRefresh` waits for it and returns. A call
|
|
461
|
+
* with `forceRefresh` waits for it and then reads again, because the running load may have read
|
|
462
|
+
* before the caller's own writes. Forced calls that arrive while that reload is still queued share
|
|
463
|
+
* it, so a burst of `Config(true)` calls costs at most one extra read.
|
|
408
464
|
* @param configs
|
|
409
465
|
* @param contextUser
|
|
410
466
|
* @returns
|
|
@@ -413,14 +469,14 @@ export class BaseEngine extends BaseSingleton {
|
|
|
413
469
|
if (this.ProviderToUse?.ProviderType === ProviderType.Database && !contextUser)
|
|
414
470
|
throw new Error('For server-side use of all engine classes, you must provide the contextUser parameter');
|
|
415
471
|
if (this._loadingSubject.value) {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
});
|
|
472
|
+
if (!forceRefresh) {
|
|
473
|
+
return this.waitForCurrentLoad();
|
|
474
|
+
}
|
|
475
|
+
this._forcedReloadAfterCurrent ??= this.waitForCurrentLoad().then(() => {
|
|
476
|
+
this._forcedReloadAfterCurrent = null;
|
|
477
|
+
return this.Load(configs, provider, true, contextUser);
|
|
423
478
|
});
|
|
479
|
+
return this._forcedReloadAfterCurrent;
|
|
424
480
|
}
|
|
425
481
|
if (!this._loaded || forceRefresh) {
|
|
426
482
|
// Reset permission-constrained state on fresh load or force refresh
|
|
@@ -478,6 +534,17 @@ export class BaseEngine extends BaseSingleton {
|
|
|
478
534
|
}
|
|
479
535
|
}
|
|
480
536
|
}
|
|
537
|
+
/** Resolves when the load that is running finishes. */
|
|
538
|
+
waitForCurrentLoad() {
|
|
539
|
+
return new Promise((resolve) => {
|
|
540
|
+
const subscription = this._loadingSubject.subscribe((loading) => {
|
|
541
|
+
if (!loading) {
|
|
542
|
+
subscription.unsubscribe();
|
|
543
|
+
resolve();
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
}
|
|
481
548
|
/**
|
|
482
549
|
* Engine classes that have already warned that the system user could not be resolved, so a
|
|
483
550
|
* misconfigured deployment says so once per class instead of on every load.
|
|
@@ -769,9 +836,7 @@ export class BaseEngine extends BaseSingleton {
|
|
|
769
836
|
if (!entityName) {
|
|
770
837
|
return true; // No entity name, nothing to do
|
|
771
838
|
}
|
|
772
|
-
const matchingConfigs = this.
|
|
773
|
-
return config.AutoRefresh && config.EntityName && config.EntityName.trim().toLowerCase() === entityName;
|
|
774
|
-
});
|
|
839
|
+
const matchingConfigs = this.AutoRefreshConfigsForEntity(entityName);
|
|
775
840
|
if (matchingConfigs.length === 0) {
|
|
776
841
|
return true;
|
|
777
842
|
}
|
|
@@ -794,22 +859,9 @@ export class BaseEngine extends BaseSingleton {
|
|
|
794
859
|
}
|
|
795
860
|
// Fall through to server fetch if direct delete failed
|
|
796
861
|
}
|
|
797
|
-
// Fallback: re-fetch from server (
|
|
798
|
-
// for the same reason as the local-event path: this fetch runs BECAUSE a
|
|
799
|
-
// (cross-server) write signaled the cache stale, so reading back through it would
|
|
800
|
-
// re-sync the pre-write snapshot.
|
|
801
|
-
let refreshCount = 0;
|
|
862
|
+
// Fallback: re-fetch from the server (no row, or it could not be applied).
|
|
802
863
|
for (const config of matchingConfigs) {
|
|
803
|
-
await this.
|
|
804
|
-
if (!this.configLoadedSuccessfully(config.PropertyName)) {
|
|
805
|
-
// Same one-shot hazard as the debounced path: the invalidation event is
|
|
806
|
-
// consumed, so a transient failure here needs a bounded retry too.
|
|
807
|
-
this.scheduleEventRefreshRetry(config, 1);
|
|
808
|
-
}
|
|
809
|
-
refreshCount++;
|
|
810
|
-
}
|
|
811
|
-
if (refreshCount > 0) {
|
|
812
|
-
await this.AdditionalLoading(this._contextUser);
|
|
864
|
+
await this.reloadAfterRemoteInvalidate(config);
|
|
813
865
|
}
|
|
814
866
|
return true;
|
|
815
867
|
}
|
|
@@ -818,6 +870,38 @@ export class BaseEngine extends BaseSingleton {
|
|
|
818
870
|
return false;
|
|
819
871
|
}
|
|
820
872
|
}
|
|
873
|
+
/**
|
|
874
|
+
* Reloads one config because a remote-invalidate event said it changed but carried no row to
|
|
875
|
+
* apply. A burst of such events shares reloads (see {@link reloadAfterExternalChange}), so a
|
|
876
|
+
* server that saves many rows of one entity costs this engine at most two reloads per burst
|
|
877
|
+
* instead of one per row, and the reload that runs last began after the last event arrived.
|
|
878
|
+
*
|
|
879
|
+
* The reload reads past the cache: it runs because a write elsewhere made the cached copy
|
|
880
|
+
* stale, and reading back through the cache would restore the pre-write snapshot. A transient
|
|
881
|
+
* failure gets the bounded retry, since the event that asked for this reload is consumed.
|
|
882
|
+
*/
|
|
883
|
+
async reloadAfterRemoteInvalidate(config) {
|
|
884
|
+
let succeeded;
|
|
885
|
+
try {
|
|
886
|
+
await this.reloadAfterExternalChange(config, /*bypassCache*/ true);
|
|
887
|
+
succeeded = this.configLoadedSuccessfully(config.PropertyName);
|
|
888
|
+
}
|
|
889
|
+
catch (e) {
|
|
890
|
+
LogError(e);
|
|
891
|
+
succeeded = false;
|
|
892
|
+
}
|
|
893
|
+
if (!succeeded) {
|
|
894
|
+
this.scheduleEventRefreshRetry(config, 1);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* The entity configs that refresh automatically when a record of `entityName` changes.
|
|
899
|
+
* Matching ignores case and surrounding whitespace.
|
|
900
|
+
*/
|
|
901
|
+
AutoRefreshConfigsForEntity(entityName) {
|
|
902
|
+
const name = entityName.trim().toLowerCase();
|
|
903
|
+
return this._metadataConfigs.filter((config) => config.AutoRefresh && config.EntityName && config.EntityName.trim().toLowerCase() === name);
|
|
904
|
+
}
|
|
821
905
|
/**
|
|
822
906
|
* Applies record data from a remote-invalidate event directly to the engine's in-memory arrays.
|
|
823
907
|
* Creates a BaseEntity instance, loads the JSON data into it, then updates the matching
|
|
@@ -1427,7 +1511,12 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1427
1511
|
if (!fresh) {
|
|
1428
1512
|
return null;
|
|
1429
1513
|
}
|
|
1430
|
-
|
|
1514
|
+
if (source.EntityInfo?.IsParentType) {
|
|
1515
|
+
await BaseEntity.CloneEntityWithISAChain(source, fresh, provider, this._contextUser);
|
|
1516
|
+
}
|
|
1517
|
+
else {
|
|
1518
|
+
await fresh.LoadFromData(source.GetAll());
|
|
1519
|
+
}
|
|
1431
1520
|
return fresh;
|
|
1432
1521
|
}
|
|
1433
1522
|
catch (e) {
|
|
@@ -1549,16 +1638,15 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1549
1638
|
*/
|
|
1550
1639
|
async LoadConfigs(configs, contextUser, bypassCache = false) {
|
|
1551
1640
|
this._metadataConfigs = configs.map(c => this.UpgradeObjectToConfig(c));
|
|
1552
|
-
// now, break up the configs into two chunks, datasets and views of entities so we can load all the views in a single network call via RunViews()
|
|
1553
1641
|
const entityConfigs = this._metadataConfigs.filter(c => c.Type === 'entity');
|
|
1554
|
-
const datasetConfigs = this._metadataConfigs.filter(c => c.Type === 'dataset');
|
|
1555
1642
|
this.WarnAboutFieldSecuredEntities(entityConfigs);
|
|
1556
|
-
// All-or-nothing permission check
|
|
1557
|
-
//
|
|
1558
|
-
//
|
|
1559
|
-
|
|
1560
|
-
const entityConfigsToLoad =
|
|
1561
|
-
|
|
1643
|
+
// All-or-nothing permission check across all configs (both entity configs and
|
|
1644
|
+
// dataset configs that declare their constituent Entities). If the user lacks
|
|
1645
|
+
// read access on ANY configured entity, skip ALL configs for this engine.
|
|
1646
|
+
const configsToLoad = this.CheckPermissionsOrSkipAll(this._metadataConfigs, contextUser);
|
|
1647
|
+
const entityConfigsToLoad = configsToLoad.filter(c => c.Type === 'entity');
|
|
1648
|
+
const datasetConfigsToLoad = configsToLoad.filter(c => c.Type === 'dataset');
|
|
1649
|
+
await Promise.all([...datasetConfigsToLoad.map(c => this.LoadSingleDatasetConfig(c, contextUser, bypassCache)),
|
|
1562
1650
|
this.LoadMultipleEntityConfigs(entityConfigsToLoad, contextUser, bypassCache)]);
|
|
1563
1651
|
// Register cross-server cache change callbacks for entity configs
|
|
1564
1652
|
this.RegisterCacheChangeCallbacks(entityConfigs);
|
|
@@ -1613,18 +1701,20 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1613
1701
|
const missingEntities = this.findMissingEntities(configs);
|
|
1614
1702
|
if (missingEntities.length > 0) {
|
|
1615
1703
|
LogStatusEx({
|
|
1616
|
-
message: `${this.constructor.name}: Skipping ${configs.length}
|
|
1704
|
+
message: `${this.constructor.name}: Skipping ${configs.length} config(s) — entity metadata not installed for: ${missingEntities.join(', ')}`,
|
|
1617
1705
|
verboseOnly: true,
|
|
1618
1706
|
category: 'Cache',
|
|
1619
1707
|
});
|
|
1708
|
+
// Loaded (so EnsureLoaded doesn't retry-loop) but readDenied: the installed entities'
|
|
1709
|
+
// configs were skipped too, and their [] is not their table. IsPropertyLoaded (and so
|
|
1710
|
+
// BaseEngineRegistry.FindCachedEntity) must not serve it as one.
|
|
1620
1711
|
for (const config of configs) {
|
|
1621
|
-
|
|
1622
|
-
this[config.PropertyName] = [];
|
|
1623
|
-
}
|
|
1712
|
+
this.initializeSkippedConfigProperty(config);
|
|
1624
1713
|
this._dataMap.set(config.PropertyName, {
|
|
1625
|
-
entityName: config.EntityName,
|
|
1714
|
+
...(config.Type === 'dataset' ? { datasetName: config.DatasetName } : { entityName: config.EntityName }),
|
|
1626
1715
|
data: [],
|
|
1627
1716
|
loadedSuccessfully: true,
|
|
1717
|
+
readDenied: true,
|
|
1628
1718
|
});
|
|
1629
1719
|
}
|
|
1630
1720
|
return [];
|
|
@@ -1636,17 +1726,15 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1636
1726
|
return configs; // Can't check without a user — proceed with normal loading
|
|
1637
1727
|
const deniedEntities = this.findUnreadableEntities(configs, user);
|
|
1638
1728
|
if (deniedEntities.length > 0) {
|
|
1639
|
-
LogWarning(`${this.constructor.name}: Skipping ${configs.length}
|
|
1640
|
-
// Mark all
|
|
1729
|
+
LogWarning(`${this.constructor.name}: Skipping ${configs.length} config(s) — user ${user.Email} lacks read permission on: ${deniedEntities.join(', ')}`, 'Cache');
|
|
1730
|
+
// Mark all configs as successfully loaded with empty data.
|
|
1641
1731
|
// This is not a failure — it's expected behavior for limited-permission users.
|
|
1642
1732
|
// Marking as successful allows the engine to set _loaded = true and avoid
|
|
1643
1733
|
// endless retry loops from EnsureLoaded().
|
|
1644
1734
|
for (const config of configs) {
|
|
1645
|
-
|
|
1646
|
-
this[config.PropertyName] = [];
|
|
1647
|
-
}
|
|
1735
|
+
this.initializeSkippedConfigProperty(config);
|
|
1648
1736
|
this._dataMap.set(config.PropertyName, {
|
|
1649
|
-
entityName: config.EntityName,
|
|
1737
|
+
...(config.Type === 'dataset' ? { datasetName: config.DatasetName } : { entityName: config.EntityName }),
|
|
1650
1738
|
data: [],
|
|
1651
1739
|
loadedSuccessfully: true,
|
|
1652
1740
|
permissionDenied: true,
|
|
@@ -1654,10 +1742,42 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1654
1742
|
}
|
|
1655
1743
|
this._isPermissionConstrained = true;
|
|
1656
1744
|
this._deniedEntityNames = deniedEntities;
|
|
1657
|
-
return []; // Return empty — don't attempt any RunView calls
|
|
1745
|
+
return []; // Return empty — don't attempt any RunView or dataset calls
|
|
1658
1746
|
}
|
|
1659
1747
|
return configs;
|
|
1660
1748
|
}
|
|
1749
|
+
/**
|
|
1750
|
+
* Initializes the target property on `this` when a config is skipped before loading.
|
|
1751
|
+
* Entity configs receive `[]`; `single_property` dataset configs receive `{}` only when
|
|
1752
|
+
* the subclass has not already initialized the property to a default container.
|
|
1753
|
+
*/
|
|
1754
|
+
initializeSkippedConfigProperty(config) {
|
|
1755
|
+
if (config.AddToObject === false) {
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
if (config.Type === 'dataset') {
|
|
1759
|
+
if (config.DatasetResultHandling === 'single_property' &&
|
|
1760
|
+
this[config.PropertyName] == null) {
|
|
1761
|
+
this[config.PropertyName] = {};
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
else {
|
|
1765
|
+
this[config.PropertyName] = [];
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* Returns the entity name(s) governed by `config`: `Entities` (or `EntityName`) for
|
|
1770
|
+
* dataset configs, and `EntityName` for entity configs.
|
|
1771
|
+
*/
|
|
1772
|
+
configEntityNames(config) {
|
|
1773
|
+
if (config.Type === 'dataset') {
|
|
1774
|
+
if (config.Entities && config.Entities.length > 0) {
|
|
1775
|
+
return config.Entities;
|
|
1776
|
+
}
|
|
1777
|
+
return config.EntityName ? [config.EntityName] : [];
|
|
1778
|
+
}
|
|
1779
|
+
return config.EntityName ? [config.EntityName] : [];
|
|
1780
|
+
}
|
|
1661
1781
|
/**
|
|
1662
1782
|
* Returns entity names among `configs` that do not exist in the loaded metadata provider.
|
|
1663
1783
|
* Only checks when the metadata provider actually has entities loaded (`md.Entities.length > 0`)
|
|
@@ -1670,10 +1790,10 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1670
1790
|
}
|
|
1671
1791
|
const missing = [];
|
|
1672
1792
|
for (const config of configs) {
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1793
|
+
for (const entityName of this.configEntityNames(config)) {
|
|
1794
|
+
if (!md.EntityByName(entityName) && !missing.includes(entityName)) {
|
|
1795
|
+
missing.push(entityName);
|
|
1796
|
+
}
|
|
1677
1797
|
}
|
|
1678
1798
|
}
|
|
1679
1799
|
return missing;
|
|
@@ -1683,14 +1803,14 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1683
1803
|
const md = this.ProviderToUse;
|
|
1684
1804
|
const denied = [];
|
|
1685
1805
|
for (const config of configs) {
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1806
|
+
for (const entityName of this.configEntityNames(config)) {
|
|
1807
|
+
const entityInfo = md.EntityByName(entityName);
|
|
1808
|
+
if (!entityInfo)
|
|
1809
|
+
continue; // Entity not in metadata — let RunView/dataset handle it
|
|
1810
|
+
const perms = entityInfo.GetUserPermisions(user);
|
|
1811
|
+
if ((!perms || !perms.CanRead) && !denied.includes(entityName)) {
|
|
1812
|
+
denied.push(entityName);
|
|
1813
|
+
}
|
|
1694
1814
|
}
|
|
1695
1815
|
}
|
|
1696
1816
|
return denied;
|
|
@@ -1726,6 +1846,29 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1726
1846
|
isLatestConfigRefresh(propertyName, generation) {
|
|
1727
1847
|
return this._configRefreshGeneration.get(propertyName) === generation;
|
|
1728
1848
|
}
|
|
1849
|
+
/**
|
|
1850
|
+
* True when a refresh holding `generation` may apply its result: it is the latest started, or
|
|
1851
|
+
* its read succeeded and nothing newer has committed rows yet. A superseded refresh that
|
|
1852
|
+
* commits first is overwritten when the newer one lands, so the property never goes
|
|
1853
|
+
* backwards, and the superseded refresh's caller (a forced `Load`, `RefreshItem`) still
|
|
1854
|
+
* returns with rows at least as new as its read. Dropping them instead would leave the
|
|
1855
|
+
* property on its previous rows until the newer refresh landed, which can be after that
|
|
1856
|
+
* caller has returned, or never when that refresh fails. A superseded refresh whose read
|
|
1857
|
+
* failed applies nothing: the newer one owns the property's load state.
|
|
1858
|
+
*/
|
|
1859
|
+
mayCommitConfigRefresh(propertyName, generation, succeeded) {
|
|
1860
|
+
return this.isLatestConfigRefresh(propertyName, generation)
|
|
1861
|
+
|| (succeeded && (this._configCommittedGeneration.get(propertyName) ?? 0) < generation);
|
|
1862
|
+
}
|
|
1863
|
+
/**
|
|
1864
|
+
* Records that the refresh holding `generation` committed `propertyName`'s rows. Call it only
|
|
1865
|
+
* for a successful read: a failed one leaves the held rows as they were.
|
|
1866
|
+
*/
|
|
1867
|
+
markConfigRefreshCommitted(propertyName, generation) {
|
|
1868
|
+
if ((this._configCommittedGeneration.get(propertyName) ?? 0) < generation) {
|
|
1869
|
+
this._configCommittedGeneration.set(propertyName, generation);
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1729
1872
|
/**
|
|
1730
1873
|
* Resolves {@link BaseEnginePropertyConfig.IncludeBinaryFields} for this engine's provider:
|
|
1731
1874
|
* `'DatabaseProviderOnly'` becomes true on a database provider and false on any other.
|
|
@@ -1777,17 +1920,28 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1777
1920
|
// UserInfoEngine's per-user '_UserApplications') apply out of initiation-order and the
|
|
1778
1921
|
// cache ends up "one operation behind". See _configRefreshGeneration.
|
|
1779
1922
|
const generation = this.beginConfigRefresh(config.PropertyName);
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1923
|
+
this.incrementInFlightFullRefresh(config.PropertyName);
|
|
1924
|
+
let result;
|
|
1925
|
+
try {
|
|
1926
|
+
const p = this.RunViewProviderToUse;
|
|
1927
|
+
const rv = new RunView(p);
|
|
1928
|
+
result = await rv.RunView(this.BuildRunViewParamsForConfig(config, bypassCache), contextUser);
|
|
1929
|
+
}
|
|
1930
|
+
finally {
|
|
1931
|
+
this.decrementInFlightFullRefresh(config.PropertyName);
|
|
1932
|
+
}
|
|
1933
|
+
// A newer full refresh superseded us while we awaited. When it has already committed rows,
|
|
1934
|
+
// or our read failed, drop this result rather than clobber the newer one: the newer
|
|
1935
|
+
// refresh owns the assignment, the observer notification, and (on failure) the retry, so
|
|
1936
|
+
// bailing here leaves no observer un-notified. When our read succeeded and the newer one
|
|
1937
|
+
// hasn't committed yet, ours commits and the newer one overwrites it when it lands (see
|
|
1938
|
+
// mayCommitConfigRefresh).
|
|
1939
|
+
if (!this.mayCommitConfigRefresh(config.PropertyName, generation, result.Success)) {
|
|
1789
1940
|
return;
|
|
1790
1941
|
}
|
|
1942
|
+
if (result.Success) {
|
|
1943
|
+
this.markConfigRefreshCommitted(config.PropertyName, generation);
|
|
1944
|
+
}
|
|
1791
1945
|
this.HandleSingleViewResult(config, result, contextUser);
|
|
1792
1946
|
if (!suppressEmit) {
|
|
1793
1947
|
// A caller that rebuilds derived state afterwards emits itself, once, AFTER the
|
|
@@ -1884,6 +2038,35 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1884
2038
|
this._dataMap.set(config.PropertyName, { entityName: config.EntityName, data: [], loadedSuccessfully: true, readDenied: true });
|
|
1885
2039
|
this.NotifyDataChange(config, []);
|
|
1886
2040
|
}
|
|
2041
|
+
/**
|
|
2042
|
+
* Returns true when a failed dataset load was caused by the current user lacking Read
|
|
2043
|
+
* permission on one of the dataset's constituent entities — either per local metadata
|
|
2044
|
+
* (`config.Entities` / `config.EntityName`) or per the server's dataset status message
|
|
2045
|
+
* (`does not have read permissions on ...`).
|
|
2046
|
+
*/
|
|
2047
|
+
IsDatasetReadDenied(config, result, contextUser) {
|
|
2048
|
+
for (const entityName of this.configEntityNames(config)) {
|
|
2049
|
+
if (!this.ContextUserCanReadConfigEntity(entityName, contextUser)) {
|
|
2050
|
+
return true;
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
return typeof result.Status === 'string' && /does not have read permissions? on/i.test(result.Status);
|
|
2054
|
+
}
|
|
2055
|
+
/**
|
|
2056
|
+
* Records a dataset config as successfully loaded in an empty state after a permanent
|
|
2057
|
+
* permission denial, so `BaseEngine.Load` marks the engine loaded and does not loop on
|
|
2058
|
+
* `EnsureLoaded()` / `Config()`.
|
|
2059
|
+
*/
|
|
2060
|
+
MarkDatasetConfigEmptyLoaded(config) {
|
|
2061
|
+
this.initializeSkippedConfigProperty(config);
|
|
2062
|
+
this._dataMap.set(config.PropertyName, {
|
|
2063
|
+
datasetName: config.DatasetName,
|
|
2064
|
+
data: [],
|
|
2065
|
+
loadedSuccessfully: true,
|
|
2066
|
+
readDenied: true,
|
|
2067
|
+
});
|
|
2068
|
+
this.NotifyDataChange(config, []);
|
|
2069
|
+
}
|
|
1887
2070
|
/**
|
|
1888
2071
|
* Handles the process of loading multiple entity configs in a single network call via RunViews()
|
|
1889
2072
|
* @param configs
|
|
@@ -1892,13 +2075,36 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1892
2075
|
*/
|
|
1893
2076
|
async LoadMultipleEntityConfigs(configs, contextUser, bypassCache = false) {
|
|
1894
2077
|
if (configs && configs.length > 0) {
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
2078
|
+
// Same protocol as LoadSingleEntityConfig, per property: claim a refresh generation and
|
|
2079
|
+
// mark a full refresh in flight BEFORE the awaited RunViews. Without it a `row_delta`
|
|
2080
|
+
// that arrives mid-flight is applied to the held array and then overwritten by this
|
|
2081
|
+
// (pre-delta) snapshot when the batch lands; with it the delta path defers to a
|
|
2082
|
+
// coalesced reload instead, and a newer per-property refresh supersedes our slot.
|
|
2083
|
+
const generations = configs.map(c => this.beginConfigRefresh(c.PropertyName));
|
|
2084
|
+
for (const c of configs) {
|
|
2085
|
+
this.incrementInFlightFullRefresh(c.PropertyName);
|
|
2086
|
+
}
|
|
2087
|
+
let results;
|
|
2088
|
+
try {
|
|
2089
|
+
const p = this.RunViewProviderToUse;
|
|
2090
|
+
const rv = new RunView(p);
|
|
2091
|
+
const viewConfigs = configs.map(c => this.BuildRunViewParamsForConfig(c, bypassCache));
|
|
2092
|
+
results = await rv.RunViews(viewConfigs, contextUser);
|
|
2093
|
+
}
|
|
2094
|
+
finally {
|
|
2095
|
+
for (const c of configs) {
|
|
2096
|
+
this.decrementInFlightFullRefresh(c.PropertyName);
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
1899
2099
|
// Process results and record entity loads for redundancy detection
|
|
1900
2100
|
const entityNames = [];
|
|
1901
2101
|
for (let i = 0; i < configs.length; i++) {
|
|
2102
|
+
if (!this.mayCommitConfigRefresh(configs[i].PropertyName, generations[i], results[i]?.Success === true)) {
|
|
2103
|
+
continue; // a newer refresh of this property owns it
|
|
2104
|
+
}
|
|
2105
|
+
if (results[i]?.Success === true) {
|
|
2106
|
+
this.markConfigRefreshCommitted(configs[i].PropertyName, generations[i]);
|
|
2107
|
+
}
|
|
1902
2108
|
this.HandleSingleViewResult(configs[i], results[i], contextUser);
|
|
1903
2109
|
this.emitPropertyChange(configs[i].PropertyName);
|
|
1904
2110
|
if (configs[i].EntityName) {
|
|
@@ -1981,7 +2187,9 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1981
2187
|
let result;
|
|
1982
2188
|
if (bypassCache) {
|
|
1983
2189
|
result = await p.GetDatasetByName(config.DatasetName, config.DatasetItemFilters, contextUser, undefined, true);
|
|
1984
|
-
|
|
2190
|
+
if (result?.Success) {
|
|
2191
|
+
await p.CacheDataset(config.DatasetName, config.DatasetItemFilters, result);
|
|
2192
|
+
}
|
|
1985
2193
|
}
|
|
1986
2194
|
else {
|
|
1987
2195
|
result = await p.GetAndCacheDatasetByName(config.DatasetName, config.DatasetItemFilters, contextUser);
|
|
@@ -1998,6 +2206,11 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1998
2206
|
return;
|
|
1999
2207
|
}
|
|
2000
2208
|
if (!result.Success) {
|
|
2209
|
+
if (this.IsDatasetReadDenied(config, result, contextUser)) {
|
|
2210
|
+
this.MarkDatasetConfigEmptyLoaded(config);
|
|
2211
|
+
LogWarning(`BaseEngine: Dataset ${config.DatasetName} not readable by current role — loaded empty (restricted-role degradation).`, 'Cache');
|
|
2212
|
+
return;
|
|
2213
|
+
}
|
|
2001
2214
|
const errorMsg = result.Status || `Dataset "${config.DatasetName}" load returned Success=false`;
|
|
2002
2215
|
LogError(`BaseEngine: Failed to load dataset ${config.DatasetName} into ${config.PropertyName}: ${errorMsg}`);
|
|
2003
2216
|
this._dataMap.set(config.PropertyName, {
|
|
@@ -2139,18 +2352,26 @@ export class BaseEngine extends BaseSingleton {
|
|
|
2139
2352
|
// overlapping cache events (or an event racing a full reload) can resolve out
|
|
2140
2353
|
// of order and the stale result would be the one that assigns last.
|
|
2141
2354
|
const generation = this.beginConfigRefresh(config.PropertyName);
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2355
|
+
this.incrementInFlightFullRefresh(config.PropertyName);
|
|
2356
|
+
let rows = null;
|
|
2357
|
+
try {
|
|
2358
|
+
// A burst of payloads for one slot usually arrives together. Let the rest of the
|
|
2359
|
+
// burst claim its generations first, so only the newest payload is materialized
|
|
2360
|
+
// instead of every one of them (receive side).
|
|
2361
|
+
await yieldToEventLoop();
|
|
2362
|
+
if (!this.isLatestConfigRefresh(config.PropertyName, generation)) {
|
|
2363
|
+
return;
|
|
2364
|
+
}
|
|
2365
|
+
rows = await this.materializeCacheEventRows(config, parsed.results);
|
|
2366
|
+
}
|
|
2367
|
+
finally {
|
|
2368
|
+
this.decrementInFlightFullRefresh(config.PropertyName);
|
|
2148
2369
|
}
|
|
2149
|
-
const rows = await this.materializeCacheEventRows(config, parsed.results);
|
|
2150
2370
|
if (!this.isLatestConfigRefresh(config.PropertyName, generation)) {
|
|
2151
2371
|
return; // superseded while materializing — the newer refresh owns the property
|
|
2152
2372
|
}
|
|
2153
2373
|
if (rows) {
|
|
2374
|
+
this.markConfigRefreshCommitted(config.PropertyName, generation);
|
|
2154
2375
|
this.HandleSingleViewResult(config, {
|
|
2155
2376
|
Success: true,
|
|
2156
2377
|
Results: rows,
|
|
@@ -2186,8 +2407,270 @@ export class BaseEngine extends BaseSingleton {
|
|
|
2186
2407
|
LogWarning(`BaseEngine.OnExternalCacheChange: payload for '${config.PropertyName}' could not be applied (${e instanceof Error ? e.message : String(e)}) — falling back to full reload`, 'Cache');
|
|
2187
2408
|
}
|
|
2188
2409
|
}
|
|
2410
|
+
else if (event.Data && event.Action === 'row_delta') {
|
|
2411
|
+
const outcome = await this.applyExternalRowDelta(config, event.Data);
|
|
2412
|
+
if (outcome === 'applied' || outcome === 'superseded') {
|
|
2413
|
+
return;
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2189
2416
|
await this.reloadAfterExternalChange(config);
|
|
2190
2417
|
}
|
|
2418
|
+
incrementInFlightFullRefresh(propertyName) {
|
|
2419
|
+
this._inFlightFullRefreshCount.set(propertyName, (this._inFlightFullRefreshCount.get(propertyName) ?? 0) + 1);
|
|
2420
|
+
}
|
|
2421
|
+
decrementInFlightFullRefresh(propertyName) {
|
|
2422
|
+
const next = (this._inFlightFullRefreshCount.get(propertyName) ?? 1) - 1;
|
|
2423
|
+
if (next <= 0) {
|
|
2424
|
+
this._inFlightFullRefreshCount.delete(propertyName);
|
|
2425
|
+
}
|
|
2426
|
+
else {
|
|
2427
|
+
this._inFlightFullRefreshCount.set(propertyName, next);
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
/**
|
|
2431
|
+
* Applies a compact `row_delta` pub/sub event in $O(\Delta)$ without re-materializing
|
|
2432
|
+
* untouched sibling rows. Falls back to `'reload'` whenever schema hash, ordering, or
|
|
2433
|
+
* row-count invariants indicate the engine cannot prove local delta equivalence.
|
|
2434
|
+
*/
|
|
2435
|
+
async applyExternalRowDelta(config, rawDeltaJson) {
|
|
2436
|
+
let delta;
|
|
2437
|
+
try {
|
|
2438
|
+
delta = JSON.parse(rawDeltaJson);
|
|
2439
|
+
}
|
|
2440
|
+
catch (e) {
|
|
2441
|
+
LogWarning(`BaseEngine.OnExternalCacheChange: row_delta payload for '${config.PropertyName}' could not be parsed (${e instanceof Error ? e.message : String(e)}) — falling back to reload`, 'Cache');
|
|
2442
|
+
return 'reload';
|
|
2443
|
+
}
|
|
2444
|
+
if (!delta ||
|
|
2445
|
+
!Array.isArray(delta.upsertedRows) ||
|
|
2446
|
+
!Array.isArray(delta.deletedPrimaryKeys) ||
|
|
2447
|
+
!Array.isArray(delta.primaryKeyFields) ||
|
|
2448
|
+
delta.primaryKeyFields.length === 0 ||
|
|
2449
|
+
typeof delta.rowCount !== 'number') {
|
|
2450
|
+
return 'reload';
|
|
2451
|
+
}
|
|
2452
|
+
if (this.payloadSchemaDiffers(config, delta.schemaHash)) {
|
|
2453
|
+
LogStatusEx({ message: `BaseEngine.OnExternalCacheChange: row_delta for '${config.PropertyName}' was written under a different schema — reloading instead`, verboseOnly: true });
|
|
2454
|
+
return 'reload';
|
|
2455
|
+
}
|
|
2456
|
+
// Ordered configs rely on database collation ordering; reload from the warm Redis slot (0 SQL).
|
|
2457
|
+
if (config.OrderBy && delta.upsertedRows.length > 0) {
|
|
2458
|
+
return 'reload';
|
|
2459
|
+
}
|
|
2460
|
+
let stepOutcome = 'reload';
|
|
2461
|
+
const priorQueue = this._rowDeltaQueues.get(config.PropertyName) ?? Promise.resolve();
|
|
2462
|
+
const currentStep = priorQueue.then(async () => {
|
|
2463
|
+
try {
|
|
2464
|
+
stepOutcome = await this.applyExternalRowDeltaSerialized(config, delta);
|
|
2465
|
+
}
|
|
2466
|
+
catch (e) {
|
|
2467
|
+
LogWarning(`BaseEngine.OnExternalCacheChange: row_delta application for '${config.PropertyName}' failed (${e instanceof Error ? e.message : String(e)}) — falling back to reload`, 'Cache');
|
|
2468
|
+
stepOutcome = 'reload';
|
|
2469
|
+
}
|
|
2470
|
+
});
|
|
2471
|
+
const settled = currentStep.catch(() => undefined);
|
|
2472
|
+
this._rowDeltaQueues.set(config.PropertyName, settled);
|
|
2473
|
+
await currentStep;
|
|
2474
|
+
if (this._rowDeltaQueues.get(config.PropertyName) === settled) {
|
|
2475
|
+
this._rowDeltaQueues.delete(config.PropertyName);
|
|
2476
|
+
}
|
|
2477
|
+
return stepOutcome;
|
|
2478
|
+
}
|
|
2479
|
+
async applyExternalRowDeltaSerialized(config, delta) {
|
|
2480
|
+
if ((this._inFlightFullRefreshCount.get(config.PropertyName) ?? 0) > 0) {
|
|
2481
|
+
return 'reload';
|
|
2482
|
+
}
|
|
2483
|
+
const heldRows = this.getHeldRowsForDelta(config);
|
|
2484
|
+
if (!heldRows) {
|
|
2485
|
+
return 'reload';
|
|
2486
|
+
}
|
|
2487
|
+
const entityInfo = config.EntityName ? this.ProviderToUse?.EntityByName?.(config.EntityName) : undefined;
|
|
2488
|
+
const pkFields = delta.primaryKeyFields;
|
|
2489
|
+
const isUuidPk = pkFields.map(fieldName => Boolean(entityInfo?.FieldByName(fieldName)?.IsUniqueIdentifier));
|
|
2490
|
+
const heldIndexByKey = this.indexHeldRowsForDelta(heldRows, pkFields, isUuidPk);
|
|
2491
|
+
if (!heldIndexByKey) {
|
|
2492
|
+
return 'reload';
|
|
2493
|
+
}
|
|
2494
|
+
const keyPlan = this.planRowDeltaKeys(delta, heldRows.length, heldIndexByKey, pkFields, isUuidPk);
|
|
2495
|
+
if (!keyPlan) {
|
|
2496
|
+
return 'reload';
|
|
2497
|
+
}
|
|
2498
|
+
// Echo / duplicate delta suppression: when no rows are added or removed and every
|
|
2499
|
+
// upserted row's __mj_UpdatedAt matches the held row, skip materialization & emit churn.
|
|
2500
|
+
if (keyPlan.matchedDeleteKeys.size === 0 &&
|
|
2501
|
+
keyPlan.netNewInserts === 0 &&
|
|
2502
|
+
this.upsertsMatchHeldTimestamps(heldRows, heldIndexByKey, delta.upsertedRows, keyPlan.upsertKeys, entityInfo)) {
|
|
2503
|
+
LogStatusEx({ message: `BaseEngine.OnExternalCacheChange: '${config.PropertyName}' already holds row_delta versions — skipped`, verboseOnly: true });
|
|
2504
|
+
return 'applied';
|
|
2505
|
+
}
|
|
2506
|
+
const generationBefore = this._configRefreshGeneration.get(config.PropertyName) ?? 0;
|
|
2507
|
+
const materializedUpserts = delta.upsertedRows.length > 0
|
|
2508
|
+
? await this.materializeCacheEventRows(config, delta.upsertedRows)
|
|
2509
|
+
: [];
|
|
2510
|
+
if (materializedUpserts === null) {
|
|
2511
|
+
return 'reload';
|
|
2512
|
+
}
|
|
2513
|
+
if ((this._configRefreshGeneration.get(config.PropertyName) ?? 0) !== generationBefore ||
|
|
2514
|
+
(this._inFlightFullRefreshCount.get(config.PropertyName) ?? 0) > 0) {
|
|
2515
|
+
return 'superseded';
|
|
2516
|
+
}
|
|
2517
|
+
const nextRows = this.mergeMaterializedDeltaRows(heldRows, pkFields, isUuidPk, keyPlan.matchedDeleteKeys, keyPlan.upsertKeys, materializedUpserts);
|
|
2518
|
+
this.HandleSingleViewResult(config, {
|
|
2519
|
+
Success: true,
|
|
2520
|
+
Results: nextRows,
|
|
2521
|
+
RowCount: nextRows.length,
|
|
2522
|
+
TotalRowCount: delta.totalRowCount ?? nextRows.length,
|
|
2523
|
+
ExecutionTime: 0,
|
|
2524
|
+
ErrorMessage: '',
|
|
2525
|
+
UserViewRunID: '',
|
|
2526
|
+
});
|
|
2527
|
+
await this.RebuildDerivedState(this._contextUser);
|
|
2528
|
+
this.emitPropertyChange(config.PropertyName);
|
|
2529
|
+
return 'applied';
|
|
2530
|
+
}
|
|
2531
|
+
indexHeldRowsForDelta(heldRows, pkFields, isUuidPk) {
|
|
2532
|
+
const heldIndexByKey = new Map();
|
|
2533
|
+
for (let i = 0; i < heldRows.length; i++) {
|
|
2534
|
+
const key = this.canonicalHeldRowPkKey(heldRows[i], pkFields, isUuidPk);
|
|
2535
|
+
if (key === null) {
|
|
2536
|
+
return null;
|
|
2537
|
+
}
|
|
2538
|
+
heldIndexByKey.set(key, i);
|
|
2539
|
+
}
|
|
2540
|
+
return heldIndexByKey.size === heldRows.length ? heldIndexByKey : null;
|
|
2541
|
+
}
|
|
2542
|
+
planRowDeltaKeys(delta, heldRowCount, heldIndexByKey, pkFields, isUuidPk) {
|
|
2543
|
+
const matchedDeleteKeys = new Set();
|
|
2544
|
+
for (const delPk of delta.deletedPrimaryKeys) {
|
|
2545
|
+
const key = this.canonicalPlainRecordPkKey(delPk, pkFields, isUuidPk);
|
|
2546
|
+
if (key === null) {
|
|
2547
|
+
return null;
|
|
2548
|
+
}
|
|
2549
|
+
if (heldIndexByKey.has(key)) {
|
|
2550
|
+
matchedDeleteKeys.add(key);
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
const upsertKeys = [];
|
|
2554
|
+
const seenUpsertKeys = new Set();
|
|
2555
|
+
let netNewInserts = 0;
|
|
2556
|
+
for (const upRow of delta.upsertedRows) {
|
|
2557
|
+
const key = this.canonicalPlainRecordPkKey(upRow, pkFields, isUuidPk);
|
|
2558
|
+
if (key === null || seenUpsertKeys.has(key)) {
|
|
2559
|
+
return null;
|
|
2560
|
+
}
|
|
2561
|
+
seenUpsertKeys.add(key);
|
|
2562
|
+
upsertKeys.push(key);
|
|
2563
|
+
if (!heldIndexByKey.has(key) || matchedDeleteKeys.has(key)) {
|
|
2564
|
+
netNewInserts++;
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
// Strict cardinality verification: detects uninitialized state, missed pub/sub messages,
|
|
2568
|
+
// or filtered-slot membership divergence and falls back to reading the warm cache slot.
|
|
2569
|
+
const expectedNextLength = heldRowCount - matchedDeleteKeys.size + netNewInserts;
|
|
2570
|
+
if (expectedNextLength !== delta.rowCount) {
|
|
2571
|
+
return null;
|
|
2572
|
+
}
|
|
2573
|
+
return { matchedDeleteKeys, upsertKeys, netNewInserts };
|
|
2574
|
+
}
|
|
2575
|
+
mergeMaterializedDeltaRows(heldRows, pkFields, isUuidPk, matchedDeleteKeys, upsertKeys, materializedUpserts) {
|
|
2576
|
+
const upsertByKey = new Map();
|
|
2577
|
+
for (let i = 0; i < upsertKeys.length; i++) {
|
|
2578
|
+
upsertByKey.set(upsertKeys[i], materializedUpserts[i]);
|
|
2579
|
+
}
|
|
2580
|
+
const consumedUpsertKeys = new Set();
|
|
2581
|
+
const nextRows = [];
|
|
2582
|
+
for (let i = 0; i < heldRows.length; i++) {
|
|
2583
|
+
const existingRow = heldRows[i];
|
|
2584
|
+
const key = this.canonicalHeldRowPkKey(existingRow, pkFields, isUuidPk);
|
|
2585
|
+
if (upsertByKey.has(key)) {
|
|
2586
|
+
nextRows.push(upsertByKey.get(key));
|
|
2587
|
+
consumedUpsertKeys.add(key);
|
|
2588
|
+
}
|
|
2589
|
+
else if (!matchedDeleteKeys.has(key)) {
|
|
2590
|
+
nextRows.push(existingRow);
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
for (let i = 0; i < upsertKeys.length; i++) {
|
|
2594
|
+
const key = upsertKeys[i];
|
|
2595
|
+
if (!consumedUpsertKeys.has(key)) {
|
|
2596
|
+
nextRows.push(upsertByKey.get(key));
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
return nextRows;
|
|
2600
|
+
}
|
|
2601
|
+
getHeldRowsForDelta(config) {
|
|
2602
|
+
const entry = this._dataMap.get(config.PropertyName);
|
|
2603
|
+
if (entry && entry.loadedSuccessfully === false) {
|
|
2604
|
+
return null;
|
|
2605
|
+
}
|
|
2606
|
+
if (entry?.loadedSuccessfully && Array.isArray(entry.data)) {
|
|
2607
|
+
return entry.data;
|
|
2608
|
+
}
|
|
2609
|
+
const propVal = Reflect.get(this, config.PropertyName);
|
|
2610
|
+
if (Array.isArray(propVal) && (propVal.length > 0 || Boolean(entry))) {
|
|
2611
|
+
return propVal;
|
|
2612
|
+
}
|
|
2613
|
+
return null;
|
|
2614
|
+
}
|
|
2615
|
+
readHeldRowField(row, fieldName) {
|
|
2616
|
+
if (row instanceof BaseEntity) {
|
|
2617
|
+
return row.Get(fieldName);
|
|
2618
|
+
}
|
|
2619
|
+
if (fieldName in row) {
|
|
2620
|
+
return row[fieldName];
|
|
2621
|
+
}
|
|
2622
|
+
const nestedData = row['data'];
|
|
2623
|
+
if (typeof nestedData === 'object' && nestedData !== null && fieldName in nestedData) {
|
|
2624
|
+
return nestedData[fieldName];
|
|
2625
|
+
}
|
|
2626
|
+
return undefined;
|
|
2627
|
+
}
|
|
2628
|
+
canonicalHeldRowPkKey(row, pkFields, isUuidPk) {
|
|
2629
|
+
const parts = [];
|
|
2630
|
+
for (let i = 0; i < pkFields.length; i++) {
|
|
2631
|
+
const val = this.readHeldRowField(row, pkFields[i]);
|
|
2632
|
+
if (val === null || val === undefined) {
|
|
2633
|
+
return null;
|
|
2634
|
+
}
|
|
2635
|
+
const str = String(val);
|
|
2636
|
+
parts.push(CanonicalPrimaryKeyValue(str, isUuidPk[i]));
|
|
2637
|
+
}
|
|
2638
|
+
return parts.join('\u001f');
|
|
2639
|
+
}
|
|
2640
|
+
canonicalPlainRecordPkKey(record, pkFields, isUuidPk) {
|
|
2641
|
+
const parts = [];
|
|
2642
|
+
for (let i = 0; i < pkFields.length; i++) {
|
|
2643
|
+
const val = record[pkFields[i]];
|
|
2644
|
+
if (val === null || val === undefined) {
|
|
2645
|
+
return null;
|
|
2646
|
+
}
|
|
2647
|
+
const str = String(val);
|
|
2648
|
+
parts.push(CanonicalPrimaryKeyValue(str, isUuidPk[i]));
|
|
2649
|
+
}
|
|
2650
|
+
return parts.join('\u001f');
|
|
2651
|
+
}
|
|
2652
|
+
upsertsMatchHeldTimestamps(heldRows, heldIndexByKey, upsertedRows, upsertKeys, entityInfo) {
|
|
2653
|
+
if (!entityInfo || !entityInfo.Fields.some(f => f.IsUpdatedAtField)) {
|
|
2654
|
+
return false;
|
|
2655
|
+
}
|
|
2656
|
+
for (let i = 0; i < upsertedRows.length; i++) {
|
|
2657
|
+
const heldIdx = heldIndexByKey.get(upsertKeys[i]);
|
|
2658
|
+
if (heldIdx === undefined) {
|
|
2659
|
+
return false;
|
|
2660
|
+
}
|
|
2661
|
+
const heldRaw = this.readHeldRowField(heldRows[heldIdx], EntityInfo.UpdatedAtFieldName);
|
|
2662
|
+
const incomingRaw = upsertedRows[i][EntityInfo.UpdatedAtFieldName];
|
|
2663
|
+
if (heldRaw == null || incomingRaw == null) {
|
|
2664
|
+
return false;
|
|
2665
|
+
}
|
|
2666
|
+
const heldMs = heldRaw instanceof Date ? heldRaw.getTime() : new Date(String(heldRaw)).getTime();
|
|
2667
|
+
const incomingMs = incomingRaw instanceof Date ? incomingRaw.getTime() : new Date(String(incomingRaw)).getTime();
|
|
2668
|
+
if (Number.isNaN(heldMs) || Number.isNaN(incomingMs) || heldMs !== incomingMs) {
|
|
2669
|
+
return false;
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
return true;
|
|
2673
|
+
}
|
|
2191
2674
|
/**
|
|
2192
2675
|
* True when a cache-change payload carries a schema hash and it differs from this engine's
|
|
2193
2676
|
* hash for the config's entity. A payload without a hash, or an entity this process cannot
|
|
@@ -2201,40 +2684,81 @@ export class BaseEngine extends BaseSingleton {
|
|
|
2201
2684
|
return ownHash !== undefined && ownHash !== payloadSchemaHash;
|
|
2202
2685
|
}
|
|
2203
2686
|
/**
|
|
2204
|
-
* Reloads a config after a
|
|
2205
|
-
* arrives while a reload for the same property is running asks for one more reload after
|
|
2206
|
-
* instead of starting its own. Every caller's promise settles only after a reload that began
|
|
2687
|
+
* Reloads a config after a change made elsewhere, coalescing overlapping requests: an event
|
|
2688
|
+
* that arrives while a reload for the same property is running asks for one more reload after
|
|
2689
|
+
* it instead of starting its own. Every caller's promise settles only after a reload that began
|
|
2207
2690
|
* after its event, so no change is missed, but N events in a burst cost at most two reloads.
|
|
2691
|
+
* Successive reloads of one property are at least {@link ExternalChangeFollowUpDelayMs} apart.
|
|
2692
|
+
* Requests for different properties never share a reload.
|
|
2693
|
+
*
|
|
2694
|
+
* @param bypassCache - Read past the cache. When any request folded into a reload asks for
|
|
2695
|
+
* it, that reload bypasses the cache.
|
|
2208
2696
|
*/
|
|
2209
|
-
reloadAfterExternalChange(config) {
|
|
2697
|
+
reloadAfterExternalChange(config, bypassCache = false) {
|
|
2210
2698
|
const running = this._externalChangeReloads.get(config.PropertyName);
|
|
2211
2699
|
if (running) {
|
|
2212
2700
|
running.Again = true;
|
|
2701
|
+
running.BypassCache = running.BypassCache || bypassCache;
|
|
2213
2702
|
return running.Done;
|
|
2214
2703
|
}
|
|
2215
|
-
const reload = { Again: false, Done: Promise.resolve() };
|
|
2704
|
+
const reload = { Again: false, BypassCache: bypassCache, Done: Promise.resolve() };
|
|
2216
2705
|
this._externalChangeReloads.set(config.PropertyName, reload);
|
|
2217
2706
|
reload.Done = this.runExternalChangeReloads(config, reload);
|
|
2218
2707
|
return reload.Done;
|
|
2219
2708
|
}
|
|
2220
|
-
/**
|
|
2709
|
+
/**
|
|
2710
|
+
* Runs reloads for {@link reloadAfterExternalChange} until no further event asked for one.
|
|
2711
|
+
* A reload that throws does not end the loop while another is pending, so an event that
|
|
2712
|
+
* arrived during a failed reload is still served; the error surfaces only when the last
|
|
2713
|
+
* reload is the one that threw.
|
|
2714
|
+
*/
|
|
2221
2715
|
async runExternalChangeReloads(config, reload) {
|
|
2222
2716
|
try {
|
|
2717
|
+
let lastError = null;
|
|
2223
2718
|
do {
|
|
2719
|
+
const wait = this.reloadSpacingRemainingMs(config.PropertyName);
|
|
2720
|
+
if (wait > 0) {
|
|
2721
|
+
// Requests that arrive during the wait join the reload that follows it.
|
|
2722
|
+
await new Promise(resolve => setTimeout(resolve, wait));
|
|
2723
|
+
}
|
|
2724
|
+
const bypassCache = reload.BypassCache;
|
|
2224
2725
|
reload.Again = false;
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
this.emitPropertyChange(config.PropertyName);
|
|
2726
|
+
reload.BypassCache = false;
|
|
2727
|
+
lastError = await this.runOneExternalChangeReload(config, bypassCache);
|
|
2728
|
+
this._externalChangeReloadEndedAt.set(config.PropertyName, Date.now());
|
|
2729
|
+
if (lastError && reload.Again) {
|
|
2730
|
+
LogError(lastError);
|
|
2731
|
+
}
|
|
2232
2732
|
} while (reload.Again);
|
|
2733
|
+
if (lastError) {
|
|
2734
|
+
throw lastError;
|
|
2735
|
+
}
|
|
2233
2736
|
}
|
|
2234
2737
|
finally {
|
|
2235
2738
|
this._externalChangeReloads.delete(config.PropertyName);
|
|
2236
2739
|
}
|
|
2237
2740
|
}
|
|
2741
|
+
/** How long the next reload of the property must still wait for {@link ExternalChangeFollowUpDelayMs}; 0 when it may start now. */
|
|
2742
|
+
reloadSpacingRemainingMs(propertyName) {
|
|
2743
|
+
const endedAt = this._externalChangeReloadEndedAt.get(propertyName);
|
|
2744
|
+
return endedAt === undefined ? 0 : Math.max(0, endedAt + BaseEngine.ExternalChangeFollowUpDelayMs - Date.now());
|
|
2745
|
+
}
|
|
2746
|
+
/** One reload for {@link runExternalChangeReloads}. Returns the error it threw, or null. */
|
|
2747
|
+
async runOneExternalChangeReload(config, bypassCache) {
|
|
2748
|
+
try {
|
|
2749
|
+
// The reload replaces the property, so the derived state has to be rebuilt too —
|
|
2750
|
+
// and the reload must NOT emit on its own, or a subscriber wakes between the new
|
|
2751
|
+
// rows and the rebuild and reads derived state still pointing at the instances
|
|
2752
|
+
// this reload discarded.
|
|
2753
|
+
await this.LoadSingleConfig(config, this._contextUser, bypassCache, /*suppressEmit*/ true);
|
|
2754
|
+
await this.RebuildDerivedState(this._contextUser);
|
|
2755
|
+
this.emitPropertyChange(config.PropertyName);
|
|
2756
|
+
return null;
|
|
2757
|
+
}
|
|
2758
|
+
catch (e) {
|
|
2759
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2238
2762
|
/**
|
|
2239
2763
|
* True when `payloadRows` are the same row versions this config currently holds: the same
|
|
2240
2764
|
* number of rows, and the same multiset of (primary key, `__mj_UpdatedAt`) pairs.
|