@memberjunction/core 6.2.0-edge.4 → 6.2.0-edge.6
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 +22 -0
- package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
- package/dist/generic/BaseEntitySaveQueue.js +43 -4
- package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +18 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
- package/dist/generic/alternateBaseViews.d.ts +119 -0
- package/dist/generic/alternateBaseViews.d.ts.map +1 -0
- package/dist/generic/alternateBaseViews.js +223 -0
- package/dist/generic/alternateBaseViews.js.map +1 -0
- package/dist/generic/baseEngine.d.ts +82 -14
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +311 -46
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
- package/dist/generic/baseEngineRegistry.js +2 -1
- package/dist/generic/baseEngineRegistry.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +194 -14
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +638 -218
- 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/contextTokens.d.ts +136 -0
- package/dist/generic/contextTokens.d.ts.map +1 -0
- package/dist/generic/contextTokens.js +319 -0
- package/dist/generic/contextTokens.js.map +1 -0
- package/dist/generic/databaseProviderBase.d.ts +103 -13
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +321 -77
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/embeddedRecord.d.ts +6 -0
- package/dist/generic/embeddedRecord.d.ts.map +1 -1
- package/dist/generic/embeddedRecord.js +15 -1
- package/dist/generic/embeddedRecord.js.map +1 -1
- package/dist/generic/entityCompanion.d.ts +7 -0
- package/dist/generic/entityCompanion.d.ts.map +1 -1
- package/dist/generic/entityCompanion.js +8 -0
- package/dist/generic/entityCompanion.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/entityInfo.d.ts +306 -12
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +894 -92
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/entityValueFieldSecurity.d.ts +139 -0
- package/dist/generic/entityValueFieldSecurity.d.ts.map +1 -0
- package/dist/generic/entityValueFieldSecurity.js +250 -0
- package/dist/generic/entityValueFieldSecurity.js.map +1 -0
- package/dist/generic/fieldSecuritySQLGate.d.ts +350 -0
- package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -0
- package/dist/generic/fieldSecuritySQLGate.js +898 -0
- package/dist/generic/fieldSecuritySQLGate.js.map +1 -0
- package/dist/generic/indexRestrictedFields.d.ts +47 -0
- package/dist/generic/indexRestrictedFields.d.ts.map +1 -0
- package/dist/generic/indexRestrictedFields.js +129 -0
- package/dist/generic/indexRestrictedFields.js.map +1 -0
- 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 +39 -2
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +318 -51
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +675 -109
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/logging.d.ts.map +1 -1
- package/dist/generic/logging.js +7 -1
- package/dist/generic/logging.js.map +1 -1
- package/dist/generic/metadata.d.ts +3 -2
- package/dist/generic/metadata.d.ts.map +1 -1
- package/dist/generic/metadata.js +5 -2
- 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/platformSQL.d.ts +54 -0
- package/dist/generic/platformSQL.d.ts.map +1 -1
- package/dist/generic/platformSQL.js +55 -0
- package/dist/generic/platformSQL.js.map +1 -1
- package/dist/generic/providerBase.d.ts +370 -57
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +1085 -284
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/queryInfo.d.ts +23 -9
- package/dist/generic/queryInfo.d.ts.map +1 -1
- package/dist/generic/queryInfo.js +73 -33
- package/dist/generic/queryInfo.js.map +1 -1
- package/dist/generic/queryInfoInterfaces.d.ts +8 -0
- package/dist/generic/queryInfoInterfaces.d.ts.map +1 -1
- package/dist/generic/queryTemplateContext.d.ts +49 -0
- package/dist/generic/queryTemplateContext.d.ts.map +1 -0
- package/dist/generic/queryTemplateContext.js +113 -0
- package/dist/generic/queryTemplateContext.js.map +1 -0
- package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -1
- package/dist/generic/recordChangeFieldSecurity.js +13 -2
- package/dist/generic/recordChangeFieldSecurity.js.map +1 -1
- package/dist/generic/relatedRecordCollection.d.ts +13 -0
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
- package/dist/generic/relatedRecordCollection.js +44 -5
- package/dist/generic/relatedRecordCollection.js.map +1 -1
- package/dist/generic/runQuery.d.ts +42 -0
- package/dist/generic/runQuery.d.ts.map +1 -1
- package/dist/generic/runQuery.js +14 -0
- package/dist/generic/runQuery.js.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.js +38 -2
- package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
- package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
- package/dist/generic/saveEntityGraphOperation.js +3 -0
- package/dist/generic/saveEntityGraphOperation.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 +17 -6
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +25 -10
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/generic/telemetryManager.d.ts +9 -0
- package/dist/generic/telemetryManager.d.ts.map +1 -1
- package/dist/generic/telemetryManager.js +5 -1
- package/dist/generic/telemetryManager.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 +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +89 -0
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +36 -0
- package/dist/views/runView.js.map +1 -1
- package/dist/views/viewProperties.d.ts +55 -0
- package/dist/views/viewProperties.d.ts.map +1 -0
- package/dist/views/viewProperties.js +258 -0
- package/dist/views/viewProperties.js.map +1 -0
- package/package.json +3 -3
- package/readme.md +29 -4
|
@@ -39,6 +39,14 @@ export class BaseEnginePropertyConfig extends BaseInfo {
|
|
|
39
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.
|
|
40
40
|
*/
|
|
41
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;
|
|
42
50
|
// now copy the values from init to this object
|
|
43
51
|
if (init)
|
|
44
52
|
Object.assign(this, init);
|
|
@@ -190,8 +198,20 @@ export class BaseEngine extends BaseSingleton {
|
|
|
190
198
|
* reconciles it. Keyed by config PropertyName. See {@link beginConfigRefresh}.
|
|
191
199
|
*/
|
|
192
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();
|
|
193
207
|
this._entityEventDebounceTime = 1500; // Default debounce time in milliseconds (1.5 seconds)
|
|
194
208
|
this._eventRefreshRetryTimers = new Map();
|
|
209
|
+
/**
|
|
210
|
+
* Maps normalized entity names (`entityName.trim().toLowerCase()`) to dataset item properties
|
|
211
|
+
* loaded via `DatasetResultHandling === 'single_property'`, allowing in-place auto-refresh of
|
|
212
|
+
* dataset-backed engines (such as `TemplateEngineBase`) on entity save/delete events.
|
|
213
|
+
*/
|
|
214
|
+
this._datasetItemMappings = new Map();
|
|
195
215
|
/** Configs waiting for the cache manager to initialize before their callbacks can register. */
|
|
196
216
|
this._deferredCacheCallbackConfigs = null;
|
|
197
217
|
}
|
|
@@ -659,14 +679,19 @@ export class BaseEngine extends BaseSingleton {
|
|
|
659
679
|
}
|
|
660
680
|
if (event.type === 'delete' || event.type === 'save') {
|
|
661
681
|
const eName = event.baseEntity.EntityInfo.Name.toLowerCase().trim();
|
|
662
|
-
const matchingConfigs = this.
|
|
682
|
+
const matchingConfigs = this._metadataConfigs.filter((config) => {
|
|
663
683
|
return config.AutoRefresh && config.EntityName && config.EntityName.trim().toLowerCase() === eName;
|
|
664
684
|
});
|
|
665
|
-
|
|
685
|
+
const matchingDatasetItems = (this._datasetItemMappings.get(eName) ?? []).filter(m => m.config.AutoRefresh !== false);
|
|
686
|
+
if (matchingConfigs.length === 0 && matchingDatasetItems.length === 0) {
|
|
666
687
|
return true;
|
|
667
688
|
}
|
|
668
|
-
// Check if ALL matching configs can use immediate mutation
|
|
669
|
-
|
|
689
|
+
// Check if ALL matching entity configs can use immediate mutation. A saved object
|
|
690
|
+
// that lacks columns (its saver could not read them) would put a narrowed row into
|
|
691
|
+
// an engine every user reads, so that save takes the full-refresh path instead,
|
|
692
|
+
// which reloads the row as this engine's own user.
|
|
693
|
+
const allCanUseImmediate = matchingConfigs.every(config => this.canUseImmediateMutation(config))
|
|
694
|
+
&& this.savedRowIsComplete(event);
|
|
670
695
|
if (allCanUseImmediate) {
|
|
671
696
|
const applyAll = async () => {
|
|
672
697
|
// mutation requires await because the entity must be cloned (with its
|
|
@@ -674,6 +699,9 @@ export class BaseEngine extends BaseSingleton {
|
|
|
674
699
|
for (const config of matchingConfigs) {
|
|
675
700
|
await this.applyImmediateMutation(config, event);
|
|
676
701
|
}
|
|
702
|
+
if (matchingDatasetItems.length > 0) {
|
|
703
|
+
await this.applyDatasetItemMutations(matchingDatasetItems, event);
|
|
704
|
+
}
|
|
677
705
|
};
|
|
678
706
|
// The entity raises save/delete as soon as its own write returns, while an
|
|
679
707
|
// enclosing transaction may still be open. Mutating the cache now would keep a
|
|
@@ -700,6 +728,21 @@ export class BaseEngine extends BaseSingleton {
|
|
|
700
728
|
return true;
|
|
701
729
|
}
|
|
702
730
|
else {
|
|
731
|
+
if (matchingDatasetItems.length > 0) {
|
|
732
|
+
const provider = event.baseEntity.ProviderToUse;
|
|
733
|
+
if (provider instanceof DatabaseProviderBase && provider.TransactionDepth > 0) {
|
|
734
|
+
const token = provider.CapturePostCommitToken();
|
|
735
|
+
const savedKey = event.type === 'save' ? event.baseEntity.PrimaryKey.ToString() : null;
|
|
736
|
+
provider.RunAfterCommit(async () => {
|
|
737
|
+
if (savedKey !== null && event.baseEntity.PrimaryKey.ToString() !== savedKey)
|
|
738
|
+
return;
|
|
739
|
+
await this.applyDatasetItemMutations(matchingDatasetItems, event);
|
|
740
|
+
}, `BaseEngine dataset cache mutation for ${eName}`, token);
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
await this.applyDatasetItemMutations(matchingDatasetItems, event);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
703
746
|
// At least one config requires full refresh, use debouncing
|
|
704
747
|
return this.DebounceIndividualBaseEntityEvent(event);
|
|
705
748
|
}
|
|
@@ -726,7 +769,7 @@ export class BaseEngine extends BaseSingleton {
|
|
|
726
769
|
if (!entityName) {
|
|
727
770
|
return true; // No entity name, nothing to do
|
|
728
771
|
}
|
|
729
|
-
const matchingConfigs = this.
|
|
772
|
+
const matchingConfigs = this._metadataConfigs.filter((config) => {
|
|
730
773
|
return config.AutoRefresh && config.EntityName && config.EntityName.trim().toLowerCase() === entityName;
|
|
731
774
|
});
|
|
732
775
|
if (matchingConfigs.length === 0) {
|
|
@@ -875,7 +918,7 @@ export class BaseEngine extends BaseSingleton {
|
|
|
875
918
|
const entityName = event.baseEntity.EntityInfo.Name.toLowerCase().trim();
|
|
876
919
|
if (!this._entityEventSubjects.has(entityName)) {
|
|
877
920
|
// Find matching config to get custom debounce time
|
|
878
|
-
const matchingConfig = this.
|
|
921
|
+
const matchingConfig = this._metadataConfigs.find(c => c.EntityName?.trim().toLowerCase() === entityName);
|
|
879
922
|
// Use config-specific debounce time or fall back to default
|
|
880
923
|
const debounceTimeValue = matchingConfig?.DebounceTime ?? this.EntityEventDebounceTime;
|
|
881
924
|
const subject = new Subject();
|
|
@@ -947,13 +990,15 @@ export class BaseEngine extends BaseSingleton {
|
|
|
947
990
|
try {
|
|
948
991
|
const entityName = events[0].baseEntity.EntityInfo.Name.toLowerCase().trim();
|
|
949
992
|
let refreshCount = 0;
|
|
950
|
-
for (const config of this.
|
|
993
|
+
for (const config of this._metadataConfigs) {
|
|
951
994
|
if (config.AutoRefresh && config.Type === 'entity' && config.EntityName?.trim().toLowerCase() === entityName) {
|
|
952
995
|
const classified = events.map(e => ({ event: e, disposition: this.classifyEventForConfig(config, e) }));
|
|
953
996
|
const needingWork = classified.filter(c => c.disposition.action === 'refresh');
|
|
954
997
|
if (needingWork.length > 0) {
|
|
955
998
|
// Check if we can use immediate array mutation instead of running a view
|
|
956
|
-
|
|
999
|
+
// A save carrying a row with missing columns can't replace an engine row
|
|
1000
|
+
// in place (see savedRowIsComplete), so the whole window refreshes instead.
|
|
1001
|
+
if (this.canUseImmediateMutation(config) && needingWork.every(c => this.savedRowIsComplete(c.event))) {
|
|
957
1002
|
// Apply, in order, every event the array hasn't seen yet
|
|
958
1003
|
for (const c of needingWork) {
|
|
959
1004
|
await this.applyImmediateMutation(config, c.event);
|
|
@@ -1018,7 +1063,11 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1018
1063
|
classifyEventForConfig(config, event) {
|
|
1019
1064
|
if (event.type === 'save') {
|
|
1020
1065
|
if (event.saveSubType === 'update' || event.saveSubType === 'create') {
|
|
1021
|
-
|
|
1066
|
+
// When AlternateBaseView is set on the config, the SQL view applies a canonical
|
|
1067
|
+
// WHERE/JOIN predicate — an in-memory update can cause the record to fall out of
|
|
1068
|
+
// the alternate view (e.g. Status changed from 'Active' to 'Inactive'), so we
|
|
1069
|
+
// must refresh from the database rather than only notifying in place.
|
|
1070
|
+
if (!config.AlternateBaseView && this.isEntityAlreadyInArray(config, event.baseEntity)) {
|
|
1022
1071
|
return { action: 'notify', changeType: event.saveSubType === 'create' ? 'add' : 'update' };
|
|
1023
1072
|
}
|
|
1024
1073
|
}
|
|
@@ -1203,6 +1252,11 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1203
1252
|
if (config.Filter) {
|
|
1204
1253
|
return false;
|
|
1205
1254
|
}
|
|
1255
|
+
// An AlternateBaseView applies a canonical SQL WHERE/JOIN in the database view;
|
|
1256
|
+
// immediate in-memory mutations cannot evaluate that SQL predicate.
|
|
1257
|
+
if (config.AlternateBaseView) {
|
|
1258
|
+
return false;
|
|
1259
|
+
}
|
|
1206
1260
|
// If there's an OrderBy, we can't safely do immediate mutations because:
|
|
1207
1261
|
// - For creates: we'd need to insert at the correct position
|
|
1208
1262
|
// - For updates: the entity might need to move to a different position
|
|
@@ -1241,6 +1295,21 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1241
1295
|
// it means the subclass has overridden it
|
|
1242
1296
|
return instanceProto.AdditionalLoading !== baseProto.AdditionalLoading;
|
|
1243
1297
|
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Whether a save event's entity carries every column, so it can replace an engine row in
|
|
1300
|
+
* place. Deletes and non-save events always qualify.
|
|
1301
|
+
*/
|
|
1302
|
+
savedRowIsComplete(event) {
|
|
1303
|
+
if (event.type !== 'save' || !event.baseEntity) {
|
|
1304
|
+
return true;
|
|
1305
|
+
}
|
|
1306
|
+
const entity = event.baseEntity;
|
|
1307
|
+
// Asked of the object directly, so no throwaway GetAll() row is built per event. An object
|
|
1308
|
+
// that is not a BaseEntity (a test double) is judged on its row, as before.
|
|
1309
|
+
return typeof entity.HoldsEveryField === 'function'
|
|
1310
|
+
? entity.HoldsEveryField()
|
|
1311
|
+
: LocalCacheManager.RowHasEveryField(entity.EntityInfo, entity.GetAll());
|
|
1312
|
+
}
|
|
1244
1313
|
/**
|
|
1245
1314
|
* Applies an immediate array mutation based on the entity event type.
|
|
1246
1315
|
* This is faster than running a full view refresh for simple add/update/delete operations.
|
|
@@ -1390,16 +1459,9 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1390
1459
|
if (cache.IsBatchingEntityEvents(entity?.ProviderToUse) || cache.WasTakenByEntityEventBatch(event)) {
|
|
1391
1460
|
return;
|
|
1392
1461
|
}
|
|
1393
|
-
//
|
|
1394
|
-
//
|
|
1395
|
-
const
|
|
1396
|
-
let connectionString;
|
|
1397
|
-
if (provider && 'InstanceConnectionString' in provider) {
|
|
1398
|
-
connectionString = provider.InstanceConnectionString;
|
|
1399
|
-
}
|
|
1400
|
-
// Use the shared builder so the fingerprint matches what LoadSingleEntityConfig
|
|
1401
|
-
// and RegisterCacheChangeCallbacks produce — prevents silent cache-slot mismatches
|
|
1402
|
-
const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(this.BuildRunViewParamsForConfig(config), connectionString);
|
|
1462
|
+
// The same builder as LoadSingleEntityConfig and RegisterCacheChangeCallbacks, so the
|
|
1463
|
+
// slot maintained here is the one this engine reads and listens on.
|
|
1464
|
+
const fingerprint = this.configFingerprint(config);
|
|
1403
1465
|
// Build CompositeKey from the entity's primary key fields
|
|
1404
1466
|
const key = entity.PrimaryKey;
|
|
1405
1467
|
if (!key || key.KeyValuePairs.length === 0 || key.KeyValuePairs.some(kv => kv.Value == null)) {
|
|
@@ -1421,6 +1483,12 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1421
1483
|
}
|
|
1422
1484
|
else {
|
|
1423
1485
|
const entityData = entity.GetAll();
|
|
1486
|
+
// A row-filtered or field-secured slot, or a saved row missing columns, cannot take the
|
|
1487
|
+
// saver's values in place: clear it and let the next read refill it.
|
|
1488
|
+
if (!LocalCacheManager.Instance.CanUpsertRowIntoSlot(fingerprint, entity.EntityInfo, entityData)) {
|
|
1489
|
+
await LocalCacheManager.Instance.InvalidateRunViewResult(fingerprint);
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1424
1492
|
await LocalCacheManager.Instance.UpsertSingleEntity(fingerprint, entityData, key, updatedAt);
|
|
1425
1493
|
}
|
|
1426
1494
|
}
|
|
@@ -1484,6 +1552,7 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1484
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()
|
|
1485
1553
|
const entityConfigs = this._metadataConfigs.filter(c => c.Type === 'entity');
|
|
1486
1554
|
const datasetConfigs = this._metadataConfigs.filter(c => c.Type === 'dataset');
|
|
1555
|
+
this.WarnAboutFieldSecuredEntities(entityConfigs);
|
|
1487
1556
|
// All-or-nothing permission check: if the user lacks read access on ANY
|
|
1488
1557
|
// entity config, skip ALL entity configs for this engine. Returns the
|
|
1489
1558
|
// original array unchanged when permissions pass, or an empty array when
|
|
@@ -1494,6 +1563,35 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1494
1563
|
// Register cross-server cache change callbacks for entity configs
|
|
1495
1564
|
this.RegisterCacheChangeCallbacks(entityConfigs);
|
|
1496
1565
|
}
|
|
1566
|
+
/** Engine-class and entity pairs already warned about by {@link WarnAboutFieldSecuredEntities}. */
|
|
1567
|
+
static { this._fieldSecurityWarned = new Set(); }
|
|
1568
|
+
/**
|
|
1569
|
+
* On a server, says once per engine class and entity when an entity this engine caches has
|
|
1570
|
+
* field-level security (its own, or an IS-A ancestor's). Changes nothing about the load.
|
|
1571
|
+
*
|
|
1572
|
+
* A server-side engine loads as the system user (see {@link ResolveContextUser}), so its
|
|
1573
|
+
* cache holds every column, including ones some users are denied. That is correct for a
|
|
1574
|
+
* process-wide cache, and it makes every path that hands engine data to a person responsible
|
|
1575
|
+
* for projecting it to that person first.
|
|
1576
|
+
*/
|
|
1577
|
+
WarnAboutFieldSecuredEntities(entityConfigs) {
|
|
1578
|
+
const provider = this.ProviderToUse;
|
|
1579
|
+
if (provider?.ProviderType !== ProviderType.Database) {
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
for (const config of entityConfigs) {
|
|
1583
|
+
const entityInfo = config.EntityName ? provider.EntityByName(config.EntityName) : undefined;
|
|
1584
|
+
if (!entityInfo?.FieldLevelSecurityApplies) {
|
|
1585
|
+
continue;
|
|
1586
|
+
}
|
|
1587
|
+
const key = `${this.constructor.name}|${entityInfo.Name}`.toLowerCase();
|
|
1588
|
+
if (BaseEngine._fieldSecurityWarned.has(key)) {
|
|
1589
|
+
continue;
|
|
1590
|
+
}
|
|
1591
|
+
BaseEngine._fieldSecurityWarned.add(key);
|
|
1592
|
+
LogStatus(`${this.constructor.name}: '${entityInfo.Name}' has field-level security. Engine data is loaded as the system user and holds every column; project it for the acting user before it reaches a person.`);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1497
1595
|
/**
|
|
1498
1596
|
* All-or-nothing permission gate: checks `CanRead` on every entity config. If ANY
|
|
1499
1597
|
* entity is denied, ALL configs are skipped — the engine is marked permission-constrained
|
|
@@ -1508,6 +1606,29 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1508
1606
|
CheckPermissionsOrSkipAll(configs, contextUser) {
|
|
1509
1607
|
if (configs.length === 0)
|
|
1510
1608
|
return configs;
|
|
1609
|
+
// First check whether any configured entity is missing from metadata altogether
|
|
1610
|
+
// (e.g., an optional OpenApp engine registered in the bundle whose database schema
|
|
1611
|
+
// is not installed in the target environment). Skip cleanly instead of firing N
|
|
1612
|
+
// guaranteed-to-fail RunView calls and logging N error stack traces at startup.
|
|
1613
|
+
const missingEntities = this.findMissingEntities(configs);
|
|
1614
|
+
if (missingEntities.length > 0) {
|
|
1615
|
+
LogStatusEx({
|
|
1616
|
+
message: `${this.constructor.name}: Skipping ${configs.length} entity config(s) — entity metadata not installed for: ${missingEntities.join(', ')}`,
|
|
1617
|
+
verboseOnly: true,
|
|
1618
|
+
category: 'Cache',
|
|
1619
|
+
});
|
|
1620
|
+
for (const config of configs) {
|
|
1621
|
+
if (config.AddToObject !== false) {
|
|
1622
|
+
this[config.PropertyName] = [];
|
|
1623
|
+
}
|
|
1624
|
+
this._dataMap.set(config.PropertyName, {
|
|
1625
|
+
entityName: config.EntityName,
|
|
1626
|
+
data: [],
|
|
1627
|
+
loadedSuccessfully: true,
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
return [];
|
|
1631
|
+
}
|
|
1511
1632
|
// Determine the user to check permissions for — on the client side contextUser
|
|
1512
1633
|
// may be undefined, so fall back to the current logged-in user from Metadata.
|
|
1513
1634
|
const user = contextUser || this.ProviderToUse?.CurrentUser;
|
|
@@ -1537,6 +1658,26 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1537
1658
|
}
|
|
1538
1659
|
return configs;
|
|
1539
1660
|
}
|
|
1661
|
+
/**
|
|
1662
|
+
* Returns entity names among `configs` that do not exist in the loaded metadata provider.
|
|
1663
|
+
* Only checks when the metadata provider actually has entities loaded (`md.Entities.length > 0`)
|
|
1664
|
+
* so unit tests with empty mock providers are unaffected.
|
|
1665
|
+
*/
|
|
1666
|
+
findMissingEntities(configs) {
|
|
1667
|
+
const md = this.ProviderToUse;
|
|
1668
|
+
if (!md?.Entities || md.Entities.length === 0) {
|
|
1669
|
+
return [];
|
|
1670
|
+
}
|
|
1671
|
+
const missing = [];
|
|
1672
|
+
for (const config of configs) {
|
|
1673
|
+
if (!config.EntityName)
|
|
1674
|
+
continue;
|
|
1675
|
+
if (!md.EntityByName(config.EntityName)) {
|
|
1676
|
+
missing.push(config.EntityName);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
return missing;
|
|
1680
|
+
}
|
|
1540
1681
|
/** The entity names among `configs` that `user` cannot read, per current metadata. */
|
|
1541
1682
|
findUnreadableEntities(configs, user) {
|
|
1542
1683
|
const md = this.ProviderToUse;
|
|
@@ -1602,14 +1743,8 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1602
1743
|
* IgnoreMaxRows, IncludeBinaryFields) are always consistent — preventing cache key mismatches that break
|
|
1603
1744
|
* cross-server invalidation via Redis pub/sub and local cache upsert/remove operations.
|
|
1604
1745
|
*
|
|
1605
|
-
*
|
|
1606
|
-
*
|
|
1607
|
-
* provider adds for the reading user. On a server that is correct: engines load as the system
|
|
1608
|
-
* user, whose RLS clause is empty, so the provider's fingerprint for the same read matches. An
|
|
1609
|
-
* engine loaded by an RLS-scoped user (possible on a client) would compute an unscoped
|
|
1610
|
-
* fingerprint for a slot the provider stored under a scoped one, so its cache callbacks and
|
|
1611
|
-
* `syncLocalCacheForConfig` would target a different slot. Pass the RLS clause here if such an
|
|
1612
|
-
* engine is ever needed.
|
|
1746
|
+
* The slot fingerprint for these params also carries the engine user's row-level-security
|
|
1747
|
+
* and field-security segments; see {@link configFingerprint}.
|
|
1613
1748
|
*/
|
|
1614
1749
|
BuildRunViewParamsForConfig(config, bypassCache = false) {
|
|
1615
1750
|
return {
|
|
@@ -1617,6 +1752,7 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1617
1752
|
ResultType: config.ResultType || this.EngineDefaultResultType,
|
|
1618
1753
|
ExtraFilter: config.Filter,
|
|
1619
1754
|
OrderBy: config.OrderBy,
|
|
1755
|
+
...(config.AlternateBaseView ? { AlternateBaseView: config.AlternateBaseView } : {}),
|
|
1620
1756
|
IgnoreMaxRows: true, // Engines always need ALL data — bypass entity-level UserViewMaxRows caps
|
|
1621
1757
|
_fromEngine: true, // Mark as engine-initiated to avoid false positive telemetry warnings
|
|
1622
1758
|
CacheLocal: config.CacheLocal,
|
|
@@ -1777,6 +1913,58 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1777
1913
|
}
|
|
1778
1914
|
}
|
|
1779
1915
|
}
|
|
1916
|
+
/**
|
|
1917
|
+
* Applies an immediate in-place mutation to dataset item arrays when a constituent entity is saved or deleted,
|
|
1918
|
+
* then invokes {@link AdditionalLoading} so derived associations (e.g. Template Content / Params) stay current.
|
|
1919
|
+
*/
|
|
1920
|
+
async applyDatasetItemMutations(mappings, event) {
|
|
1921
|
+
const entity = event.baseEntity;
|
|
1922
|
+
let mutated = false;
|
|
1923
|
+
const notifiedProperties = new Set();
|
|
1924
|
+
for (const mapping of mappings) {
|
|
1925
|
+
const container = this[mapping.config.PropertyName];
|
|
1926
|
+
const currentData = container?.[mapping.itemCode];
|
|
1927
|
+
if (!Array.isArray(currentData)) {
|
|
1928
|
+
continue;
|
|
1929
|
+
}
|
|
1930
|
+
if (event.type === 'save') {
|
|
1931
|
+
const provider = this.ProviderToUse;
|
|
1932
|
+
if (!provider)
|
|
1933
|
+
continue;
|
|
1934
|
+
const cached = await provider.GetEntityObject(mapping.entityName, this._contextUser);
|
|
1935
|
+
if (!cached)
|
|
1936
|
+
continue;
|
|
1937
|
+
await cached.LoadFromData(entity.GetAll());
|
|
1938
|
+
const existsByRef = currentData.indexOf(entity) >= 0;
|
|
1939
|
+
if (!existsByRef) {
|
|
1940
|
+
const index = this.findEntityIndexByPrimaryKeys(currentData, entity);
|
|
1941
|
+
if (index >= 0) {
|
|
1942
|
+
currentData[index] = cached;
|
|
1943
|
+
}
|
|
1944
|
+
else {
|
|
1945
|
+
currentData.push(cached);
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
mutated = true;
|
|
1949
|
+
notifiedProperties.add(mapping.config.PropertyName);
|
|
1950
|
+
}
|
|
1951
|
+
else if (event.type === 'delete') {
|
|
1952
|
+
const oldValues = event.payload?.OldValues;
|
|
1953
|
+
const index = this.indexOfEntityByRefOrKey(currentData, entity, oldValues);
|
|
1954
|
+
if (index >= 0) {
|
|
1955
|
+
currentData.splice(index, 1);
|
|
1956
|
+
mutated = true;
|
|
1957
|
+
notifiedProperties.add(mapping.config.PropertyName);
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
if (mutated) {
|
|
1962
|
+
await this.AdditionalLoading(this._contextUser);
|
|
1963
|
+
for (const propName of notifiedProperties) {
|
|
1964
|
+
this.emitPropertyChange(propName);
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1780
1968
|
/**
|
|
1781
1969
|
* Handles the process of loading a single config of type 'dataset'.
|
|
1782
1970
|
* @param config
|
|
@@ -1796,7 +1984,7 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1796
1984
|
await p.CacheDataset(config.DatasetName, config.DatasetItemFilters, result);
|
|
1797
1985
|
}
|
|
1798
1986
|
else {
|
|
1799
|
-
result = await p.GetAndCacheDatasetByName(config.DatasetName, config.DatasetItemFilters);
|
|
1987
|
+
result = await p.GetAndCacheDatasetByName(config.DatasetName, config.DatasetItemFilters, contextUser);
|
|
1800
1988
|
}
|
|
1801
1989
|
if (!result) {
|
|
1802
1990
|
const errorMsg = `GetAndCacheDatasetByName("${config.DatasetName}") returned undefined/null — provider: ${p?.constructor?.name}`;
|
|
@@ -1823,18 +2011,31 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1823
2011
|
if (result.Success) {
|
|
1824
2012
|
if (config.AddToObject !== false) {
|
|
1825
2013
|
if (config.DatasetResultHandling === 'single_property') {
|
|
2014
|
+
// Clear previous dataset item mappings for this property before re-indexing
|
|
2015
|
+
for (const [key, list] of this._datasetItemMappings.entries()) {
|
|
2016
|
+
const filtered = list.filter(m => m.config.PropertyName !== config.PropertyName);
|
|
2017
|
+
if (filtered.length === 0) {
|
|
2018
|
+
this._datasetItemMappings.delete(key);
|
|
2019
|
+
}
|
|
2020
|
+
else if (filtered.length !== list.length) {
|
|
2021
|
+
this._datasetItemMappings.set(key, filtered);
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
1826
2024
|
const singleObject = {};
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
//adding them to the singleObject
|
|
1830
|
-
const entities = [];
|
|
1831
|
-
for (const entityData of item.Results) {
|
|
2025
|
+
await Promise.all(result.Results.map(async (item) => {
|
|
2026
|
+
const entities = await Promise.all(item.Results.map(async (entityData) => {
|
|
1832
2027
|
const entity = await p.GetEntityObject(item.EntityName, contextUser);
|
|
1833
2028
|
await entity.LoadFromData(entityData);
|
|
1834
|
-
|
|
1835
|
-
}
|
|
2029
|
+
return entity;
|
|
2030
|
+
}));
|
|
1836
2031
|
singleObject[item.Code] = entities;
|
|
1837
|
-
|
|
2032
|
+
if (item.EntityName && item.Code) {
|
|
2033
|
+
const normName = item.EntityName.trim().toLowerCase();
|
|
2034
|
+
const existing = this._datasetItemMappings.get(normName) ?? [];
|
|
2035
|
+
existing.push({ config, itemCode: item.Code, entityName: item.EntityName });
|
|
2036
|
+
this._datasetItemMappings.set(normName, existing);
|
|
2037
|
+
}
|
|
2038
|
+
}));
|
|
1838
2039
|
this[config.PropertyName] = singleObject;
|
|
1839
2040
|
}
|
|
1840
2041
|
else {
|
|
@@ -1925,6 +2126,14 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1925
2126
|
LogStatusEx({ message: `BaseEngine.OnExternalCacheChange: '${config.PropertyName}' already holds the payload's rows — skipped`, verboseOnly: true });
|
|
1926
2127
|
return;
|
|
1927
2128
|
}
|
|
2129
|
+
// Rows written under a different field list (a server on the other side of a
|
|
2130
|
+
// migration) lack columns this process has, or carry ones it does not. Loading
|
|
2131
|
+
// them would blank the missing columns on every row, so read the rows instead.
|
|
2132
|
+
if (this.payloadSchemaDiffers(config, parsed.schemaHash)) {
|
|
2133
|
+
LogStatusEx({ message: `BaseEngine.OnExternalCacheChange: payload for '${config.PropertyName}' was written under a different schema — reloading instead`, verboseOnly: true });
|
|
2134
|
+
await this.reloadAfterExternalChange(config);
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
1928
2137
|
// Claim a refresh generation BEFORE the awaited materialization — the same
|
|
1929
2138
|
// protocol LoadSingleConfig uses around its awaited RunView. Without it, two
|
|
1930
2139
|
// overlapping cache events (or an event racing a full reload) can resolve out
|
|
@@ -1977,13 +2186,54 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1977
2186
|
LogWarning(`BaseEngine.OnExternalCacheChange: payload for '${config.PropertyName}' could not be applied (${e instanceof Error ? e.message : String(e)}) — falling back to full reload`, 'Cache');
|
|
1978
2187
|
}
|
|
1979
2188
|
}
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
2189
|
+
await this.reloadAfterExternalChange(config);
|
|
2190
|
+
}
|
|
2191
|
+
/**
|
|
2192
|
+
* True when a cache-change payload carries a schema hash and it differs from this engine's
|
|
2193
|
+
* hash for the config's entity. A payload without a hash, or an entity this process cannot
|
|
2194
|
+
* resolve, does not count as differing.
|
|
2195
|
+
*/
|
|
2196
|
+
payloadSchemaDiffers(config, payloadSchemaHash) {
|
|
2197
|
+
if (typeof payloadSchemaHash !== 'string' || !payloadSchemaHash || !config.EntityName) {
|
|
2198
|
+
return false;
|
|
2199
|
+
}
|
|
2200
|
+
const ownHash = LocalCacheManager.Instance.ComputeSchemaHash(this.ProviderToUse, config.EntityName);
|
|
2201
|
+
return ownHash !== undefined && ownHash !== payloadSchemaHash;
|
|
2202
|
+
}
|
|
2203
|
+
/**
|
|
2204
|
+
* Reloads a config after a peer's cache change, coalescing overlapping requests: an event that
|
|
2205
|
+
* arrives while a reload for the same property is running asks for one more reload after it
|
|
2206
|
+
* instead of starting its own. Every caller's promise settles only after a reload that began
|
|
2207
|
+
* after its event, so no change is missed, but N events in a burst cost at most two reloads.
|
|
2208
|
+
*/
|
|
2209
|
+
reloadAfterExternalChange(config) {
|
|
2210
|
+
const running = this._externalChangeReloads.get(config.PropertyName);
|
|
2211
|
+
if (running) {
|
|
2212
|
+
running.Again = true;
|
|
2213
|
+
return running.Done;
|
|
2214
|
+
}
|
|
2215
|
+
const reload = { Again: false, Done: Promise.resolve() };
|
|
2216
|
+
this._externalChangeReloads.set(config.PropertyName, reload);
|
|
2217
|
+
reload.Done = this.runExternalChangeReloads(config, reload);
|
|
2218
|
+
return reload.Done;
|
|
2219
|
+
}
|
|
2220
|
+
/** Runs reloads for {@link reloadAfterExternalChange} until no further event asked for one. */
|
|
2221
|
+
async runExternalChangeReloads(config, reload) {
|
|
2222
|
+
try {
|
|
2223
|
+
do {
|
|
2224
|
+
reload.Again = false;
|
|
2225
|
+
// The reload replaces the property, so the derived state has to be rebuilt too —
|
|
2226
|
+
// and the reload must NOT emit on its own, or a subscriber wakes between the new
|
|
2227
|
+
// rows and the rebuild and reads derived state still pointing at the instances
|
|
2228
|
+
// this reload discarded.
|
|
2229
|
+
await this.LoadSingleConfig(config, this._contextUser, /*bypassCache*/ false, /*suppressEmit*/ true);
|
|
2230
|
+
await this.RebuildDerivedState(this._contextUser);
|
|
2231
|
+
this.emitPropertyChange(config.PropertyName);
|
|
2232
|
+
} while (reload.Again);
|
|
2233
|
+
}
|
|
2234
|
+
finally {
|
|
2235
|
+
this._externalChangeReloads.delete(config.PropertyName);
|
|
2236
|
+
}
|
|
1987
2237
|
}
|
|
1988
2238
|
/**
|
|
1989
2239
|
* True when `payloadRows` are the same row versions this config currently holds: the same
|
|
@@ -2231,11 +2481,26 @@ export class BaseEngine extends BaseSingleton {
|
|
|
2231
2481
|
// read-modify-write of this slot, and an unlocked replace would clobber it.
|
|
2232
2482
|
await cache.ReplaceRunViewResultLocked(this.configFingerprint(config), params, plainRows, maxUpdatedAt, plainRows.length, this.ProviderToUse);
|
|
2233
2483
|
}
|
|
2234
|
-
/**
|
|
2484
|
+
/**
|
|
2485
|
+
* The cache fingerprint of a config's slot: the one {@link RegisterCacheChangeCallbacks} listens
|
|
2486
|
+
* on, {@link syncLocalCacheForConfig} maintains and {@link storeConfigRows} writes.
|
|
2487
|
+
*
|
|
2488
|
+
* Built by the provider for this engine's user, with that user's row-level-security and
|
|
2489
|
+
* field-security segments, so it names the same slot the provider's own RunView read used. An
|
|
2490
|
+
* engine running as a restricted user holds only what that user may see; without the segments
|
|
2491
|
+
* its rows would be written into, and its callbacks registered on, the slot unrestricted
|
|
2492
|
+
* readers share.
|
|
2493
|
+
*/
|
|
2235
2494
|
configFingerprint(config) {
|
|
2495
|
+
const params = this.BuildRunViewParamsForConfig(config);
|
|
2236
2496
|
const provider = this.RunViewProviderToUse;
|
|
2237
|
-
|
|
2238
|
-
|
|
2497
|
+
if (provider instanceof ProviderBase) {
|
|
2498
|
+
return provider.ComputeRunViewCacheFingerprint(params, this._contextUser);
|
|
2499
|
+
}
|
|
2500
|
+
const connection = provider && 'InstanceConnectionString' in provider
|
|
2501
|
+
? provider.InstanceConnectionString
|
|
2502
|
+
: undefined;
|
|
2503
|
+
return LocalCacheManager.Instance.GenerateRunViewFingerprint(params, connection);
|
|
2239
2504
|
}
|
|
2240
2505
|
/**
|
|
2241
2506
|
* Snapshot of what this engine holds: per property, the row count, newest `__mj_UpdatedAt`,
|