@memberjunction/core 5.44.0 → 5.45.1
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/baseEngine.d.ts +146 -10
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +286 -54
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +16 -0
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +12 -0
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +12 -0
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/externalDataSourceReadRouter.d.ts +63 -0
- package/dist/generic/externalDataSourceReadRouter.d.ts.map +1 -0
- package/dist/generic/externalDataSourceReadRouter.js +19 -0
- package/dist/generic/externalDataSourceReadRouter.js.map +1 -0
- package/dist/generic/externalDataSourceTypes.d.ts +68 -0
- package/dist/generic/externalDataSourceTypes.d.ts.map +1 -0
- package/dist/generic/externalDataSourceTypes.js +14 -0
- package/dist/generic/externalDataSourceTypes.js.map +1 -0
- package/dist/generic/localCacheManager.d.ts +1 -1
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +21 -2
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/logging.d.ts.map +1 -1
- package/dist/generic/logging.js +4 -1
- package/dist/generic/logging.js.map +1 -1
- package/dist/generic/providerBase.d.ts +35 -0
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +83 -3
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/securityInfo.d.ts +48 -0
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +27 -0
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { BaseSingleton, MJEventType, MJGlobal } from "@memberjunction/global";
|
|
2
2
|
import { TelemetryManager } from "./telemetryManager.js";
|
|
3
3
|
import { BehaviorSubject, Subject } from "rxjs";
|
|
4
|
-
import { debounceTime } from "rxjs/operators";
|
|
4
|
+
import { buffer, debounceTime, filter } from "rxjs/operators";
|
|
5
5
|
import { RunView } from "../views/runView.js";
|
|
6
6
|
import { LogError, LogStatus } from "./logging.js";
|
|
7
7
|
import { Metadata } from "./metadata.js";
|
|
@@ -107,7 +107,19 @@ export class BaseEngine extends BaseSingleton {
|
|
|
107
107
|
this._propertySubjects = new Map();
|
|
108
108
|
this._isPermissionConstrained = false;
|
|
109
109
|
this._deniedEntityNames = [];
|
|
110
|
+
/**
|
|
111
|
+
* Per-property monotonic full-refresh counter. Guards {@link LoadSingleEntityConfig}
|
|
112
|
+
* against overlapping full refreshes clobbering each other: when several full-refresh
|
|
113
|
+
* RunViews for the same property are in flight at once (e.g. a burst of saves each
|
|
114
|
+
* landing in its own debounce window because the round-trip exceeds the DebounceTime),
|
|
115
|
+
* only the latest-INITIATED refresh may commit its results. Without this, whichever
|
|
116
|
+
* RunView happens to RESOLVE last wins — which can be an earlier-initiated request that
|
|
117
|
+
* read a staler snapshot, leaving the cache "one operation behind" until a full reload
|
|
118
|
+
* reconciles it. Keyed by config PropertyName. See {@link beginConfigRefresh}.
|
|
119
|
+
*/
|
|
120
|
+
this._configRefreshGeneration = new Map();
|
|
110
121
|
this._entityEventDebounceTime = 1500; // Default debounce time in milliseconds (1.5 seconds)
|
|
122
|
+
this._eventRefreshRetryTimers = new Map();
|
|
111
123
|
}
|
|
112
124
|
/**
|
|
113
125
|
* Observable that emits when any data property changes due to a refresh.
|
|
@@ -564,10 +576,18 @@ export class BaseEngine extends BaseSingleton {
|
|
|
564
576
|
}
|
|
565
577
|
// Fall through to server fetch if direct delete failed
|
|
566
578
|
}
|
|
567
|
-
// Fallback: re-fetch from server (missing data or apply failure)
|
|
579
|
+
// Fallback: re-fetch from server (missing data or apply failure). Bypass the cache
|
|
580
|
+
// for the same reason as the local-event path: this fetch runs BECAUSE a
|
|
581
|
+
// (cross-server) write signaled the cache stale, so reading back through it would
|
|
582
|
+
// re-sync the pre-write snapshot.
|
|
568
583
|
let refreshCount = 0;
|
|
569
584
|
for (const config of matchingConfigs) {
|
|
570
|
-
await this.LoadSingleConfig(config, this._contextUser);
|
|
585
|
+
await this.LoadSingleConfig(config, this._contextUser, /*bypassCache*/ true);
|
|
586
|
+
if (!this.configLoadedSuccessfully(config.PropertyName)) {
|
|
587
|
+
// Same one-shot hazard as the debounced path: the invalidation event is
|
|
588
|
+
// consumed, so a transient failure here needs a bounded retry too.
|
|
589
|
+
this.scheduleEventRefreshRetry(config, 1);
|
|
590
|
+
}
|
|
571
591
|
refreshCount++;
|
|
572
592
|
}
|
|
573
593
|
if (refreshCount > 0) {
|
|
@@ -663,10 +683,16 @@ export class BaseEngine extends BaseSingleton {
|
|
|
663
683
|
}
|
|
664
684
|
/**
|
|
665
685
|
* This method handles the debouncing process, by default using the EntityEventDebounceTime property to set the debounce time. Debouncing is
|
|
666
|
-
* done on a per-entity basis, meaning that if the debounce time passes for a specific entity name, the
|
|
686
|
+
* done on a per-entity basis, meaning that if the debounce time passes for a specific entity name, the events will be processed. This is done to
|
|
667
687
|
* prevent multiple events from being processed in quick succession for a single entity which would cause a lot of wasted processing.
|
|
668
688
|
*
|
|
669
|
-
*
|
|
689
|
+
* ALL events raised during the debounce window are buffered and delivered as one batch to
|
|
690
|
+
* {@link ProcessEntityEvents} — not just the last one. The refresh-vs-skip decision must be
|
|
691
|
+
* an OR over every coalesced event: judging only the last event would let an
|
|
692
|
+
* already-applied write (e.g., an engine method's in-place save of a cached instance)
|
|
693
|
+
* mask an earlier fresh-instance save the array has never seen.
|
|
694
|
+
*
|
|
695
|
+
* Override this method if you want to change how debouncing works, such as having variable debounce times per-entity, etc.
|
|
670
696
|
* @param event
|
|
671
697
|
* @returns
|
|
672
698
|
*/
|
|
@@ -679,8 +705,11 @@ export class BaseEngine extends BaseSingleton {
|
|
|
679
705
|
// Use config-specific debounce time or fall back to default
|
|
680
706
|
const debounceTimeValue = matchingConfig?.DebounceTime ?? this.EntityEventDebounceTime;
|
|
681
707
|
const subject = new Subject();
|
|
682
|
-
subject.pipe(
|
|
683
|
-
|
|
708
|
+
subject.pipe(
|
|
709
|
+
// Collect every event in the window; the debounced stream closes the buffer,
|
|
710
|
+
// so the batch is emitted once the entity has been quiet for the full window.
|
|
711
|
+
buffer(subject.pipe(debounceTime(debounceTimeValue))), filter(batch => batch.length > 0)).subscribe(async (batch) => {
|
|
712
|
+
await this.ProcessEntityEvents(batch);
|
|
684
713
|
});
|
|
685
714
|
this._entityEventSubjects.set(entityName, subject);
|
|
686
715
|
}
|
|
@@ -704,55 +733,82 @@ export class BaseEngine extends BaseSingleton {
|
|
|
704
733
|
return this._entityEventDebounceTime;
|
|
705
734
|
}
|
|
706
735
|
/**
|
|
707
|
-
*
|
|
708
|
-
*
|
|
709
|
-
*
|
|
710
|
-
*
|
|
711
|
-
* This is the best method to override if you want to change the actual processing of an entity event but do NOT want to modify the debouncing behavior.
|
|
736
|
+
* Back-compat single-event wrapper around {@link ProcessEntityEvents}. The debounced
|
|
737
|
+
* pipeline delivers full batches to ProcessEntityEvents — override THAT method to change
|
|
738
|
+
* event-processing behavior; this wrapper exists for subclasses/tests that process one
|
|
739
|
+
* event at a time.
|
|
712
740
|
*/
|
|
713
741
|
async ProcessEntityEvent(event) {
|
|
742
|
+
return this.ProcessEntityEvents([event]);
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Does the actual work of processing all entity events coalesced into one debounce window.
|
|
746
|
+
* Not called directly from the event handler because we first debounce the events, which also
|
|
747
|
+
* introduces a delay that is usually desirable so processing happens outside the scope of any
|
|
748
|
+
* transaction processing that originated the events.
|
|
749
|
+
*
|
|
750
|
+
* Per matching config, the decision is an OR over the whole batch:
|
|
751
|
+
* - If ANY event's changes are not yet reflected in the config's array, run the refresh
|
|
752
|
+
* (or apply each such event via immediate mutation when the config allows it). A single
|
|
753
|
+
* full refresh covers every event in the window.
|
|
754
|
+
* - Else, if any event's changes were already applied (in-place save of a cached instance,
|
|
755
|
+
* manual push after create), notify observers once — the refresh is redundant but the
|
|
756
|
+
* notification is not.
|
|
757
|
+
* - Deletes of rows absent from the array stay silent: "already spliced by engine code"
|
|
758
|
+
* (that code owns the notification, see {@link notifyAlreadyAppliedMutation}) is
|
|
759
|
+
* indistinguishable from "never matched this config's Filter", and notifying would
|
|
760
|
+
* assert phantom deletes to filtered configs' observers.
|
|
761
|
+
*
|
|
762
|
+
* A transiently-failed refresh schedules a bounded retry via
|
|
763
|
+
* {@link scheduleEventRefreshRetry} — without it, the consumed debounce event would leave
|
|
764
|
+
* observers permanently stale until an unrelated event arrived.
|
|
765
|
+
*
|
|
766
|
+
* This is the best method to override if you want to change the actual processing of entity
|
|
767
|
+
* events but do NOT want to modify the debouncing behavior.
|
|
768
|
+
*/
|
|
769
|
+
async ProcessEntityEvents(events) {
|
|
770
|
+
if (!events || events.length === 0) {
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
714
773
|
try {
|
|
715
|
-
const entityName =
|
|
774
|
+
const entityName = events[0].baseEntity.EntityInfo.Name.toLowerCase().trim();
|
|
716
775
|
let refreshCount = 0;
|
|
717
776
|
for (const config of this.Configs) {
|
|
718
777
|
if (config.AutoRefresh && config.Type === 'entity' && config.EntityName?.trim().toLowerCase() === entityName) {
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
if (
|
|
722
|
-
if
|
|
723
|
-
|
|
724
|
-
//
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
// For CREATE events, check if the entity was already added to our array
|
|
729
|
-
// (e.g., by engine methods like InstallApplication that manually push).
|
|
730
|
-
if (event.type === 'save' && event.saveSubType === 'create') {
|
|
731
|
-
if (this.isEntityAlreadyInArray(config, event.baseEntity)) {
|
|
732
|
-
// Object already in array (manually added), skip refresh
|
|
733
|
-
// LogStatus(`>>> Skipping refresh for ${config.PropertyName} - newly created object already in array`);
|
|
734
|
-
continue;
|
|
778
|
+
const classified = events.map(e => ({ event: e, disposition: this.classifyEventForConfig(config, e) }));
|
|
779
|
+
const needingWork = classified.filter(c => c.disposition.action === 'refresh');
|
|
780
|
+
if (needingWork.length > 0) {
|
|
781
|
+
// Check if we can use immediate array mutation instead of running a view
|
|
782
|
+
if (this.canUseImmediateMutation(config)) {
|
|
783
|
+
// Apply, in order, every event the array hasn't seen yet
|
|
784
|
+
for (const c of needingWork) {
|
|
785
|
+
await this.applyImmediateMutation(config, c.event);
|
|
786
|
+
}
|
|
735
787
|
}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
//
|
|
743
|
-
//
|
|
744
|
-
|
|
788
|
+
else {
|
|
789
|
+
// One full refresh covers every event in the window. Bypass the cache:
|
|
790
|
+
// this refresh is triggered BY a save/delete of this very entity, so a
|
|
791
|
+
// cached view result is precisely what may be stale. Reading true DB
|
|
792
|
+
// state guarantees the engine cache reflects the write that just fired
|
|
793
|
+
// the event — otherwise a filtered/ordered config (which can't be
|
|
794
|
+
// updated in place, e.g. UserInfoEngine's per-user '_UserApplications')
|
|
795
|
+
// re-syncs the PRE-write snapshot and the UI sits "one operation behind"
|
|
796
|
+
// until a full page reload repopulates the cache.
|
|
797
|
+
await this.LoadSingleConfig(config, this._contextUser, /*bypassCache*/ true);
|
|
798
|
+
if (!this.configLoadedSuccessfully(config.PropertyName)) {
|
|
799
|
+
this.scheduleEventRefreshRetry(config, 1);
|
|
800
|
+
}
|
|
801
|
+
refreshCount++;
|
|
745
802
|
}
|
|
746
803
|
}
|
|
747
|
-
// Check if we can use immediate array mutation instead of running a view
|
|
748
|
-
if (this.canUseImmediateMutation(config)) {
|
|
749
|
-
// LogStatus(`>>> Immediate mutation for ${config.PropertyName} due to BaseEntity ${event.type} event for: ${event.baseEntity.EntityInfo.Name}`);
|
|
750
|
-
await this.applyImmediateMutation(config, event);
|
|
751
|
-
}
|
|
752
804
|
else {
|
|
753
|
-
//
|
|
754
|
-
|
|
755
|
-
|
|
805
|
+
// No event requires a refresh. If any event's changes were already
|
|
806
|
+
// applied to the array, notify observers once (latest such event wins);
|
|
807
|
+
// otherwise every event was a silent delete-of-absent-row.
|
|
808
|
+
const lastNotify = [...classified].reverse().find(c => c.disposition.action === 'notify');
|
|
809
|
+
if (lastNotify && lastNotify.disposition.action === 'notify') {
|
|
810
|
+
this.notifyAlreadyAppliedMutation(config, lastNotify.disposition.changeType, lastNotify.event.baseEntity);
|
|
811
|
+
}
|
|
756
812
|
}
|
|
757
813
|
}
|
|
758
814
|
}
|
|
@@ -770,6 +826,38 @@ export class BaseEngine extends BaseSingleton {
|
|
|
770
826
|
LogError(e);
|
|
771
827
|
}
|
|
772
828
|
}
|
|
829
|
+
/**
|
|
830
|
+
* Classifies a single entity event against a single config's backing array:
|
|
831
|
+
* - 'refresh' — the array does not yet reflect this event's changes; a refresh
|
|
832
|
+
* (or immediate mutation) is required.
|
|
833
|
+
* - 'notify' — the array already reflects the change (in-place save of the array's own
|
|
834
|
+
* cached instance, or a manually-pushed create); observers still need a notification.
|
|
835
|
+
* - 'silent' — a delete of a row absent from the array; "already spliced" is
|
|
836
|
+
* indistinguishable from "never matched the Filter", so no notification is emitted
|
|
837
|
+
* (manual-splice engine code owns that notification).
|
|
838
|
+
*
|
|
839
|
+
* For deletes, the by-key membership check uses the event payload's pre-delete OldValues
|
|
840
|
+
* snapshot — BaseEntity.Delete() calls NewRecord() right after raising the event, which
|
|
841
|
+
* wipes field values and REGENERATES the primary key, so the live entity's key can never
|
|
842
|
+
* match the deleted row by the time the debounced handler runs.
|
|
843
|
+
*/
|
|
844
|
+
classifyEventForConfig(config, event) {
|
|
845
|
+
if (event.type === 'save') {
|
|
846
|
+
if (event.saveSubType === 'update' || event.saveSubType === 'create') {
|
|
847
|
+
if (this.isEntityAlreadyInArray(config, event.baseEntity)) {
|
|
848
|
+
return { action: 'notify', changeType: event.saveSubType === 'create' ? 'add' : 'update' };
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
return { action: 'refresh' };
|
|
852
|
+
}
|
|
853
|
+
if (event.type === 'delete') {
|
|
854
|
+
const oldValues = event.payload?.OldValues;
|
|
855
|
+
return this.isEntityInArrayByRefOrKey(config, event.baseEntity, oldValues)
|
|
856
|
+
? { action: 'refresh' }
|
|
857
|
+
: { action: 'silent' };
|
|
858
|
+
}
|
|
859
|
+
return { action: 'silent' };
|
|
860
|
+
}
|
|
773
861
|
/**
|
|
774
862
|
* Checks if the exact entity object reference is already in the config's data array.
|
|
775
863
|
* Used to skip unnecessary refreshes for UPDATE events where the object was mutated in place.
|
|
@@ -788,14 +876,19 @@ export class BaseEngine extends BaseSingleton {
|
|
|
788
876
|
/**
|
|
789
877
|
* Checks if an entity is in the config's data array by object reference OR by primary key match.
|
|
790
878
|
* Used for DELETE events where we need to know if the entity still exists in the array.
|
|
791
|
-
*
|
|
792
|
-
*
|
|
879
|
+
*
|
|
880
|
+
* For deletes, pass `preDeleteValues` (the event payload's OldValues snapshot): by the time
|
|
881
|
+
* the debounced handler runs, BaseEntity.Delete() has already called NewRecord(), which wipes
|
|
882
|
+
* the entity's fields and regenerates its primary key — so a by-key check against the live
|
|
883
|
+
* entity can never match the deleted row. Same hazard (and same OldValues workaround) as
|
|
884
|
+
* LocalCacheManager.HandleBaseEntityEvent.
|
|
793
885
|
*
|
|
794
886
|
* @param config - The configuration to check
|
|
795
887
|
* @param entity - The entity to look for
|
|
888
|
+
* @param preDeleteValues - Pre-delete field snapshot (delete event payload's OldValues)
|
|
796
889
|
* @returns true if the entity is in the array (by reference or by primary key)
|
|
797
890
|
*/
|
|
798
|
-
isEntityInArrayByRefOrKey(config, entity) {
|
|
891
|
+
isEntityInArrayByRefOrKey(config, entity, preDeleteValues) {
|
|
799
892
|
const currentData = this[config.PropertyName];
|
|
800
893
|
if (!currentData) {
|
|
801
894
|
return false;
|
|
@@ -804,9 +897,108 @@ export class BaseEngine extends BaseSingleton {
|
|
|
804
897
|
if (currentData.indexOf(entity) >= 0) {
|
|
805
898
|
return true;
|
|
806
899
|
}
|
|
807
|
-
//
|
|
900
|
+
// Preferred by-key check: build the key from the pre-delete snapshot
|
|
901
|
+
if (preDeleteValues) {
|
|
902
|
+
const key = new CompositeKey();
|
|
903
|
+
key.LoadFromEntityInfoAndRecord(entity.EntityInfo, preDeleteValues);
|
|
904
|
+
if (key.KeyValuePairs.length > 0 && !key.KeyValuePairs.some(kv => kv.Value == null)) {
|
|
905
|
+
return currentData.some(e => e.PrimaryKey.Equals(key));
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// Fallback: check by the entity's current primary key
|
|
808
909
|
return this.findEntityIndexByPrimaryKeys(currentData, entity) >= 0;
|
|
809
910
|
}
|
|
911
|
+
/**
|
|
912
|
+
* Emits change notifications for a config whose backing array ALREADY reflects the
|
|
913
|
+
* entity event — e.g., engine code saved the array's own cached instance in place,
|
|
914
|
+
* or manually pushed a newly created entity after Save. In those cases
|
|
915
|
+
* {@link ProcessEntityEvent} safely skips the redundant refresh, but the notification
|
|
916
|
+
* must NOT be skipped: without it, `DataChange$` and `ObserveProperty` subscribers
|
|
917
|
+
* (and anything derived from them downstream) never learn the array changed and are
|
|
918
|
+
* stranded on stale state.
|
|
919
|
+
*
|
|
920
|
+
* Engine subclasses that manually SPLICE a deleted row out of a config's array must
|
|
921
|
+
* call this themselves ('delete') right after splicing — the debounced event handler
|
|
922
|
+
* cannot distinguish "already spliced" from "never matched this config's Filter", so
|
|
923
|
+
* it stays silent for absent rows.
|
|
924
|
+
*
|
|
925
|
+
* Deliberately does not run AdditionalLoading — the skip paths never did, and
|
|
926
|
+
* engines that maintain their arrays manually own any derived-data updates themselves.
|
|
927
|
+
*/
|
|
928
|
+
notifyAlreadyAppliedMutation(config, changeType, entity) {
|
|
929
|
+
// Don't emit for configs that were skipped due to permission denial or never
|
|
930
|
+
// loaded — an emission with fabricated empty data would invite consumers to
|
|
931
|
+
// read engine getters that throw PermissionConstrainedError.
|
|
932
|
+
const mapEntry = this._dataMap.get(config.PropertyName);
|
|
933
|
+
if (mapEntry?.permissionDenied) {
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
const currentData = this[config.PropertyName];
|
|
937
|
+
if (!currentData) {
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
this.NotifyDataChange(config, currentData, changeType, entity);
|
|
941
|
+
this.emitPropertyChange(config.PropertyName);
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* True when the config's last load attempt left it in a successfully-loaded state.
|
|
945
|
+
* Reads the same map entry {@link HandleSingleViewResult} writes — a transient failure
|
|
946
|
+
* (network, server restart) records loadedSuccessfully=false; a permission denial is
|
|
947
|
+
* recorded as loaded-empty (true) and is deliberately NOT retryable.
|
|
948
|
+
*/
|
|
949
|
+
configLoadedSuccessfully(propertyName) {
|
|
950
|
+
return this._dataMap.get(propertyName)?.loadedSuccessfully === true;
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Maximum number of retries for an event-triggered config refresh that failed transiently.
|
|
954
|
+
* Overridable by subclasses that want more or less persistence.
|
|
955
|
+
*/
|
|
956
|
+
get MaxEventRefreshRetries() {
|
|
957
|
+
return 2;
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Schedules a bounded, backed-off retry of a config refresh that failed transiently during
|
|
961
|
+
* entity-event processing. Without this, one failed RunView after a save would permanently
|
|
962
|
+
* strand every observer on stale data — the debounced event is already consumed, so nothing
|
|
963
|
+
* else re-runs the refresh until an unrelated event for the same entity arrives.
|
|
964
|
+
*
|
|
965
|
+
* At most one retry is pending per property at a time; a retry that succeeds notifies
|
|
966
|
+
* observers through the normal HandleSingleViewResult → NotifyDataChange path. Permission
|
|
967
|
+
* denials never reach here (HandleSingleViewResult marks them loaded-empty).
|
|
968
|
+
*
|
|
969
|
+
* @param config - The config whose refresh failed
|
|
970
|
+
* @param attempt - 1-based attempt number; delays back off linearly (2s, 4s, ...)
|
|
971
|
+
*/
|
|
972
|
+
scheduleEventRefreshRetry(config, attempt) {
|
|
973
|
+
const key = config.PropertyName;
|
|
974
|
+
if (this._eventRefreshRetryTimers.has(key)) {
|
|
975
|
+
return; // a retry is already pending for this property
|
|
976
|
+
}
|
|
977
|
+
if (attempt > this.MaxEventRefreshRetries) {
|
|
978
|
+
LogError(`BaseEngine: giving up on event-triggered refresh of ${config.EntityName} → ${key} after ${this.MaxEventRefreshRetries} retries`);
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
const delayMs = 2000 * attempt;
|
|
982
|
+
const timer = setTimeout(async () => {
|
|
983
|
+
this._eventRefreshRetryTimers.delete(key);
|
|
984
|
+
try {
|
|
985
|
+
// Bypass the cache: this retries an event-triggered refresh, so the cache is
|
|
986
|
+
// still the stale copy the original write invalidated — reading through it
|
|
987
|
+
// could "succeed" with stale data and reinstate the one-operation-behind bug.
|
|
988
|
+
await this.LoadSingleConfig(config, this._contextUser, /*bypassCache*/ true);
|
|
989
|
+
if (this.configLoadedSuccessfully(key)) {
|
|
990
|
+
await this.AdditionalLoading(this._contextUser);
|
|
991
|
+
}
|
|
992
|
+
else {
|
|
993
|
+
this.scheduleEventRefreshRetry(config, attempt + 1);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
catch (e) {
|
|
997
|
+
LogError(e);
|
|
998
|
+
}
|
|
999
|
+
}, delayMs);
|
|
1000
|
+
this._eventRefreshRetryTimers.set(key, timer);
|
|
1001
|
+
}
|
|
810
1002
|
/**
|
|
811
1003
|
* Determines if an immediate array mutation can be used instead of running a full view refresh.
|
|
812
1004
|
* Immediate mutations are only safe when:
|
|
@@ -898,8 +1090,6 @@ export class BaseEngine extends BaseSingleton {
|
|
|
898
1090
|
if (event.saveSubType === 'create') {
|
|
899
1091
|
// For create, first check if the exact object is already in the array
|
|
900
1092
|
const existsByRef = currentData.indexOf(entity) >= 0;
|
|
901
|
-
// if already in the array, nothing to do, but we keep going
|
|
902
|
-
// in the method as there is stuff below the outer if block
|
|
903
1093
|
if (!existsByRef) {
|
|
904
1094
|
// Check by composite primary key in case it was added with a different object reference
|
|
905
1095
|
const indexByKey = this.findEntityIndexByPrimaryKeys(currentData, entity);
|
|
@@ -916,11 +1106,15 @@ export class BaseEngine extends BaseSingleton {
|
|
|
916
1106
|
this.NotifyDataChange(config, currentData, 'add', cached);
|
|
917
1107
|
}
|
|
918
1108
|
}
|
|
1109
|
+
else {
|
|
1110
|
+
// Already in the array by reference (engine code pushed it manually after
|
|
1111
|
+
// Save) — the array is current, but DataChange$ subscribers still need to
|
|
1112
|
+
// hear about the change. Pass the in-array instance, not the clone.
|
|
1113
|
+
this.NotifyDataChange(config, currentData, 'add', entity);
|
|
1114
|
+
}
|
|
919
1115
|
}
|
|
920
1116
|
else {
|
|
921
1117
|
// Update: first check if the exact object is already in the array
|
|
922
|
-
// if already in the array, we don't do anything but we keep going
|
|
923
|
-
// in the method so stuff at end can be done
|
|
924
1118
|
const existsByRef = currentData.indexOf(entity) >= 0;
|
|
925
1119
|
if (!existsByRef) {
|
|
926
1120
|
// Find by composite primary key and replace
|
|
@@ -938,6 +1132,11 @@ export class BaseEngine extends BaseSingleton {
|
|
|
938
1132
|
this.NotifyDataChange(config, currentData, 'add', cached);
|
|
939
1133
|
}
|
|
940
1134
|
}
|
|
1135
|
+
else {
|
|
1136
|
+
// In-place save of the array's own cached instance — the array is already
|
|
1137
|
+
// current, but DataChange$ subscribers still need to hear about the change.
|
|
1138
|
+
this.NotifyDataChange(config, currentData, 'update', entity);
|
|
1139
|
+
}
|
|
941
1140
|
}
|
|
942
1141
|
}
|
|
943
1142
|
else if (event.type === 'delete') {
|
|
@@ -1170,6 +1369,25 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1170
1369
|
else
|
|
1171
1370
|
return await this.LoadSingleEntityConfig(config, contextUser, bypassCache);
|
|
1172
1371
|
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Opens a new full-refresh "generation" for a property and returns its token. Each call
|
|
1374
|
+
* bumps the property's monotonic counter, so a token is the latest iff no later refresh
|
|
1375
|
+
* for that property has begun since. See {@link _configRefreshGeneration}.
|
|
1376
|
+
*/
|
|
1377
|
+
beginConfigRefresh(propertyName) {
|
|
1378
|
+
const next = (this._configRefreshGeneration.get(propertyName) ?? 0) + 1;
|
|
1379
|
+
this._configRefreshGeneration.set(propertyName, next);
|
|
1380
|
+
return next;
|
|
1381
|
+
}
|
|
1382
|
+
/**
|
|
1383
|
+
* True when `generation` is still the most recent token handed out by
|
|
1384
|
+
* {@link beginConfigRefresh} for `propertyName` — i.e. no newer full refresh for this
|
|
1385
|
+
* property has started since. A refresh whose token is stale must NOT commit its results:
|
|
1386
|
+
* a newer refresh was initiated afterward and read a more-recent state.
|
|
1387
|
+
*/
|
|
1388
|
+
isLatestConfigRefresh(propertyName, generation) {
|
|
1389
|
+
return this._configRefreshGeneration.get(propertyName) === generation;
|
|
1390
|
+
}
|
|
1173
1391
|
/**
|
|
1174
1392
|
* Handles the process of loading a single config of type 'entity'.
|
|
1175
1393
|
* @param config
|
|
@@ -1177,6 +1395,12 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1177
1395
|
* @param bypassCache - When true, bypasses server-side cache to get fresh data from the database
|
|
1178
1396
|
*/
|
|
1179
1397
|
async LoadSingleEntityConfig(config, contextUser, bypassCache = false) {
|
|
1398
|
+
// Claim a refresh generation BEFORE the awaited RunView. If another full refresh for
|
|
1399
|
+
// this same property starts while our RunView is in flight, ours becomes stale and must
|
|
1400
|
+
// not commit — otherwise concurrent refreshes (the filtered/OrderBy path, e.g.
|
|
1401
|
+
// UserInfoEngine's per-user '_UserApplications') apply out of initiation-order and the
|
|
1402
|
+
// cache ends up "one operation behind". See _configRefreshGeneration.
|
|
1403
|
+
const generation = this.beginConfigRefresh(config.PropertyName);
|
|
1180
1404
|
const p = this.RunViewProviderToUse;
|
|
1181
1405
|
const rv = new RunView(p);
|
|
1182
1406
|
const result = await rv.RunView({
|
|
@@ -1190,6 +1414,14 @@ export class BaseEngine extends BaseSingleton {
|
|
|
1190
1414
|
CacheLocalTTL: config.CacheLocalTTL,
|
|
1191
1415
|
BypassCache: bypassCache
|
|
1192
1416
|
}, contextUser);
|
|
1417
|
+
// A newer full refresh superseded us while we awaited — drop this (staler) snapshot
|
|
1418
|
+
// rather than clobber the newer one. The newer refresh owns the assignment, the
|
|
1419
|
+
// observer notification, and (on failure) the retry, so bailing here leaves no
|
|
1420
|
+
// observer un-notified. The single-refresh path is unaffected: its generation is
|
|
1421
|
+
// still the latest when its RunView returns.
|
|
1422
|
+
if (!this.isLatestConfigRefresh(config.PropertyName, generation)) {
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1193
1425
|
this.HandleSingleViewResult(config, result, contextUser);
|
|
1194
1426
|
this.emitPropertyChange(config.PropertyName);
|
|
1195
1427
|
}
|