@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.
Files changed (39) hide show
  1. package/dist/generic/baseEngine.d.ts +146 -10
  2. package/dist/generic/baseEngine.d.ts.map +1 -1
  3. package/dist/generic/baseEngine.js +286 -54
  4. package/dist/generic/baseEngine.js.map +1 -1
  5. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  6. package/dist/generic/databaseProviderBase.js +16 -0
  7. package/dist/generic/databaseProviderBase.js.map +1 -1
  8. package/dist/generic/entityInfo.d.ts +12 -0
  9. package/dist/generic/entityInfo.d.ts.map +1 -1
  10. package/dist/generic/entityInfo.js +12 -0
  11. package/dist/generic/entityInfo.js.map +1 -1
  12. package/dist/generic/externalDataSourceReadRouter.d.ts +63 -0
  13. package/dist/generic/externalDataSourceReadRouter.d.ts.map +1 -0
  14. package/dist/generic/externalDataSourceReadRouter.js +19 -0
  15. package/dist/generic/externalDataSourceReadRouter.js.map +1 -0
  16. package/dist/generic/externalDataSourceTypes.d.ts +68 -0
  17. package/dist/generic/externalDataSourceTypes.d.ts.map +1 -0
  18. package/dist/generic/externalDataSourceTypes.js +14 -0
  19. package/dist/generic/externalDataSourceTypes.js.map +1 -0
  20. package/dist/generic/localCacheManager.d.ts +1 -1
  21. package/dist/generic/localCacheManager.d.ts.map +1 -1
  22. package/dist/generic/localCacheManager.js +21 -2
  23. package/dist/generic/localCacheManager.js.map +1 -1
  24. package/dist/generic/logging.d.ts.map +1 -1
  25. package/dist/generic/logging.js +4 -1
  26. package/dist/generic/logging.js.map +1 -1
  27. package/dist/generic/providerBase.d.ts +35 -0
  28. package/dist/generic/providerBase.d.ts.map +1 -1
  29. package/dist/generic/providerBase.js +83 -3
  30. package/dist/generic/providerBase.js.map +1 -1
  31. package/dist/generic/securityInfo.d.ts +48 -0
  32. package/dist/generic/securityInfo.d.ts.map +1 -1
  33. package/dist/generic/securityInfo.js +27 -0
  34. package/dist/generic/securityInfo.js.map +1 -1
  35. package/dist/index.d.ts +2 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +2 -0
  38. package/dist/index.js.map +1 -1
  39. 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 event will be processed. This is done to
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
- * Override this method if you want to change how debouncing time such as having variable debounce times per-entity, etc.
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(debounceTime(debounceTimeValue)).subscribe(async (e) => {
683
- await this.ProcessEntityEvent(e);
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
- * This method does the actual work of processing the entity event. It is not directly called from the event handler because we want to first debounce the events
708
- * which also introduces a delay which is usually desirable so that our processing is typically outside of the scope of any transaction processing that would have
709
- * originated the event.
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 = event.baseEntity.EntityInfo.Name.toLowerCase().trim();
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
- // For UPDATE events, check if the exact object is already in our array.
720
- // If so, it's already been mutated in place - no need to refresh.
721
- if (event.type === 'save' && event.saveSubType === 'update') {
722
- if (this.isEntityAlreadyInArray(config, event.baseEntity)) {
723
- // Object already in array and updated in place, skip refresh
724
- // LogStatus(`>>> Skipping refresh for ${config.PropertyName} - object already in array`);
725
- continue;
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
- // For DELETE events, check if the entity was already removed from our array
738
- // (e.g., by engine methods like UninstallApplication that manually splice).
739
- // Also check by primary key since the object reference may still exist but be removed.
740
- if (event.type === 'delete') {
741
- if (!this.isEntityInArrayByRefOrKey(config, event.baseEntity)) {
742
- // Object not in array (already removed), skip refresh
743
- // LogStatus(`>>> Skipping refresh for ${config.PropertyName} - deleted object not in array`);
744
- continue;
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
- // LogStatus(`>>> Refreshing metadata for ${config.PropertyName} due to BaseEntity ${event.type} event for: ${event.baseEntity.EntityInfo.Name}, pkey: ${event.baseEntity.PrimaryKey.ToString()}`);
754
- await this.LoadSingleConfig(config, this._contextUser);
755
- refreshCount++;
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
- * The object reference may still exist (entity.Delete() just marks it deleted), but if it was
792
- * manually spliced out by engine code, we check by primary key as fallback.
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
- // Fallback: check by primary key
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
  }