@memberjunction/core 6.2.0-edge.5 → 6.2.0-edge.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generic/BaseEntitySaveQueue.d.ts +15 -0
- package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
- package/dist/generic/BaseEntitySaveQueue.js +21 -0
- package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
- 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/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 +171 -17
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +722 -117
- 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 +216 -21
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +969 -100
- 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/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 -5
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +381 -52
- 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/entityInfo.d.ts +234 -9
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +592 -77
- 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/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 +359 -0
- package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -0
- package/dist/generic/fieldSecuritySQLGate.js +902 -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/interfaces.d.ts +64 -2
- 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 +126 -14
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +348 -48
- 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/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 +408 -57
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +1051 -310
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/queryInfo.d.ts +13 -0
- package/dist/generic/queryInfo.d.ts.map +1 -1
- package/dist/generic/queryInfo.js +13 -0
- 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 +18 -0
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
- package/dist/generic/relatedRecordCollection.js +55 -7
- 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/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 +33 -6
- package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.js +119 -29
- 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/securityInfo.d.ts +11 -0
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +15 -0
- 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/transportError.d.ts +41 -0
- package/dist/generic/transportError.d.ts.map +1 -0
- package/dist/generic/transportError.js +167 -0
- package/dist/generic/transportError.js.map +1 -0
- package/dist/generic/util.d.ts +12 -0
- package/dist/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +52 -3
- package/dist/generic/util.js.map +1 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +102 -0
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +40 -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
|
@@ -14,7 +14,7 @@ import { BaseEngineRegistry } from "./baseEngineRegistry.js";
|
|
|
14
14
|
import { LocalCacheManager } from "./localCacheManager.js";
|
|
15
15
|
import { ProviderBase } from "./providerBase.js";
|
|
16
16
|
import { DatabaseProviderBase } from "./databaseProviderBase.js";
|
|
17
|
-
import { TransformSimpleObjectToEntityObject } from "./util.js";
|
|
17
|
+
import { TransformSimpleObjectToEntityObject, CanonicalPrimaryKeyValue } from "./util.js";
|
|
18
18
|
import { WellKnownUserSource } from "./wellKnownUserSource.js";
|
|
19
19
|
/**
|
|
20
20
|
* Property configuration for the BaseEngine class to automatically load/set properties on the class.
|
|
@@ -127,6 +127,21 @@ export class BaseEngine extends BaseSingleton {
|
|
|
127
127
|
});
|
|
128
128
|
return this._derivedStateRebuild;
|
|
129
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* The least time, in milliseconds, between the end of one reload from
|
|
132
|
+
* {@link reloadAfterExternalChange} and the start of the next for the same property. A change
|
|
133
|
+
* that arrives after this long since the last reload reloads at once; one that arrives sooner
|
|
134
|
+
* (including while a reload is running) waits out the rest of it, and every change that arrives
|
|
135
|
+
* meanwhile shares that one reload.
|
|
136
|
+
*
|
|
137
|
+
* Without it, a steady stream of changes (a server writing rows of one entity a few times a
|
|
138
|
+
* second) keeps a property reloading back to back. Each full reload costs time proportional to
|
|
139
|
+
* the table, so the process spends most of its time reloading and the cost of the stream grows
|
|
140
|
+
* with the square of its length. With it, a property reloads at most once per this delay plus
|
|
141
|
+
* one reload's duration, and its data is never more than that far behind the last change.
|
|
142
|
+
* 0 restores back-to-back reloads.
|
|
143
|
+
*/
|
|
144
|
+
static { this.ExternalChangeFollowUpDelayMs = 1000; }
|
|
130
145
|
/**
|
|
131
146
|
* Returns an Observable for a specific engine array property. Subscribers receive the
|
|
132
147
|
* current array immediately (BehaviorSubject semantics), then re-receive the same array
|
|
@@ -199,13 +214,33 @@ export class BaseEngine extends BaseSingleton {
|
|
|
199
214
|
*/
|
|
200
215
|
this._configRefreshGeneration = new Map();
|
|
201
216
|
/**
|
|
202
|
-
* Reloads started by {@link OnExternalCacheChange}
|
|
203
|
-
*
|
|
204
|
-
*
|
|
217
|
+
* Reloads started by {@link OnExternalCacheChange} and by the no-row fallback of
|
|
218
|
+
* {@link HandleRemoteInvalidateEvent}, keyed by config PropertyName. While one is running,
|
|
219
|
+
* further events for that property set `Again` instead of starting another, so a burst of
|
|
220
|
+
* events costs at most two reloads per property. See {@link reloadAfterExternalChange}.
|
|
205
221
|
*/
|
|
206
222
|
this._externalChangeReloads = new Map();
|
|
223
|
+
/**
|
|
224
|
+
* Per-property count of in-flight full refreshes (`LoadSingleEntityConfig` or full `'set'`
|
|
225
|
+
* cache payload materialization). When positive, incoming `row_delta` events defer to a
|
|
226
|
+
* coalesced full reload rather than racing an in-progress snapshot replacement.
|
|
227
|
+
*/
|
|
228
|
+
this._inFlightFullRefreshCount = new Map();
|
|
229
|
+
/**
|
|
230
|
+
* Per-property promise chain serializing `row_delta` applications so back-to-back row deltas
|
|
231
|
+
* for distinct rows apply in arrival order without dropping earlier mutations.
|
|
232
|
+
*/
|
|
233
|
+
this._rowDeltaQueues = new Map();
|
|
234
|
+
/** When the last reload from {@link reloadAfterExternalChange} ended, per config PropertyName (epoch ms). */
|
|
235
|
+
this._externalChangeReloadEndedAt = new Map();
|
|
207
236
|
this._entityEventDebounceTime = 1500; // Default debounce time in milliseconds (1.5 seconds)
|
|
208
237
|
this._eventRefreshRetryTimers = new Map();
|
|
238
|
+
/**
|
|
239
|
+
* Maps normalized entity names (`entityName.trim().toLowerCase()`) to dataset item properties
|
|
240
|
+
* loaded via `DatasetResultHandling === 'single_property'`, allowing in-place auto-refresh of
|
|
241
|
+
* dataset-backed engines (such as `TemplateEngineBase`) on entity save/delete events.
|
|
242
|
+
*/
|
|
243
|
+
this._datasetItemMappings = new Map();
|
|
209
244
|
/** Configs waiting for the cache manager to initialize before their callbacks can register. */
|
|
210
245
|
this._deferredCacheCallbackConfigs = null;
|
|
211
246
|
}
|
|
@@ -676,11 +711,16 @@ export class BaseEngine extends BaseSingleton {
|
|
|
676
711
|
const matchingConfigs = this._metadataConfigs.filter((config) => {
|
|
677
712
|
return config.AutoRefresh && config.EntityName && config.EntityName.trim().toLowerCase() === eName;
|
|
678
713
|
});
|
|
679
|
-
|
|
714
|
+
const matchingDatasetItems = (this._datasetItemMappings.get(eName) ?? []).filter(m => m.config.AutoRefresh !== false);
|
|
715
|
+
if (matchingConfigs.length === 0 && matchingDatasetItems.length === 0) {
|
|
680
716
|
return true;
|
|
681
717
|
}
|
|
682
|
-
// Check if ALL matching configs can use immediate mutation
|
|
683
|
-
|
|
718
|
+
// Check if ALL matching entity configs can use immediate mutation. A saved object
|
|
719
|
+
// that lacks columns (its saver could not read them) would put a narrowed row into
|
|
720
|
+
// an engine every user reads, so that save takes the full-refresh path instead,
|
|
721
|
+
// which reloads the row as this engine's own user.
|
|
722
|
+
const allCanUseImmediate = matchingConfigs.every(config => this.canUseImmediateMutation(config))
|
|
723
|
+
&& this.savedRowIsComplete(event);
|
|
684
724
|
if (allCanUseImmediate) {
|
|
685
725
|
const applyAll = async () => {
|
|
686
726
|
// mutation requires await because the entity must be cloned (with its
|
|
@@ -688,6 +728,9 @@ export class BaseEngine extends BaseSingleton {
|
|
|
688
728
|
for (const config of matchingConfigs) {
|
|
689
729
|
await this.applyImmediateMutation(config, event);
|
|
690
730
|
}
|
|
731
|
+
if (matchingDatasetItems.length > 0) {
|
|
732
|
+
await this.applyDatasetItemMutations(matchingDatasetItems, event);
|
|
733
|
+
}
|
|
691
734
|
};
|
|
692
735
|
// The entity raises save/delete as soon as its own write returns, while an
|
|
693
736
|
// enclosing transaction may still be open. Mutating the cache now would keep a
|
|
@@ -714,6 +757,21 @@ export class BaseEngine extends BaseSingleton {
|
|
|
714
757
|
return true;
|
|
715
758
|
}
|
|
716
759
|
else {
|
|
760
|
+
if (matchingDatasetItems.length > 0) {
|
|
761
|
+
const provider = event.baseEntity.ProviderToUse;
|
|
762
|
+
if (provider instanceof DatabaseProviderBase && provider.TransactionDepth > 0) {
|
|
763
|
+
const token = provider.CapturePostCommitToken();
|
|
764
|
+
const savedKey = event.type === 'save' ? event.baseEntity.PrimaryKey.ToString() : null;
|
|
765
|
+
provider.RunAfterCommit(async () => {
|
|
766
|
+
if (savedKey !== null && event.baseEntity.PrimaryKey.ToString() !== savedKey)
|
|
767
|
+
return;
|
|
768
|
+
await this.applyDatasetItemMutations(matchingDatasetItems, event);
|
|
769
|
+
}, `BaseEngine dataset cache mutation for ${eName}`, token);
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
await this.applyDatasetItemMutations(matchingDatasetItems, event);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
717
775
|
// At least one config requires full refresh, use debouncing
|
|
718
776
|
return this.DebounceIndividualBaseEntityEvent(event);
|
|
719
777
|
}
|
|
@@ -740,9 +798,7 @@ export class BaseEngine extends BaseSingleton {
|
|
|
740
798
|
if (!entityName) {
|
|
741
799
|
return true; // No entity name, nothing to do
|
|
742
800
|
}
|
|
743
|
-
const matchingConfigs = this.
|
|
744
|
-
return config.AutoRefresh && config.EntityName && config.EntityName.trim().toLowerCase() === entityName;
|
|
745
|
-
});
|
|
801
|
+
const matchingConfigs = this.AutoRefreshConfigsForEntity(entityName);
|
|
746
802
|
if (matchingConfigs.length === 0) {
|
|
747
803
|
return true;
|
|
748
804
|
}
|
|
@@ -765,22 +821,9 @@ export class BaseEngine extends BaseSingleton {
|
|
|
765
821
|
}
|
|
766
822
|
// Fall through to server fetch if direct delete failed
|
|
767
823
|
}
|
|
768
|
-
// Fallback: re-fetch from server (
|
|
769
|
-
// for the same reason as the local-event path: this fetch runs BECAUSE a
|
|
770
|
-
// (cross-server) write signaled the cache stale, so reading back through it would
|
|
771
|
-
// re-sync the pre-write snapshot.
|
|
772
|
-
let refreshCount = 0;
|
|
824
|
+
// Fallback: re-fetch from the server (no row, or it could not be applied).
|
|
773
825
|
for (const config of matchingConfigs) {
|
|
774
|
-
await this.
|
|
775
|
-
if (!this.configLoadedSuccessfully(config.PropertyName)) {
|
|
776
|
-
// Same one-shot hazard as the debounced path: the invalidation event is
|
|
777
|
-
// consumed, so a transient failure here needs a bounded retry too.
|
|
778
|
-
this.scheduleEventRefreshRetry(config, 1);
|
|
779
|
-
}
|
|
780
|
-
refreshCount++;
|
|
781
|
-
}
|
|
782
|
-
if (refreshCount > 0) {
|
|
783
|
-
await this.AdditionalLoading(this._contextUser);
|
|
826
|
+
await this.reloadAfterRemoteInvalidate(config);
|
|
784
827
|
}
|
|
785
828
|
return true;
|
|
786
829
|
}
|
|
@@ -789,6 +832,38 @@ export class BaseEngine extends BaseSingleton {
|
|
|
789
832
|
return false;
|
|
790
833
|
}
|
|
791
834
|
}
|
|
835
|
+
/**
|
|
836
|
+
* Reloads one config because a remote-invalidate event said it changed but carried no row to
|
|
837
|
+
* apply. A burst of such events shares reloads (see {@link reloadAfterExternalChange}), so a
|
|
838
|
+
* server that saves many rows of one entity costs this engine at most two reloads per burst
|
|
839
|
+
* instead of one per row, and the reload that runs last began after the last event arrived.
|
|
840
|
+
*
|
|
841
|
+
* The reload reads past the cache: it runs because a write elsewhere made the cached copy
|
|
842
|
+
* stale, and reading back through the cache would restore the pre-write snapshot. A transient
|
|
843
|
+
* failure gets the bounded retry, since the event that asked for this reload is consumed.
|
|
844
|
+
*/
|
|
845
|
+
async reloadAfterRemoteInvalidate(config) {
|
|
846
|
+
let succeeded;
|
|
847
|
+
try {
|
|
848
|
+
await this.reloadAfterExternalChange(config, /*bypassCache*/ true);
|
|
849
|
+
succeeded = this.configLoadedSuccessfully(config.PropertyName);
|
|
850
|
+
}
|
|
851
|
+
catch (e) {
|
|
852
|
+
LogError(e);
|
|
853
|
+
succeeded = false;
|
|
854
|
+
}
|
|
855
|
+
if (!succeeded) {
|
|
856
|
+
this.scheduleEventRefreshRetry(config, 1);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* The entity configs that refresh automatically when a record of `entityName` changes.
|
|
861
|
+
* Matching ignores case and surrounding whitespace.
|
|
862
|
+
*/
|
|
863
|
+
AutoRefreshConfigsForEntity(entityName) {
|
|
864
|
+
const name = entityName.trim().toLowerCase();
|
|
865
|
+
return this._metadataConfigs.filter((config) => config.AutoRefresh && config.EntityName && config.EntityName.trim().toLowerCase() === name);
|
|
866
|
+
}
|
|
792
867
|
/**
|
|
793
868
|
* Applies record data from a remote-invalidate event directly to the engine's in-memory arrays.
|
|
794
869
|
* Creates a BaseEntity instance, loads the JSON data into it, then updates the matching
|
|
@@ -967,7 +1042,9 @@ export class BaseEngine extends BaseSingleton {
|
|
|
967
1042
|
const needingWork = classified.filter(c => c.disposition.action === 'refresh');
|
|
968
1043
|
if (needingWork.length > 0) {
|
|
969
1044
|
// Check if we can use immediate array mutation instead of running a view
|
|
970
|
-
|
|
1045
|
+
// A save carrying a row with missing columns can't replace an engine row
|
|
1046
|
+
// in place (see savedRowIsComplete), so the whole window refreshes instead.
|
|
1047
|
+
if (this.canUseImmediateMutation(config) && needingWork.every(c => this.savedRowIsComplete(c.event))) {
|
|
971
1048
|
// Apply, in order, every event the array hasn't seen yet
|
|
972
1049
|
for (const c of needingWork) {
|
|
973
1050
|
await this.applyImmediateMutation(config, c.event);
|
|
@@ -1032,7 +1109,11 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1032
1109
|
classifyEventForConfig(config, event) {
|
|
1033
1110
|
if (event.type === 'save') {
|
|
1034
1111
|
if (event.saveSubType === 'update' || event.saveSubType === 'create') {
|
|
1035
|
-
|
|
1112
|
+
// When AlternateBaseView is set on the config, the SQL view applies a canonical
|
|
1113
|
+
// WHERE/JOIN predicate — an in-memory update can cause the record to fall out of
|
|
1114
|
+
// the alternate view (e.g. Status changed from 'Active' to 'Inactive'), so we
|
|
1115
|
+
// must refresh from the database rather than only notifying in place.
|
|
1116
|
+
if (!config.AlternateBaseView && this.isEntityAlreadyInArray(config, event.baseEntity)) {
|
|
1036
1117
|
return { action: 'notify', changeType: event.saveSubType === 'create' ? 'add' : 'update' };
|
|
1037
1118
|
}
|
|
1038
1119
|
}
|
|
@@ -1217,6 +1298,11 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1217
1298
|
if (config.Filter) {
|
|
1218
1299
|
return false;
|
|
1219
1300
|
}
|
|
1301
|
+
// An AlternateBaseView applies a canonical SQL WHERE/JOIN in the database view;
|
|
1302
|
+
// immediate in-memory mutations cannot evaluate that SQL predicate.
|
|
1303
|
+
if (config.AlternateBaseView) {
|
|
1304
|
+
return false;
|
|
1305
|
+
}
|
|
1220
1306
|
// If there's an OrderBy, we can't safely do immediate mutations because:
|
|
1221
1307
|
// - For creates: we'd need to insert at the correct position
|
|
1222
1308
|
// - For updates: the entity might need to move to a different position
|
|
@@ -1255,6 +1341,21 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1255
1341
|
// it means the subclass has overridden it
|
|
1256
1342
|
return instanceProto.AdditionalLoading !== baseProto.AdditionalLoading;
|
|
1257
1343
|
}
|
|
1344
|
+
/**
|
|
1345
|
+
* Whether a save event's entity carries every column, so it can replace an engine row in
|
|
1346
|
+
* place. Deletes and non-save events always qualify.
|
|
1347
|
+
*/
|
|
1348
|
+
savedRowIsComplete(event) {
|
|
1349
|
+
if (event.type !== 'save' || !event.baseEntity) {
|
|
1350
|
+
return true;
|
|
1351
|
+
}
|
|
1352
|
+
const entity = event.baseEntity;
|
|
1353
|
+
// Asked of the object directly, so no throwaway GetAll() row is built per event. An object
|
|
1354
|
+
// that is not a BaseEntity (a test double) is judged on its row, as before.
|
|
1355
|
+
return typeof entity.HoldsEveryField === 'function'
|
|
1356
|
+
? entity.HoldsEveryField()
|
|
1357
|
+
: LocalCacheManager.RowHasEveryField(entity.EntityInfo, entity.GetAll());
|
|
1358
|
+
}
|
|
1258
1359
|
/**
|
|
1259
1360
|
* Applies an immediate array mutation based on the entity event type.
|
|
1260
1361
|
* This is faster than running a full view refresh for simple add/update/delete operations.
|
|
@@ -1404,16 +1505,9 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1404
1505
|
if (cache.IsBatchingEntityEvents(entity?.ProviderToUse) || cache.WasTakenByEntityEventBatch(event)) {
|
|
1405
1506
|
return;
|
|
1406
1507
|
}
|
|
1407
|
-
//
|
|
1408
|
-
//
|
|
1409
|
-
const
|
|
1410
|
-
let connectionString;
|
|
1411
|
-
if (provider && 'InstanceConnectionString' in provider) {
|
|
1412
|
-
connectionString = provider.InstanceConnectionString;
|
|
1413
|
-
}
|
|
1414
|
-
// Use the shared builder so the fingerprint matches what LoadSingleEntityConfig
|
|
1415
|
-
// and RegisterCacheChangeCallbacks produce — prevents silent cache-slot mismatches
|
|
1416
|
-
const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(this.BuildRunViewParamsForConfig(config), connectionString);
|
|
1508
|
+
// The same builder as LoadSingleEntityConfig and RegisterCacheChangeCallbacks, so the
|
|
1509
|
+
// slot maintained here is the one this engine reads and listens on.
|
|
1510
|
+
const fingerprint = this.configFingerprint(config);
|
|
1417
1511
|
// Build CompositeKey from the entity's primary key fields
|
|
1418
1512
|
const key = entity.PrimaryKey;
|
|
1419
1513
|
if (!key || key.KeyValuePairs.length === 0 || key.KeyValuePairs.some(kv => kv.Value == null)) {
|
|
@@ -1435,6 +1529,12 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1435
1529
|
}
|
|
1436
1530
|
else {
|
|
1437
1531
|
const entityData = entity.GetAll();
|
|
1532
|
+
// A row-filtered or field-secured slot, or a saved row missing columns, cannot take the
|
|
1533
|
+
// saver's values in place: clear it and let the next read refill it.
|
|
1534
|
+
if (!LocalCacheManager.Instance.CanUpsertRowIntoSlot(fingerprint, entity.EntityInfo, entityData)) {
|
|
1535
|
+
await LocalCacheManager.Instance.InvalidateRunViewResult(fingerprint);
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1438
1538
|
await LocalCacheManager.Instance.UpsertSingleEntity(fingerprint, entityData, key, updatedAt);
|
|
1439
1539
|
}
|
|
1440
1540
|
}
|
|
@@ -1495,19 +1595,48 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1495
1595
|
*/
|
|
1496
1596
|
async LoadConfigs(configs, contextUser, bypassCache = false) {
|
|
1497
1597
|
this._metadataConfigs = configs.map(c => this.UpgradeObjectToConfig(c));
|
|
1498
|
-
// 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()
|
|
1499
1598
|
const entityConfigs = this._metadataConfigs.filter(c => c.Type === 'entity');
|
|
1500
|
-
|
|
1501
|
-
// All-or-nothing permission check
|
|
1502
|
-
//
|
|
1503
|
-
//
|
|
1504
|
-
|
|
1505
|
-
const entityConfigsToLoad =
|
|
1506
|
-
|
|
1599
|
+
this.WarnAboutFieldSecuredEntities(entityConfigs);
|
|
1600
|
+
// All-or-nothing permission check across all configs (both entity configs and
|
|
1601
|
+
// dataset configs that declare their constituent Entities). If the user lacks
|
|
1602
|
+
// read access on ANY configured entity, skip ALL configs for this engine.
|
|
1603
|
+
const configsToLoad = this.CheckPermissionsOrSkipAll(this._metadataConfigs, contextUser);
|
|
1604
|
+
const entityConfigsToLoad = configsToLoad.filter(c => c.Type === 'entity');
|
|
1605
|
+
const datasetConfigsToLoad = configsToLoad.filter(c => c.Type === 'dataset');
|
|
1606
|
+
await Promise.all([...datasetConfigsToLoad.map(c => this.LoadSingleDatasetConfig(c, contextUser, bypassCache)),
|
|
1507
1607
|
this.LoadMultipleEntityConfigs(entityConfigsToLoad, contextUser, bypassCache)]);
|
|
1508
1608
|
// Register cross-server cache change callbacks for entity configs
|
|
1509
1609
|
this.RegisterCacheChangeCallbacks(entityConfigs);
|
|
1510
1610
|
}
|
|
1611
|
+
/** Engine-class and entity pairs already warned about by {@link WarnAboutFieldSecuredEntities}. */
|
|
1612
|
+
static { this._fieldSecurityWarned = new Set(); }
|
|
1613
|
+
/**
|
|
1614
|
+
* On a server, says once per engine class and entity when an entity this engine caches has
|
|
1615
|
+
* field-level security (its own, or an IS-A ancestor's). Changes nothing about the load.
|
|
1616
|
+
*
|
|
1617
|
+
* A server-side engine loads as the system user (see {@link ResolveContextUser}), so its
|
|
1618
|
+
* cache holds every column, including ones some users are denied. That is correct for a
|
|
1619
|
+
* process-wide cache, and it makes every path that hands engine data to a person responsible
|
|
1620
|
+
* for projecting it to that person first.
|
|
1621
|
+
*/
|
|
1622
|
+
WarnAboutFieldSecuredEntities(entityConfigs) {
|
|
1623
|
+
const provider = this.ProviderToUse;
|
|
1624
|
+
if (provider?.ProviderType !== ProviderType.Database) {
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
for (const config of entityConfigs) {
|
|
1628
|
+
const entityInfo = config.EntityName ? provider.EntityByName(config.EntityName) : undefined;
|
|
1629
|
+
if (!entityInfo?.FieldLevelSecurityApplies) {
|
|
1630
|
+
continue;
|
|
1631
|
+
}
|
|
1632
|
+
const key = `${this.constructor.name}|${entityInfo.Name}`.toLowerCase();
|
|
1633
|
+
if (BaseEngine._fieldSecurityWarned.has(key)) {
|
|
1634
|
+
continue;
|
|
1635
|
+
}
|
|
1636
|
+
BaseEngine._fieldSecurityWarned.add(key);
|
|
1637
|
+
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.`);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1511
1640
|
/**
|
|
1512
1641
|
* All-or-nothing permission gate: checks `CanRead` on every entity config. If ANY
|
|
1513
1642
|
* entity is denied, ALL configs are skipped — the engine is marked permission-constrained
|
|
@@ -1529,16 +1658,14 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1529
1658
|
const missingEntities = this.findMissingEntities(configs);
|
|
1530
1659
|
if (missingEntities.length > 0) {
|
|
1531
1660
|
LogStatusEx({
|
|
1532
|
-
message: `${this.constructor.name}: Skipping ${configs.length}
|
|
1661
|
+
message: `${this.constructor.name}: Skipping ${configs.length} config(s) — entity metadata not installed for: ${missingEntities.join(', ')}`,
|
|
1533
1662
|
verboseOnly: true,
|
|
1534
1663
|
category: 'Cache',
|
|
1535
1664
|
});
|
|
1536
1665
|
for (const config of configs) {
|
|
1537
|
-
|
|
1538
|
-
this[config.PropertyName] = [];
|
|
1539
|
-
}
|
|
1666
|
+
this.initializeSkippedConfigProperty(config);
|
|
1540
1667
|
this._dataMap.set(config.PropertyName, {
|
|
1541
|
-
entityName: config.EntityName,
|
|
1668
|
+
...(config.Type === 'dataset' ? { datasetName: config.DatasetName } : { entityName: config.EntityName }),
|
|
1542
1669
|
data: [],
|
|
1543
1670
|
loadedSuccessfully: true,
|
|
1544
1671
|
});
|
|
@@ -1552,17 +1679,15 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1552
1679
|
return configs; // Can't check without a user — proceed with normal loading
|
|
1553
1680
|
const deniedEntities = this.findUnreadableEntities(configs, user);
|
|
1554
1681
|
if (deniedEntities.length > 0) {
|
|
1555
|
-
LogWarning(`${this.constructor.name}: Skipping ${configs.length}
|
|
1556
|
-
// Mark all
|
|
1682
|
+
LogWarning(`${this.constructor.name}: Skipping ${configs.length} config(s) — user ${user.Email} lacks read permission on: ${deniedEntities.join(', ')}`, 'Cache');
|
|
1683
|
+
// Mark all configs as successfully loaded with empty data.
|
|
1557
1684
|
// This is not a failure — it's expected behavior for limited-permission users.
|
|
1558
1685
|
// Marking as successful allows the engine to set _loaded = true and avoid
|
|
1559
1686
|
// endless retry loops from EnsureLoaded().
|
|
1560
1687
|
for (const config of configs) {
|
|
1561
|
-
|
|
1562
|
-
this[config.PropertyName] = [];
|
|
1563
|
-
}
|
|
1688
|
+
this.initializeSkippedConfigProperty(config);
|
|
1564
1689
|
this._dataMap.set(config.PropertyName, {
|
|
1565
|
-
entityName: config.EntityName,
|
|
1690
|
+
...(config.Type === 'dataset' ? { datasetName: config.DatasetName } : { entityName: config.EntityName }),
|
|
1566
1691
|
data: [],
|
|
1567
1692
|
loadedSuccessfully: true,
|
|
1568
1693
|
permissionDenied: true,
|
|
@@ -1570,10 +1695,42 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1570
1695
|
}
|
|
1571
1696
|
this._isPermissionConstrained = true;
|
|
1572
1697
|
this._deniedEntityNames = deniedEntities;
|
|
1573
|
-
return []; // Return empty — don't attempt any RunView calls
|
|
1698
|
+
return []; // Return empty — don't attempt any RunView or dataset calls
|
|
1574
1699
|
}
|
|
1575
1700
|
return configs;
|
|
1576
1701
|
}
|
|
1702
|
+
/**
|
|
1703
|
+
* Initializes the target property on `this` when a config is skipped before loading.
|
|
1704
|
+
* Entity configs receive `[]`; `single_property` dataset configs receive `{}` only when
|
|
1705
|
+
* the subclass has not already initialized the property to a default container.
|
|
1706
|
+
*/
|
|
1707
|
+
initializeSkippedConfigProperty(config) {
|
|
1708
|
+
if (config.AddToObject === false) {
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
if (config.Type === 'dataset') {
|
|
1712
|
+
if (config.DatasetResultHandling === 'single_property' &&
|
|
1713
|
+
this[config.PropertyName] == null) {
|
|
1714
|
+
this[config.PropertyName] = {};
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
else {
|
|
1718
|
+
this[config.PropertyName] = [];
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
/**
|
|
1722
|
+
* Returns the entity name(s) governed by `config`: `Entities` (or `EntityName`) for
|
|
1723
|
+
* dataset configs, and `EntityName` for entity configs.
|
|
1724
|
+
*/
|
|
1725
|
+
configEntityNames(config) {
|
|
1726
|
+
if (config.Type === 'dataset') {
|
|
1727
|
+
if (config.Entities && config.Entities.length > 0) {
|
|
1728
|
+
return config.Entities;
|
|
1729
|
+
}
|
|
1730
|
+
return config.EntityName ? [config.EntityName] : [];
|
|
1731
|
+
}
|
|
1732
|
+
return config.EntityName ? [config.EntityName] : [];
|
|
1733
|
+
}
|
|
1577
1734
|
/**
|
|
1578
1735
|
* Returns entity names among `configs` that do not exist in the loaded metadata provider.
|
|
1579
1736
|
* Only checks when the metadata provider actually has entities loaded (`md.Entities.length > 0`)
|
|
@@ -1586,10 +1743,10 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1586
1743
|
}
|
|
1587
1744
|
const missing = [];
|
|
1588
1745
|
for (const config of configs) {
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1746
|
+
for (const entityName of this.configEntityNames(config)) {
|
|
1747
|
+
if (!md.EntityByName(entityName) && !missing.includes(entityName)) {
|
|
1748
|
+
missing.push(entityName);
|
|
1749
|
+
}
|
|
1593
1750
|
}
|
|
1594
1751
|
}
|
|
1595
1752
|
return missing;
|
|
@@ -1599,14 +1756,14 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1599
1756
|
const md = this.ProviderToUse;
|
|
1600
1757
|
const denied = [];
|
|
1601
1758
|
for (const config of configs) {
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1759
|
+
for (const entityName of this.configEntityNames(config)) {
|
|
1760
|
+
const entityInfo = md.EntityByName(entityName);
|
|
1761
|
+
if (!entityInfo)
|
|
1762
|
+
continue; // Entity not in metadata — let RunView/dataset handle it
|
|
1763
|
+
const perms = entityInfo.GetUserPermisions(user);
|
|
1764
|
+
if ((!perms || !perms.CanRead) && !denied.includes(entityName)) {
|
|
1765
|
+
denied.push(entityName);
|
|
1766
|
+
}
|
|
1610
1767
|
}
|
|
1611
1768
|
}
|
|
1612
1769
|
return denied;
|
|
@@ -1659,14 +1816,8 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1659
1816
|
* IgnoreMaxRows, IncludeBinaryFields) are always consistent — preventing cache key mismatches that break
|
|
1660
1817
|
* cross-server invalidation via Redis pub/sub and local cache upsert/remove operations.
|
|
1661
1818
|
*
|
|
1662
|
-
*
|
|
1663
|
-
*
|
|
1664
|
-
* provider adds for the reading user. On a server that is correct: engines load as the system
|
|
1665
|
-
* user, whose RLS clause is empty, so the provider's fingerprint for the same read matches. An
|
|
1666
|
-
* engine loaded by an RLS-scoped user (possible on a client) would compute an unscoped
|
|
1667
|
-
* fingerprint for a slot the provider stored under a scoped one, so its cache callbacks and
|
|
1668
|
-
* `syncLocalCacheForConfig` would target a different slot. Pass the RLS clause here if such an
|
|
1669
|
-
* engine is ever needed.
|
|
1819
|
+
* The slot fingerprint for these params also carries the engine user's row-level-security
|
|
1820
|
+
* and field-security segments; see {@link configFingerprint}.
|
|
1670
1821
|
*/
|
|
1671
1822
|
BuildRunViewParamsForConfig(config, bypassCache = false) {
|
|
1672
1823
|
return {
|
|
@@ -1674,6 +1825,7 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1674
1825
|
ResultType: config.ResultType || this.EngineDefaultResultType,
|
|
1675
1826
|
ExtraFilter: config.Filter,
|
|
1676
1827
|
OrderBy: config.OrderBy,
|
|
1828
|
+
...(config.AlternateBaseView ? { AlternateBaseView: config.AlternateBaseView } : {}),
|
|
1677
1829
|
IgnoreMaxRows: true, // Engines always need ALL data — bypass entity-level UserViewMaxRows caps
|
|
1678
1830
|
_fromEngine: true, // Mark as engine-initiated to avoid false positive telemetry warnings
|
|
1679
1831
|
CacheLocal: config.CacheLocal,
|
|
@@ -1698,9 +1850,16 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1698
1850
|
// UserInfoEngine's per-user '_UserApplications') apply out of initiation-order and the
|
|
1699
1851
|
// cache ends up "one operation behind". See _configRefreshGeneration.
|
|
1700
1852
|
const generation = this.beginConfigRefresh(config.PropertyName);
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1853
|
+
this.incrementInFlightFullRefresh(config.PropertyName);
|
|
1854
|
+
let result;
|
|
1855
|
+
try {
|
|
1856
|
+
const p = this.RunViewProviderToUse;
|
|
1857
|
+
const rv = new RunView(p);
|
|
1858
|
+
result = await rv.RunView(this.BuildRunViewParamsForConfig(config, bypassCache), contextUser);
|
|
1859
|
+
}
|
|
1860
|
+
finally {
|
|
1861
|
+
this.decrementInFlightFullRefresh(config.PropertyName);
|
|
1862
|
+
}
|
|
1704
1863
|
// A newer full refresh superseded us while we awaited — drop this (staler) snapshot
|
|
1705
1864
|
// rather than clobber the newer one. The newer refresh owns the assignment, the
|
|
1706
1865
|
// observer notification, and (on failure) the retry, so bailing here leaves no
|
|
@@ -1805,6 +1964,35 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1805
1964
|
this._dataMap.set(config.PropertyName, { entityName: config.EntityName, data: [], loadedSuccessfully: true, readDenied: true });
|
|
1806
1965
|
this.NotifyDataChange(config, []);
|
|
1807
1966
|
}
|
|
1967
|
+
/**
|
|
1968
|
+
* Returns true when a failed dataset load was caused by the current user lacking Read
|
|
1969
|
+
* permission on one of the dataset's constituent entities — either per local metadata
|
|
1970
|
+
* (`config.Entities` / `config.EntityName`) or per the server's dataset status message
|
|
1971
|
+
* (`does not have read permissions on ...`).
|
|
1972
|
+
*/
|
|
1973
|
+
IsDatasetReadDenied(config, result, contextUser) {
|
|
1974
|
+
for (const entityName of this.configEntityNames(config)) {
|
|
1975
|
+
if (!this.ContextUserCanReadConfigEntity(entityName, contextUser)) {
|
|
1976
|
+
return true;
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
return typeof result.Status === 'string' && /does not have read permissions? on/i.test(result.Status);
|
|
1980
|
+
}
|
|
1981
|
+
/**
|
|
1982
|
+
* Records a dataset config as successfully loaded in an empty state after a permanent
|
|
1983
|
+
* permission denial, so `BaseEngine.Load` marks the engine loaded and does not loop on
|
|
1984
|
+
* `EnsureLoaded()` / `Config()`.
|
|
1985
|
+
*/
|
|
1986
|
+
MarkDatasetConfigEmptyLoaded(config) {
|
|
1987
|
+
this.initializeSkippedConfigProperty(config);
|
|
1988
|
+
this._dataMap.set(config.PropertyName, {
|
|
1989
|
+
datasetName: config.DatasetName,
|
|
1990
|
+
data: [],
|
|
1991
|
+
loadedSuccessfully: true,
|
|
1992
|
+
readDenied: true,
|
|
1993
|
+
});
|
|
1994
|
+
this.NotifyDataChange(config, []);
|
|
1995
|
+
}
|
|
1808
1996
|
/**
|
|
1809
1997
|
* Handles the process of loading multiple entity configs in a single network call via RunViews()
|
|
1810
1998
|
* @param configs
|
|
@@ -1813,13 +2001,33 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1813
2001
|
*/
|
|
1814
2002
|
async LoadMultipleEntityConfigs(configs, contextUser, bypassCache = false) {
|
|
1815
2003
|
if (configs && configs.length > 0) {
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
2004
|
+
// Same protocol as LoadSingleEntityConfig, per property: claim a refresh generation and
|
|
2005
|
+
// mark a full refresh in flight BEFORE the awaited RunViews. Without it a `row_delta`
|
|
2006
|
+
// that arrives mid-flight is applied to the held array and then overwritten by this
|
|
2007
|
+
// (pre-delta) snapshot when the batch lands; with it the delta path defers to a
|
|
2008
|
+
// coalesced reload instead, and a newer per-property refresh supersedes our slot.
|
|
2009
|
+
const generations = configs.map(c => this.beginConfigRefresh(c.PropertyName));
|
|
2010
|
+
for (const c of configs) {
|
|
2011
|
+
this.incrementInFlightFullRefresh(c.PropertyName);
|
|
2012
|
+
}
|
|
2013
|
+
let results;
|
|
2014
|
+
try {
|
|
2015
|
+
const p = this.RunViewProviderToUse;
|
|
2016
|
+
const rv = new RunView(p);
|
|
2017
|
+
const viewConfigs = configs.map(c => this.BuildRunViewParamsForConfig(c, bypassCache));
|
|
2018
|
+
results = await rv.RunViews(viewConfigs, contextUser);
|
|
2019
|
+
}
|
|
2020
|
+
finally {
|
|
2021
|
+
for (const c of configs) {
|
|
2022
|
+
this.decrementInFlightFullRefresh(c.PropertyName);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
1820
2025
|
// Process results and record entity loads for redundancy detection
|
|
1821
2026
|
const entityNames = [];
|
|
1822
2027
|
for (let i = 0; i < configs.length; i++) {
|
|
2028
|
+
if (!this.isLatestConfigRefresh(configs[i].PropertyName, generations[i])) {
|
|
2029
|
+
continue; // a newer refresh of this property owns the assignment
|
|
2030
|
+
}
|
|
1823
2031
|
this.HandleSingleViewResult(configs[i], results[i], contextUser);
|
|
1824
2032
|
this.emitPropertyChange(configs[i].PropertyName);
|
|
1825
2033
|
if (configs[i].EntityName) {
|
|
@@ -1834,6 +2042,58 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1834
2042
|
}
|
|
1835
2043
|
}
|
|
1836
2044
|
}
|
|
2045
|
+
/**
|
|
2046
|
+
* Applies an immediate in-place mutation to dataset item arrays when a constituent entity is saved or deleted,
|
|
2047
|
+
* then invokes {@link AdditionalLoading} so derived associations (e.g. Template Content / Params) stay current.
|
|
2048
|
+
*/
|
|
2049
|
+
async applyDatasetItemMutations(mappings, event) {
|
|
2050
|
+
const entity = event.baseEntity;
|
|
2051
|
+
let mutated = false;
|
|
2052
|
+
const notifiedProperties = new Set();
|
|
2053
|
+
for (const mapping of mappings) {
|
|
2054
|
+
const container = this[mapping.config.PropertyName];
|
|
2055
|
+
const currentData = container?.[mapping.itemCode];
|
|
2056
|
+
if (!Array.isArray(currentData)) {
|
|
2057
|
+
continue;
|
|
2058
|
+
}
|
|
2059
|
+
if (event.type === 'save') {
|
|
2060
|
+
const provider = this.ProviderToUse;
|
|
2061
|
+
if (!provider)
|
|
2062
|
+
continue;
|
|
2063
|
+
const cached = await provider.GetEntityObject(mapping.entityName, this._contextUser);
|
|
2064
|
+
if (!cached)
|
|
2065
|
+
continue;
|
|
2066
|
+
await cached.LoadFromData(entity.GetAll());
|
|
2067
|
+
const existsByRef = currentData.indexOf(entity) >= 0;
|
|
2068
|
+
if (!existsByRef) {
|
|
2069
|
+
const index = this.findEntityIndexByPrimaryKeys(currentData, entity);
|
|
2070
|
+
if (index >= 0) {
|
|
2071
|
+
currentData[index] = cached;
|
|
2072
|
+
}
|
|
2073
|
+
else {
|
|
2074
|
+
currentData.push(cached);
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
mutated = true;
|
|
2078
|
+
notifiedProperties.add(mapping.config.PropertyName);
|
|
2079
|
+
}
|
|
2080
|
+
else if (event.type === 'delete') {
|
|
2081
|
+
const oldValues = event.payload?.OldValues;
|
|
2082
|
+
const index = this.indexOfEntityByRefOrKey(currentData, entity, oldValues);
|
|
2083
|
+
if (index >= 0) {
|
|
2084
|
+
currentData.splice(index, 1);
|
|
2085
|
+
mutated = true;
|
|
2086
|
+
notifiedProperties.add(mapping.config.PropertyName);
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
if (mutated) {
|
|
2091
|
+
await this.AdditionalLoading(this._contextUser);
|
|
2092
|
+
for (const propName of notifiedProperties) {
|
|
2093
|
+
this.emitPropertyChange(propName);
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
1837
2097
|
/**
|
|
1838
2098
|
* Handles the process of loading a single config of type 'dataset'.
|
|
1839
2099
|
* @param config
|
|
@@ -1850,10 +2110,12 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1850
2110
|
let result;
|
|
1851
2111
|
if (bypassCache) {
|
|
1852
2112
|
result = await p.GetDatasetByName(config.DatasetName, config.DatasetItemFilters, contextUser, undefined, true);
|
|
1853
|
-
|
|
2113
|
+
if (result?.Success) {
|
|
2114
|
+
await p.CacheDataset(config.DatasetName, config.DatasetItemFilters, result);
|
|
2115
|
+
}
|
|
1854
2116
|
}
|
|
1855
2117
|
else {
|
|
1856
|
-
result = await p.GetAndCacheDatasetByName(config.DatasetName, config.DatasetItemFilters);
|
|
2118
|
+
result = await p.GetAndCacheDatasetByName(config.DatasetName, config.DatasetItemFilters, contextUser);
|
|
1857
2119
|
}
|
|
1858
2120
|
if (!result) {
|
|
1859
2121
|
const errorMsg = `GetAndCacheDatasetByName("${config.DatasetName}") returned undefined/null — provider: ${p?.constructor?.name}`;
|
|
@@ -1867,6 +2129,11 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1867
2129
|
return;
|
|
1868
2130
|
}
|
|
1869
2131
|
if (!result.Success) {
|
|
2132
|
+
if (this.IsDatasetReadDenied(config, result, contextUser)) {
|
|
2133
|
+
this.MarkDatasetConfigEmptyLoaded(config);
|
|
2134
|
+
LogWarning(`BaseEngine: Dataset ${config.DatasetName} not readable by current role — loaded empty (restricted-role degradation).`, 'Cache');
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
1870
2137
|
const errorMsg = result.Status || `Dataset "${config.DatasetName}" load returned Success=false`;
|
|
1871
2138
|
LogError(`BaseEngine: Failed to load dataset ${config.DatasetName} into ${config.PropertyName}: ${errorMsg}`);
|
|
1872
2139
|
this._dataMap.set(config.PropertyName, {
|
|
@@ -1880,18 +2147,31 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1880
2147
|
if (result.Success) {
|
|
1881
2148
|
if (config.AddToObject !== false) {
|
|
1882
2149
|
if (config.DatasetResultHandling === 'single_property') {
|
|
2150
|
+
// Clear previous dataset item mappings for this property before re-indexing
|
|
2151
|
+
for (const [key, list] of this._datasetItemMappings.entries()) {
|
|
2152
|
+
const filtered = list.filter(m => m.config.PropertyName !== config.PropertyName);
|
|
2153
|
+
if (filtered.length === 0) {
|
|
2154
|
+
this._datasetItemMappings.delete(key);
|
|
2155
|
+
}
|
|
2156
|
+
else if (filtered.length !== list.length) {
|
|
2157
|
+
this._datasetItemMappings.set(key, filtered);
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
1883
2160
|
const singleObject = {};
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
//adding them to the singleObject
|
|
1887
|
-
const entities = [];
|
|
1888
|
-
for (const entityData of item.Results) {
|
|
2161
|
+
await Promise.all(result.Results.map(async (item) => {
|
|
2162
|
+
const entities = await Promise.all(item.Results.map(async (entityData) => {
|
|
1889
2163
|
const entity = await p.GetEntityObject(item.EntityName, contextUser);
|
|
1890
2164
|
await entity.LoadFromData(entityData);
|
|
1891
|
-
|
|
1892
|
-
}
|
|
2165
|
+
return entity;
|
|
2166
|
+
}));
|
|
1893
2167
|
singleObject[item.Code] = entities;
|
|
1894
|
-
|
|
2168
|
+
if (item.EntityName && item.Code) {
|
|
2169
|
+
const normName = item.EntityName.trim().toLowerCase();
|
|
2170
|
+
const existing = this._datasetItemMappings.get(normName) ?? [];
|
|
2171
|
+
existing.push({ config, itemCode: item.Code, entityName: item.EntityName });
|
|
2172
|
+
this._datasetItemMappings.set(normName, existing);
|
|
2173
|
+
}
|
|
2174
|
+
}));
|
|
1895
2175
|
this[config.PropertyName] = singleObject;
|
|
1896
2176
|
}
|
|
1897
2177
|
else {
|
|
@@ -1995,14 +2275,21 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1995
2275
|
// overlapping cache events (or an event racing a full reload) can resolve out
|
|
1996
2276
|
// of order and the stale result would be the one that assigns last.
|
|
1997
2277
|
const generation = this.beginConfigRefresh(config.PropertyName);
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2278
|
+
this.incrementInFlightFullRefresh(config.PropertyName);
|
|
2279
|
+
let rows = null;
|
|
2280
|
+
try {
|
|
2281
|
+
// A burst of payloads for one slot usually arrives together. Let the rest of the
|
|
2282
|
+
// burst claim its generations first, so only the newest payload is materialized
|
|
2283
|
+
// instead of every one of them (receive side).
|
|
2284
|
+
await yieldToEventLoop();
|
|
2285
|
+
if (!this.isLatestConfigRefresh(config.PropertyName, generation)) {
|
|
2286
|
+
return;
|
|
2287
|
+
}
|
|
2288
|
+
rows = await this.materializeCacheEventRows(config, parsed.results);
|
|
2289
|
+
}
|
|
2290
|
+
finally {
|
|
2291
|
+
this.decrementInFlightFullRefresh(config.PropertyName);
|
|
2004
2292
|
}
|
|
2005
|
-
const rows = await this.materializeCacheEventRows(config, parsed.results);
|
|
2006
2293
|
if (!this.isLatestConfigRefresh(config.PropertyName, generation)) {
|
|
2007
2294
|
return; // superseded while materializing — the newer refresh owns the property
|
|
2008
2295
|
}
|
|
@@ -2042,8 +2329,270 @@ export class BaseEngine extends BaseSingleton {
|
|
|
2042
2329
|
LogWarning(`BaseEngine.OnExternalCacheChange: payload for '${config.PropertyName}' could not be applied (${e instanceof Error ? e.message : String(e)}) — falling back to full reload`, 'Cache');
|
|
2043
2330
|
}
|
|
2044
2331
|
}
|
|
2332
|
+
else if (event.Data && event.Action === 'row_delta') {
|
|
2333
|
+
const outcome = await this.applyExternalRowDelta(config, event.Data);
|
|
2334
|
+
if (outcome === 'applied' || outcome === 'superseded') {
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2045
2338
|
await this.reloadAfterExternalChange(config);
|
|
2046
2339
|
}
|
|
2340
|
+
incrementInFlightFullRefresh(propertyName) {
|
|
2341
|
+
this._inFlightFullRefreshCount.set(propertyName, (this._inFlightFullRefreshCount.get(propertyName) ?? 0) + 1);
|
|
2342
|
+
}
|
|
2343
|
+
decrementInFlightFullRefresh(propertyName) {
|
|
2344
|
+
const next = (this._inFlightFullRefreshCount.get(propertyName) ?? 1) - 1;
|
|
2345
|
+
if (next <= 0) {
|
|
2346
|
+
this._inFlightFullRefreshCount.delete(propertyName);
|
|
2347
|
+
}
|
|
2348
|
+
else {
|
|
2349
|
+
this._inFlightFullRefreshCount.set(propertyName, next);
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
/**
|
|
2353
|
+
* Applies a compact `row_delta` pub/sub event in $O(\Delta)$ without re-materializing
|
|
2354
|
+
* untouched sibling rows. Falls back to `'reload'` whenever schema hash, ordering, or
|
|
2355
|
+
* row-count invariants indicate the engine cannot prove local delta equivalence.
|
|
2356
|
+
*/
|
|
2357
|
+
async applyExternalRowDelta(config, rawDeltaJson) {
|
|
2358
|
+
let delta;
|
|
2359
|
+
try {
|
|
2360
|
+
delta = JSON.parse(rawDeltaJson);
|
|
2361
|
+
}
|
|
2362
|
+
catch (e) {
|
|
2363
|
+
LogWarning(`BaseEngine.OnExternalCacheChange: row_delta payload for '${config.PropertyName}' could not be parsed (${e instanceof Error ? e.message : String(e)}) — falling back to reload`, 'Cache');
|
|
2364
|
+
return 'reload';
|
|
2365
|
+
}
|
|
2366
|
+
if (!delta ||
|
|
2367
|
+
!Array.isArray(delta.upsertedRows) ||
|
|
2368
|
+
!Array.isArray(delta.deletedPrimaryKeys) ||
|
|
2369
|
+
!Array.isArray(delta.primaryKeyFields) ||
|
|
2370
|
+
delta.primaryKeyFields.length === 0 ||
|
|
2371
|
+
typeof delta.rowCount !== 'number') {
|
|
2372
|
+
return 'reload';
|
|
2373
|
+
}
|
|
2374
|
+
if (this.payloadSchemaDiffers(config, delta.schemaHash)) {
|
|
2375
|
+
LogStatusEx({ message: `BaseEngine.OnExternalCacheChange: row_delta for '${config.PropertyName}' was written under a different schema — reloading instead`, verboseOnly: true });
|
|
2376
|
+
return 'reload';
|
|
2377
|
+
}
|
|
2378
|
+
// Ordered configs rely on database collation ordering; reload from the warm Redis slot (0 SQL).
|
|
2379
|
+
if (config.OrderBy && delta.upsertedRows.length > 0) {
|
|
2380
|
+
return 'reload';
|
|
2381
|
+
}
|
|
2382
|
+
let stepOutcome = 'reload';
|
|
2383
|
+
const priorQueue = this._rowDeltaQueues.get(config.PropertyName) ?? Promise.resolve();
|
|
2384
|
+
const currentStep = priorQueue.then(async () => {
|
|
2385
|
+
try {
|
|
2386
|
+
stepOutcome = await this.applyExternalRowDeltaSerialized(config, delta);
|
|
2387
|
+
}
|
|
2388
|
+
catch (e) {
|
|
2389
|
+
LogWarning(`BaseEngine.OnExternalCacheChange: row_delta application for '${config.PropertyName}' failed (${e instanceof Error ? e.message : String(e)}) — falling back to reload`, 'Cache');
|
|
2390
|
+
stepOutcome = 'reload';
|
|
2391
|
+
}
|
|
2392
|
+
});
|
|
2393
|
+
const settled = currentStep.catch(() => undefined);
|
|
2394
|
+
this._rowDeltaQueues.set(config.PropertyName, settled);
|
|
2395
|
+
await currentStep;
|
|
2396
|
+
if (this._rowDeltaQueues.get(config.PropertyName) === settled) {
|
|
2397
|
+
this._rowDeltaQueues.delete(config.PropertyName);
|
|
2398
|
+
}
|
|
2399
|
+
return stepOutcome;
|
|
2400
|
+
}
|
|
2401
|
+
async applyExternalRowDeltaSerialized(config, delta) {
|
|
2402
|
+
if ((this._inFlightFullRefreshCount.get(config.PropertyName) ?? 0) > 0) {
|
|
2403
|
+
return 'reload';
|
|
2404
|
+
}
|
|
2405
|
+
const heldRows = this.getHeldRowsForDelta(config);
|
|
2406
|
+
if (!heldRows) {
|
|
2407
|
+
return 'reload';
|
|
2408
|
+
}
|
|
2409
|
+
const entityInfo = config.EntityName ? this.ProviderToUse?.EntityByName?.(config.EntityName) : undefined;
|
|
2410
|
+
const pkFields = delta.primaryKeyFields;
|
|
2411
|
+
const isUuidPk = pkFields.map(fieldName => Boolean(entityInfo?.FieldByName(fieldName)?.IsUniqueIdentifier));
|
|
2412
|
+
const heldIndexByKey = this.indexHeldRowsForDelta(heldRows, pkFields, isUuidPk);
|
|
2413
|
+
if (!heldIndexByKey) {
|
|
2414
|
+
return 'reload';
|
|
2415
|
+
}
|
|
2416
|
+
const keyPlan = this.planRowDeltaKeys(delta, heldRows.length, heldIndexByKey, pkFields, isUuidPk);
|
|
2417
|
+
if (!keyPlan) {
|
|
2418
|
+
return 'reload';
|
|
2419
|
+
}
|
|
2420
|
+
// Echo / duplicate delta suppression: when no rows are added or removed and every
|
|
2421
|
+
// upserted row's __mj_UpdatedAt matches the held row, skip materialization & emit churn.
|
|
2422
|
+
if (keyPlan.matchedDeleteKeys.size === 0 &&
|
|
2423
|
+
keyPlan.netNewInserts === 0 &&
|
|
2424
|
+
this.upsertsMatchHeldTimestamps(heldRows, heldIndexByKey, delta.upsertedRows, keyPlan.upsertKeys, entityInfo)) {
|
|
2425
|
+
LogStatusEx({ message: `BaseEngine.OnExternalCacheChange: '${config.PropertyName}' already holds row_delta versions — skipped`, verboseOnly: true });
|
|
2426
|
+
return 'applied';
|
|
2427
|
+
}
|
|
2428
|
+
const generationBefore = this._configRefreshGeneration.get(config.PropertyName) ?? 0;
|
|
2429
|
+
const materializedUpserts = delta.upsertedRows.length > 0
|
|
2430
|
+
? await this.materializeCacheEventRows(config, delta.upsertedRows)
|
|
2431
|
+
: [];
|
|
2432
|
+
if (materializedUpserts === null) {
|
|
2433
|
+
return 'reload';
|
|
2434
|
+
}
|
|
2435
|
+
if ((this._configRefreshGeneration.get(config.PropertyName) ?? 0) !== generationBefore ||
|
|
2436
|
+
(this._inFlightFullRefreshCount.get(config.PropertyName) ?? 0) > 0) {
|
|
2437
|
+
return 'superseded';
|
|
2438
|
+
}
|
|
2439
|
+
const nextRows = this.mergeMaterializedDeltaRows(heldRows, pkFields, isUuidPk, keyPlan.matchedDeleteKeys, keyPlan.upsertKeys, materializedUpserts);
|
|
2440
|
+
this.HandleSingleViewResult(config, {
|
|
2441
|
+
Success: true,
|
|
2442
|
+
Results: nextRows,
|
|
2443
|
+
RowCount: nextRows.length,
|
|
2444
|
+
TotalRowCount: delta.totalRowCount ?? nextRows.length,
|
|
2445
|
+
ExecutionTime: 0,
|
|
2446
|
+
ErrorMessage: '',
|
|
2447
|
+
UserViewRunID: '',
|
|
2448
|
+
});
|
|
2449
|
+
await this.RebuildDerivedState(this._contextUser);
|
|
2450
|
+
this.emitPropertyChange(config.PropertyName);
|
|
2451
|
+
return 'applied';
|
|
2452
|
+
}
|
|
2453
|
+
indexHeldRowsForDelta(heldRows, pkFields, isUuidPk) {
|
|
2454
|
+
const heldIndexByKey = new Map();
|
|
2455
|
+
for (let i = 0; i < heldRows.length; i++) {
|
|
2456
|
+
const key = this.canonicalHeldRowPkKey(heldRows[i], pkFields, isUuidPk);
|
|
2457
|
+
if (key === null) {
|
|
2458
|
+
return null;
|
|
2459
|
+
}
|
|
2460
|
+
heldIndexByKey.set(key, i);
|
|
2461
|
+
}
|
|
2462
|
+
return heldIndexByKey.size === heldRows.length ? heldIndexByKey : null;
|
|
2463
|
+
}
|
|
2464
|
+
planRowDeltaKeys(delta, heldRowCount, heldIndexByKey, pkFields, isUuidPk) {
|
|
2465
|
+
const matchedDeleteKeys = new Set();
|
|
2466
|
+
for (const delPk of delta.deletedPrimaryKeys) {
|
|
2467
|
+
const key = this.canonicalPlainRecordPkKey(delPk, pkFields, isUuidPk);
|
|
2468
|
+
if (key === null) {
|
|
2469
|
+
return null;
|
|
2470
|
+
}
|
|
2471
|
+
if (heldIndexByKey.has(key)) {
|
|
2472
|
+
matchedDeleteKeys.add(key);
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
const upsertKeys = [];
|
|
2476
|
+
const seenUpsertKeys = new Set();
|
|
2477
|
+
let netNewInserts = 0;
|
|
2478
|
+
for (const upRow of delta.upsertedRows) {
|
|
2479
|
+
const key = this.canonicalPlainRecordPkKey(upRow, pkFields, isUuidPk);
|
|
2480
|
+
if (key === null || seenUpsertKeys.has(key)) {
|
|
2481
|
+
return null;
|
|
2482
|
+
}
|
|
2483
|
+
seenUpsertKeys.add(key);
|
|
2484
|
+
upsertKeys.push(key);
|
|
2485
|
+
if (!heldIndexByKey.has(key) || matchedDeleteKeys.has(key)) {
|
|
2486
|
+
netNewInserts++;
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
// Strict cardinality verification: detects uninitialized state, missed pub/sub messages,
|
|
2490
|
+
// or filtered-slot membership divergence and falls back to reading the warm cache slot.
|
|
2491
|
+
const expectedNextLength = heldRowCount - matchedDeleteKeys.size + netNewInserts;
|
|
2492
|
+
if (expectedNextLength !== delta.rowCount) {
|
|
2493
|
+
return null;
|
|
2494
|
+
}
|
|
2495
|
+
return { matchedDeleteKeys, upsertKeys, netNewInserts };
|
|
2496
|
+
}
|
|
2497
|
+
mergeMaterializedDeltaRows(heldRows, pkFields, isUuidPk, matchedDeleteKeys, upsertKeys, materializedUpserts) {
|
|
2498
|
+
const upsertByKey = new Map();
|
|
2499
|
+
for (let i = 0; i < upsertKeys.length; i++) {
|
|
2500
|
+
upsertByKey.set(upsertKeys[i], materializedUpserts[i]);
|
|
2501
|
+
}
|
|
2502
|
+
const consumedUpsertKeys = new Set();
|
|
2503
|
+
const nextRows = [];
|
|
2504
|
+
for (let i = 0; i < heldRows.length; i++) {
|
|
2505
|
+
const existingRow = heldRows[i];
|
|
2506
|
+
const key = this.canonicalHeldRowPkKey(existingRow, pkFields, isUuidPk);
|
|
2507
|
+
if (upsertByKey.has(key)) {
|
|
2508
|
+
nextRows.push(upsertByKey.get(key));
|
|
2509
|
+
consumedUpsertKeys.add(key);
|
|
2510
|
+
}
|
|
2511
|
+
else if (!matchedDeleteKeys.has(key)) {
|
|
2512
|
+
nextRows.push(existingRow);
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
for (let i = 0; i < upsertKeys.length; i++) {
|
|
2516
|
+
const key = upsertKeys[i];
|
|
2517
|
+
if (!consumedUpsertKeys.has(key)) {
|
|
2518
|
+
nextRows.push(upsertByKey.get(key));
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
return nextRows;
|
|
2522
|
+
}
|
|
2523
|
+
getHeldRowsForDelta(config) {
|
|
2524
|
+
const entry = this._dataMap.get(config.PropertyName);
|
|
2525
|
+
if (entry && entry.loadedSuccessfully === false) {
|
|
2526
|
+
return null;
|
|
2527
|
+
}
|
|
2528
|
+
if (entry?.loadedSuccessfully && Array.isArray(entry.data)) {
|
|
2529
|
+
return entry.data;
|
|
2530
|
+
}
|
|
2531
|
+
const propVal = Reflect.get(this, config.PropertyName);
|
|
2532
|
+
if (Array.isArray(propVal) && (propVal.length > 0 || Boolean(entry))) {
|
|
2533
|
+
return propVal;
|
|
2534
|
+
}
|
|
2535
|
+
return null;
|
|
2536
|
+
}
|
|
2537
|
+
readHeldRowField(row, fieldName) {
|
|
2538
|
+
if (row instanceof BaseEntity) {
|
|
2539
|
+
return row.Get(fieldName);
|
|
2540
|
+
}
|
|
2541
|
+
if (fieldName in row) {
|
|
2542
|
+
return row[fieldName];
|
|
2543
|
+
}
|
|
2544
|
+
const nestedData = row['data'];
|
|
2545
|
+
if (typeof nestedData === 'object' && nestedData !== null && fieldName in nestedData) {
|
|
2546
|
+
return nestedData[fieldName];
|
|
2547
|
+
}
|
|
2548
|
+
return undefined;
|
|
2549
|
+
}
|
|
2550
|
+
canonicalHeldRowPkKey(row, pkFields, isUuidPk) {
|
|
2551
|
+
const parts = [];
|
|
2552
|
+
for (let i = 0; i < pkFields.length; i++) {
|
|
2553
|
+
const val = this.readHeldRowField(row, pkFields[i]);
|
|
2554
|
+
if (val === null || val === undefined) {
|
|
2555
|
+
return null;
|
|
2556
|
+
}
|
|
2557
|
+
const str = String(val);
|
|
2558
|
+
parts.push(CanonicalPrimaryKeyValue(str, isUuidPk[i]));
|
|
2559
|
+
}
|
|
2560
|
+
return parts.join('\u001f');
|
|
2561
|
+
}
|
|
2562
|
+
canonicalPlainRecordPkKey(record, pkFields, isUuidPk) {
|
|
2563
|
+
const parts = [];
|
|
2564
|
+
for (let i = 0; i < pkFields.length; i++) {
|
|
2565
|
+
const val = record[pkFields[i]];
|
|
2566
|
+
if (val === null || val === undefined) {
|
|
2567
|
+
return null;
|
|
2568
|
+
}
|
|
2569
|
+
const str = String(val);
|
|
2570
|
+
parts.push(CanonicalPrimaryKeyValue(str, isUuidPk[i]));
|
|
2571
|
+
}
|
|
2572
|
+
return parts.join('\u001f');
|
|
2573
|
+
}
|
|
2574
|
+
upsertsMatchHeldTimestamps(heldRows, heldIndexByKey, upsertedRows, upsertKeys, entityInfo) {
|
|
2575
|
+
if (!entityInfo || !entityInfo.Fields.some(f => f.IsUpdatedAtField)) {
|
|
2576
|
+
return false;
|
|
2577
|
+
}
|
|
2578
|
+
for (let i = 0; i < upsertedRows.length; i++) {
|
|
2579
|
+
const heldIdx = heldIndexByKey.get(upsertKeys[i]);
|
|
2580
|
+
if (heldIdx === undefined) {
|
|
2581
|
+
return false;
|
|
2582
|
+
}
|
|
2583
|
+
const heldRaw = this.readHeldRowField(heldRows[heldIdx], EntityInfo.UpdatedAtFieldName);
|
|
2584
|
+
const incomingRaw = upsertedRows[i][EntityInfo.UpdatedAtFieldName];
|
|
2585
|
+
if (heldRaw == null || incomingRaw == null) {
|
|
2586
|
+
return false;
|
|
2587
|
+
}
|
|
2588
|
+
const heldMs = heldRaw instanceof Date ? heldRaw.getTime() : new Date(String(heldRaw)).getTime();
|
|
2589
|
+
const incomingMs = incomingRaw instanceof Date ? incomingRaw.getTime() : new Date(String(incomingRaw)).getTime();
|
|
2590
|
+
if (Number.isNaN(heldMs) || Number.isNaN(incomingMs) || heldMs !== incomingMs) {
|
|
2591
|
+
return false;
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
return true;
|
|
2595
|
+
}
|
|
2047
2596
|
/**
|
|
2048
2597
|
* True when a cache-change payload carries a schema hash and it differs from this engine's
|
|
2049
2598
|
* hash for the config's entity. A payload without a hash, or an entity this process cannot
|
|
@@ -2057,40 +2606,81 @@ export class BaseEngine extends BaseSingleton {
|
|
|
2057
2606
|
return ownHash !== undefined && ownHash !== payloadSchemaHash;
|
|
2058
2607
|
}
|
|
2059
2608
|
/**
|
|
2060
|
-
* Reloads a config after a
|
|
2061
|
-
* arrives while a reload for the same property is running asks for one more reload after
|
|
2062
|
-
* instead of starting its own. Every caller's promise settles only after a reload that began
|
|
2609
|
+
* Reloads a config after a change made elsewhere, coalescing overlapping requests: an event
|
|
2610
|
+
* that arrives while a reload for the same property is running asks for one more reload after
|
|
2611
|
+
* it instead of starting its own. Every caller's promise settles only after a reload that began
|
|
2063
2612
|
* after its event, so no change is missed, but N events in a burst cost at most two reloads.
|
|
2613
|
+
* Successive reloads of one property are at least {@link ExternalChangeFollowUpDelayMs} apart.
|
|
2614
|
+
* Requests for different properties never share a reload.
|
|
2615
|
+
*
|
|
2616
|
+
* @param bypassCache - Read past the cache. When any request folded into a reload asks for
|
|
2617
|
+
* it, that reload bypasses the cache.
|
|
2064
2618
|
*/
|
|
2065
|
-
reloadAfterExternalChange(config) {
|
|
2619
|
+
reloadAfterExternalChange(config, bypassCache = false) {
|
|
2066
2620
|
const running = this._externalChangeReloads.get(config.PropertyName);
|
|
2067
2621
|
if (running) {
|
|
2068
2622
|
running.Again = true;
|
|
2623
|
+
running.BypassCache = running.BypassCache || bypassCache;
|
|
2069
2624
|
return running.Done;
|
|
2070
2625
|
}
|
|
2071
|
-
const reload = { Again: false, Done: Promise.resolve() };
|
|
2626
|
+
const reload = { Again: false, BypassCache: bypassCache, Done: Promise.resolve() };
|
|
2072
2627
|
this._externalChangeReloads.set(config.PropertyName, reload);
|
|
2073
2628
|
reload.Done = this.runExternalChangeReloads(config, reload);
|
|
2074
2629
|
return reload.Done;
|
|
2075
2630
|
}
|
|
2076
|
-
/**
|
|
2631
|
+
/**
|
|
2632
|
+
* Runs reloads for {@link reloadAfterExternalChange} until no further event asked for one.
|
|
2633
|
+
* A reload that throws does not end the loop while another is pending, so an event that
|
|
2634
|
+
* arrived during a failed reload is still served; the error surfaces only when the last
|
|
2635
|
+
* reload is the one that threw.
|
|
2636
|
+
*/
|
|
2077
2637
|
async runExternalChangeReloads(config, reload) {
|
|
2078
2638
|
try {
|
|
2639
|
+
let lastError = null;
|
|
2079
2640
|
do {
|
|
2641
|
+
const wait = this.reloadSpacingRemainingMs(config.PropertyName);
|
|
2642
|
+
if (wait > 0) {
|
|
2643
|
+
// Requests that arrive during the wait join the reload that follows it.
|
|
2644
|
+
await new Promise(resolve => setTimeout(resolve, wait));
|
|
2645
|
+
}
|
|
2646
|
+
const bypassCache = reload.BypassCache;
|
|
2080
2647
|
reload.Again = false;
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
this.emitPropertyChange(config.PropertyName);
|
|
2648
|
+
reload.BypassCache = false;
|
|
2649
|
+
lastError = await this.runOneExternalChangeReload(config, bypassCache);
|
|
2650
|
+
this._externalChangeReloadEndedAt.set(config.PropertyName, Date.now());
|
|
2651
|
+
if (lastError && reload.Again) {
|
|
2652
|
+
LogError(lastError);
|
|
2653
|
+
}
|
|
2088
2654
|
} while (reload.Again);
|
|
2655
|
+
if (lastError) {
|
|
2656
|
+
throw lastError;
|
|
2657
|
+
}
|
|
2089
2658
|
}
|
|
2090
2659
|
finally {
|
|
2091
2660
|
this._externalChangeReloads.delete(config.PropertyName);
|
|
2092
2661
|
}
|
|
2093
2662
|
}
|
|
2663
|
+
/** How long the next reload of the property must still wait for {@link ExternalChangeFollowUpDelayMs}; 0 when it may start now. */
|
|
2664
|
+
reloadSpacingRemainingMs(propertyName) {
|
|
2665
|
+
const endedAt = this._externalChangeReloadEndedAt.get(propertyName);
|
|
2666
|
+
return endedAt === undefined ? 0 : Math.max(0, endedAt + BaseEngine.ExternalChangeFollowUpDelayMs - Date.now());
|
|
2667
|
+
}
|
|
2668
|
+
/** One reload for {@link runExternalChangeReloads}. Returns the error it threw, or null. */
|
|
2669
|
+
async runOneExternalChangeReload(config, bypassCache) {
|
|
2670
|
+
try {
|
|
2671
|
+
// The reload replaces the property, so the derived state has to be rebuilt too —
|
|
2672
|
+
// and the reload must NOT emit on its own, or a subscriber wakes between the new
|
|
2673
|
+
// rows and the rebuild and reads derived state still pointing at the instances
|
|
2674
|
+
// this reload discarded.
|
|
2675
|
+
await this.LoadSingleConfig(config, this._contextUser, bypassCache, /*suppressEmit*/ true);
|
|
2676
|
+
await this.RebuildDerivedState(this._contextUser);
|
|
2677
|
+
this.emitPropertyChange(config.PropertyName);
|
|
2678
|
+
return null;
|
|
2679
|
+
}
|
|
2680
|
+
catch (e) {
|
|
2681
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2094
2684
|
/**
|
|
2095
2685
|
* True when `payloadRows` are the same row versions this config currently holds: the same
|
|
2096
2686
|
* number of rows, and the same multiset of (primary key, `__mj_UpdatedAt`) pairs.
|
|
@@ -2337,11 +2927,26 @@ export class BaseEngine extends BaseSingleton {
|
|
|
2337
2927
|
// read-modify-write of this slot, and an unlocked replace would clobber it.
|
|
2338
2928
|
await cache.ReplaceRunViewResultLocked(this.configFingerprint(config), params, plainRows, maxUpdatedAt, plainRows.length, this.ProviderToUse);
|
|
2339
2929
|
}
|
|
2340
|
-
/**
|
|
2930
|
+
/**
|
|
2931
|
+
* The cache fingerprint of a config's slot: the one {@link RegisterCacheChangeCallbacks} listens
|
|
2932
|
+
* on, {@link syncLocalCacheForConfig} maintains and {@link storeConfigRows} writes.
|
|
2933
|
+
*
|
|
2934
|
+
* Built by the provider for this engine's user, with that user's row-level-security and
|
|
2935
|
+
* field-security segments, so it names the same slot the provider's own RunView read used. An
|
|
2936
|
+
* engine running as a restricted user holds only what that user may see; without the segments
|
|
2937
|
+
* its rows would be written into, and its callbacks registered on, the slot unrestricted
|
|
2938
|
+
* readers share.
|
|
2939
|
+
*/
|
|
2341
2940
|
configFingerprint(config) {
|
|
2941
|
+
const params = this.BuildRunViewParamsForConfig(config);
|
|
2342
2942
|
const provider = this.RunViewProviderToUse;
|
|
2343
|
-
|
|
2344
|
-
|
|
2943
|
+
if (provider instanceof ProviderBase) {
|
|
2944
|
+
return provider.ComputeRunViewCacheFingerprint(params, this._contextUser);
|
|
2945
|
+
}
|
|
2946
|
+
const connection = provider && 'InstanceConnectionString' in provider
|
|
2947
|
+
? provider.InstanceConnectionString
|
|
2948
|
+
: undefined;
|
|
2949
|
+
return LocalCacheManager.Instance.GenerateRunViewFingerprint(params, connection);
|
|
2345
2950
|
}
|
|
2346
2951
|
/**
|
|
2347
2952
|
* Snapshot of what this engine holds: per property, the row count, newest `__mj_UpdatedAt`,
|