@memberjunction/core 5.43.0 → 5.45.0

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 (68) hide show
  1. package/dist/generic/BaseEntitySaveQueue.d.ts +6 -0
  2. package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
  3. package/dist/generic/BaseEntitySaveQueue.js +9 -0
  4. package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
  5. package/dist/generic/EntityFieldRules.js +2 -2
  6. package/dist/generic/EntityFieldRules.js.map +1 -1
  7. package/dist/generic/authTypes.d.ts +1 -0
  8. package/dist/generic/authTypes.d.ts.map +1 -1
  9. package/dist/generic/authTypes.js +1 -0
  10. package/dist/generic/authTypes.js.map +1 -1
  11. package/dist/generic/baseEngine.d.ts +216 -10
  12. package/dist/generic/baseEngine.d.ts.map +1 -1
  13. package/dist/generic/baseEngine.js +426 -55
  14. package/dist/generic/baseEngine.js.map +1 -1
  15. package/dist/generic/baseEntity.d.ts.map +1 -1
  16. package/dist/generic/baseEntity.js +35 -15
  17. package/dist/generic/baseEntity.js.map +1 -1
  18. package/dist/generic/compositeKey.js +1 -1
  19. package/dist/generic/compositeKey.js.map +1 -1
  20. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  21. package/dist/generic/databaseProviderBase.js +17 -1
  22. package/dist/generic/databaseProviderBase.js.map +1 -1
  23. package/dist/generic/entityInfo.d.ts +21 -0
  24. package/dist/generic/entityInfo.d.ts.map +1 -1
  25. package/dist/generic/entityInfo.js +22 -1
  26. package/dist/generic/entityInfo.js.map +1 -1
  27. package/dist/generic/externalDataSourceReadRouter.d.ts +63 -0
  28. package/dist/generic/externalDataSourceReadRouter.d.ts.map +1 -0
  29. package/dist/generic/externalDataSourceReadRouter.js +19 -0
  30. package/dist/generic/externalDataSourceReadRouter.js.map +1 -0
  31. package/dist/generic/externalDataSourceTypes.d.ts +68 -0
  32. package/dist/generic/externalDataSourceTypes.d.ts.map +1 -0
  33. package/dist/generic/externalDataSourceTypes.js +14 -0
  34. package/dist/generic/externalDataSourceTypes.js.map +1 -0
  35. package/dist/generic/graphqlTypeNames.d.ts.map +1 -1
  36. package/dist/generic/graphqlTypeNames.js +6 -1
  37. package/dist/generic/graphqlTypeNames.js.map +1 -1
  38. package/dist/generic/interfaces.d.ts +24 -0
  39. package/dist/generic/interfaces.d.ts.map +1 -1
  40. package/dist/generic/interfaces.js.map +1 -1
  41. package/dist/generic/localCacheManager.d.ts +1 -1
  42. package/dist/generic/localCacheManager.d.ts.map +1 -1
  43. package/dist/generic/localCacheManager.js +21 -2
  44. package/dist/generic/localCacheManager.js.map +1 -1
  45. package/dist/generic/logging.d.ts.map +1 -1
  46. package/dist/generic/logging.js +4 -1
  47. package/dist/generic/logging.js.map +1 -1
  48. package/dist/generic/providerBase.d.ts +35 -0
  49. package/dist/generic/providerBase.d.ts.map +1 -1
  50. package/dist/generic/providerBase.js +83 -3
  51. package/dist/generic/providerBase.js.map +1 -1
  52. package/dist/generic/queryResultEnricher.d.ts +77 -0
  53. package/dist/generic/queryResultEnricher.d.ts.map +1 -0
  54. package/dist/generic/queryResultEnricher.js +41 -0
  55. package/dist/generic/queryResultEnricher.js.map +1 -0
  56. package/dist/generic/runQuery.d.ts +17 -0
  57. package/dist/generic/runQuery.d.ts.map +1 -1
  58. package/dist/generic/runQuery.js.map +1 -1
  59. package/dist/generic/securityInfo.d.ts +48 -0
  60. package/dist/generic/securityInfo.d.ts.map +1 -1
  61. package/dist/generic/securityInfo.js +27 -0
  62. package/dist/generic/securityInfo.js.map +1 -1
  63. package/dist/index.d.ts +3 -0
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +3 -0
  66. package/dist/index.js.map +1 -1
  67. package/package.json +3 -3
  68. package/readme.md +34 -4
@@ -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";
@@ -40,6 +40,24 @@ export class BaseEnginePropertyConfig extends BaseInfo {
40
40
  Object.assign(this, init);
41
41
  }
42
42
  }
43
+ /**
44
+ * Thrown when engine data is accessed but was never loaded because the
45
+ * current user lacks read permissions on the underlying entities.
46
+ *
47
+ * Consumers that want graceful degradation should check
48
+ * `engine.IsPermissionConstrained` BEFORE accessing properties.
49
+ * This exception is the safety net for code paths that forget to check.
50
+ */
51
+ export class PermissionConstrainedError extends Error {
52
+ constructor(engineName, deniedEntities) {
53
+ super(`${engineName} data is not available — user lacks read permission ` +
54
+ `on: ${deniedEntities.join(', ')}. Check engine.IsPermissionConstrained ` +
55
+ `before accessing properties to handle this gracefully.`);
56
+ this.name = 'PermissionConstrainedError';
57
+ this.EngineName = engineName;
58
+ this.DeniedEntities = deniedEntities;
59
+ }
60
+ }
43
61
  export class BaseEngine extends BaseSingleton {
44
62
  /**
45
63
  * Returns an Observable for a specific engine array property. Subscribers receive the
@@ -87,7 +105,21 @@ export class BaseEngine extends BaseSingleton {
87
105
  this._dataChange$ = new Subject();
88
106
  this._cacheChangeUnsubscribers = [];
89
107
  this._propertySubjects = new Map();
108
+ this._isPermissionConstrained = false;
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();
90
121
  this._entityEventDebounceTime = 1500; // Default debounce time in milliseconds (1.5 seconds)
122
+ this._eventRefreshRetryTimers = new Map();
91
123
  }
92
124
  /**
93
125
  * Observable that emits when any data property changes due to a refresh.
@@ -128,6 +160,55 @@ export class BaseEngine extends BaseSingleton {
128
160
  };
129
161
  this._dataChange$.next(event);
130
162
  }
163
+ /**
164
+ * True when the engine loaded successfully but all entity configs were
165
+ * skipped because the current user lacks read permissions. Accessor
166
+ * properties will throw {@link PermissionConstrainedError} if accessed
167
+ * in this state. Check this flag first to degrade gracefully.
168
+ */
169
+ get IsPermissionConstrained() {
170
+ return this._isPermissionConstrained;
171
+ }
172
+ /**
173
+ * Retrieves engine-loaded data for a config property by name. This is the
174
+ * canonical accessor for engine getter properties — it checks the data map
175
+ * for permission denial and throws {@link PermissionConstrainedError} with
176
+ * the specific denied entity name(s) if the config was skipped.
177
+ *
178
+ * Subclasses should use this in every getter that exposes engine-loaded data:
179
+ * ```typescript
180
+ * public get Models(): MJAIModelEntityExtended[] {
181
+ * return this.GetConfigData<MJAIModelEntityExtended>('_models');
182
+ * }
183
+ * ```
184
+ *
185
+ * @param propertyName - The config property name (e.g., '_models', '_agents'),
186
+ * matching the PropertyName used in the engine's Config() params array.
187
+ * @returns The data array for the property, or an empty array if not yet loaded.
188
+ * @throws {PermissionConstrainedError} if the property was skipped due to permission denial.
189
+ */
190
+ GetConfigData(propertyName) {
191
+ const entry = this._dataMap.get(propertyName);
192
+ if (entry?.permissionDenied) {
193
+ throw new PermissionConstrainedError(this.constructor.name, entry.entityName ? [entry.entityName] : this._deniedEntityNames);
194
+ }
195
+ return this[propertyName] ?? [];
196
+ }
197
+ /**
198
+ * Check if a specific property was skipped due to permission denial.
199
+ * Forward-compatible with a future partial-loading approach.
200
+ */
201
+ IsPropertyPermissionConstrained(propertyName) {
202
+ const entry = this._dataMap.get(propertyName);
203
+ return entry?.permissionDenied === true;
204
+ }
205
+ /**
206
+ * List of entity names that were skipped due to permission denial.
207
+ * Empty if not permission-constrained. Useful for logging/diagnostics.
208
+ */
209
+ get PermissionConstrainedEntities() {
210
+ return [...this._deniedEntityNames];
211
+ }
131
212
  /**
132
213
  * Controls the default RunView ResultType for all entity configs loaded by this engine.
133
214
  * Override in subclasses to change the default for the entire engine without modifying
@@ -238,6 +319,10 @@ export class BaseEngine extends BaseSingleton {
238
319
  });
239
320
  }
240
321
  if (!this._loaded || forceRefresh) {
322
+ // Reset permission-constrained state on fresh load or force refresh
323
+ // so permission changes mid-session take effect via Config(true).
324
+ this._isPermissionConstrained = false;
325
+ this._deniedEntityNames = [];
241
326
  // Start telemetry tracking for engine load
242
327
  const entityNames = configs
243
328
  .filter(c => c.Type !== 'dataset' && c.EntityName)
@@ -412,6 +497,12 @@ export class BaseEngine extends BaseSingleton {
412
497
  */
413
498
  async HandleIndividualBaseEntityEvent(event) {
414
499
  try {
500
+ // If the engine is permission-constrained, don't attempt to reload
501
+ // configs in response to entity events — the user can't read them
502
+ // anyway. Use Config(true) to re-check if permissions change.
503
+ if (this._isPermissionConstrained) {
504
+ return true;
505
+ }
415
506
  if (event.type === 'remote-invalidate') {
416
507
  return await this.HandleRemoteInvalidateEvent(event);
417
508
  }
@@ -485,10 +576,18 @@ export class BaseEngine extends BaseSingleton {
485
576
  }
486
577
  // Fall through to server fetch if direct delete failed
487
578
  }
488
- // 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.
489
583
  let refreshCount = 0;
490
584
  for (const config of matchingConfigs) {
491
- 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
+ }
492
591
  refreshCount++;
493
592
  }
494
593
  if (refreshCount > 0) {
@@ -584,10 +683,16 @@ export class BaseEngine extends BaseSingleton {
584
683
  }
585
684
  /**
586
685
  * This method handles the debouncing process, by default using the EntityEventDebounceTime property to set the debounce time. Debouncing is
587
- * 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
588
687
  * prevent multiple events from being processed in quick succession for a single entity which would cause a lot of wasted processing.
589
688
  *
590
- * 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.
591
696
  * @param event
592
697
  * @returns
593
698
  */
@@ -600,8 +705,11 @@ export class BaseEngine extends BaseSingleton {
600
705
  // Use config-specific debounce time or fall back to default
601
706
  const debounceTimeValue = matchingConfig?.DebounceTime ?? this.EntityEventDebounceTime;
602
707
  const subject = new Subject();
603
- subject.pipe(debounceTime(debounceTimeValue)).subscribe(async (e) => {
604
- 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);
605
713
  });
606
714
  this._entityEventSubjects.set(entityName, subject);
607
715
  }
@@ -625,55 +733,82 @@ export class BaseEngine extends BaseSingleton {
625
733
  return this._entityEventDebounceTime;
626
734
  }
627
735
  /**
628
- * 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
629
- * 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
630
- * originated the event.
631
- *
632
- * 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.
633
740
  */
634
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
+ }
635
773
  try {
636
- const entityName = event.baseEntity.EntityInfo.Name.toLowerCase().trim();
774
+ const entityName = events[0].baseEntity.EntityInfo.Name.toLowerCase().trim();
637
775
  let refreshCount = 0;
638
776
  for (const config of this.Configs) {
639
777
  if (config.AutoRefresh && config.Type === 'entity' && config.EntityName?.trim().toLowerCase() === entityName) {
640
- // For UPDATE events, check if the exact object is already in our array.
641
- // If so, it's already been mutated in place - no need to refresh.
642
- if (event.type === 'save' && event.saveSubType === 'update') {
643
- if (this.isEntityAlreadyInArray(config, event.baseEntity)) {
644
- // Object already in array and updated in place, skip refresh
645
- // LogStatus(`>>> Skipping refresh for ${config.PropertyName} - object already in array`);
646
- continue;
647
- }
648
- }
649
- // For CREATE events, check if the entity was already added to our array
650
- // (e.g., by engine methods like InstallApplication that manually push).
651
- if (event.type === 'save' && event.saveSubType === 'create') {
652
- if (this.isEntityAlreadyInArray(config, event.baseEntity)) {
653
- // Object already in array (manually added), skip refresh
654
- // LogStatus(`>>> Skipping refresh for ${config.PropertyName} - newly created object already in array`);
655
- 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
+ }
656
787
  }
657
- }
658
- // For DELETE events, check if the entity was already removed from our array
659
- // (e.g., by engine methods like UninstallApplication that manually splice).
660
- // Also check by primary key since the object reference may still exist but be removed.
661
- if (event.type === 'delete') {
662
- if (!this.isEntityInArrayByRefOrKey(config, event.baseEntity)) {
663
- // Object not in array (already removed), skip refresh
664
- // LogStatus(`>>> Skipping refresh for ${config.PropertyName} - deleted object not in array`);
665
- 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++;
666
802
  }
667
803
  }
668
- // Check if we can use immediate array mutation instead of running a view
669
- if (this.canUseImmediateMutation(config)) {
670
- // LogStatus(`>>> Immediate mutation for ${config.PropertyName} due to BaseEntity ${event.type} event for: ${event.baseEntity.EntityInfo.Name}`);
671
- await this.applyImmediateMutation(config, event);
672
- }
673
804
  else {
674
- // LogStatus(`>>> Refreshing metadata for ${config.PropertyName} due to BaseEntity ${event.type} event for: ${event.baseEntity.EntityInfo.Name}, pkey: ${event.baseEntity.PrimaryKey.ToString()}`);
675
- await this.LoadSingleConfig(config, this._contextUser);
676
- 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
+ }
677
812
  }
678
813
  }
679
814
  }
@@ -691,6 +826,38 @@ export class BaseEngine extends BaseSingleton {
691
826
  LogError(e);
692
827
  }
693
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
+ }
694
861
  /**
695
862
  * Checks if the exact entity object reference is already in the config's data array.
696
863
  * Used to skip unnecessary refreshes for UPDATE events where the object was mutated in place.
@@ -709,14 +876,19 @@ export class BaseEngine extends BaseSingleton {
709
876
  /**
710
877
  * Checks if an entity is in the config's data array by object reference OR by primary key match.
711
878
  * Used for DELETE events where we need to know if the entity still exists in the array.
712
- * The object reference may still exist (entity.Delete() just marks it deleted), but if it was
713
- * 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.
714
885
  *
715
886
  * @param config - The configuration to check
716
887
  * @param entity - The entity to look for
888
+ * @param preDeleteValues - Pre-delete field snapshot (delete event payload's OldValues)
717
889
  * @returns true if the entity is in the array (by reference or by primary key)
718
890
  */
719
- isEntityInArrayByRefOrKey(config, entity) {
891
+ isEntityInArrayByRefOrKey(config, entity, preDeleteValues) {
720
892
  const currentData = this[config.PropertyName];
721
893
  if (!currentData) {
722
894
  return false;
@@ -725,9 +897,108 @@ export class BaseEngine extends BaseSingleton {
725
897
  if (currentData.indexOf(entity) >= 0) {
726
898
  return true;
727
899
  }
728
- // 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
729
909
  return this.findEntityIndexByPrimaryKeys(currentData, entity) >= 0;
730
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
+ }
731
1002
  /**
732
1003
  * Determines if an immediate array mutation can be used instead of running a full view refresh.
733
1004
  * Immediate mutations are only safe when:
@@ -819,8 +1090,6 @@ export class BaseEngine extends BaseSingleton {
819
1090
  if (event.saveSubType === 'create') {
820
1091
  // For create, first check if the exact object is already in the array
821
1092
  const existsByRef = currentData.indexOf(entity) >= 0;
822
- // if already in the array, nothing to do, but we keep going
823
- // in the method as there is stuff below the outer if block
824
1093
  if (!existsByRef) {
825
1094
  // Check by composite primary key in case it was added with a different object reference
826
1095
  const indexByKey = this.findEntityIndexByPrimaryKeys(currentData, entity);
@@ -837,11 +1106,15 @@ export class BaseEngine extends BaseSingleton {
837
1106
  this.NotifyDataChange(config, currentData, 'add', cached);
838
1107
  }
839
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
+ }
840
1115
  }
841
1116
  else {
842
1117
  // Update: first check if the exact object is already in the array
843
- // if already in the array, we don't do anything but we keep going
844
- // in the method so stuff at end can be done
845
1118
  const existsByRef = currentData.indexOf(entity) >= 0;
846
1119
  if (!existsByRef) {
847
1120
  // Find by composite primary key and replace
@@ -859,6 +1132,11 @@ export class BaseEngine extends BaseSingleton {
859
1132
  this.NotifyDataChange(config, currentData, 'add', cached);
860
1133
  }
861
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
+ }
862
1140
  }
863
1141
  }
864
1142
  else if (event.type === 'delete') {
@@ -1014,11 +1292,71 @@ export class BaseEngine extends BaseSingleton {
1014
1292
  // 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()
1015
1293
  const entityConfigs = this._metadataConfigs.filter(c => c.Type === 'entity');
1016
1294
  const datasetConfigs = this._metadataConfigs.filter(c => c.Type === 'dataset');
1295
+ // All-or-nothing permission check: if the user lacks read access on ANY
1296
+ // entity config, skip ALL entity configs for this engine. Returns the
1297
+ // original array unchanged when permissions pass, or an empty array when
1298
+ // any entity is denied (marking the engine as permission-constrained).
1299
+ const entityConfigsToLoad = this.CheckPermissionsOrSkipAll(entityConfigs, contextUser);
1017
1300
  await Promise.all([...datasetConfigs.map(c => this.LoadSingleDatasetConfig(c, contextUser, bypassCache)),
1018
- this.LoadMultipleEntityConfigs(entityConfigs, contextUser, bypassCache)]);
1301
+ this.LoadMultipleEntityConfigs(entityConfigsToLoad, contextUser, bypassCache)]);
1019
1302
  // Register cross-server cache change callbacks for entity configs
1020
1303
  this.RegisterCacheChangeCallbacks(entityConfigs);
1021
1304
  }
1305
+ /**
1306
+ * All-or-nothing permission gate: checks `CanRead` on every entity config. If ANY
1307
+ * entity is denied, ALL configs are skipped — the engine is marked permission-constrained
1308
+ * and its data arrays are set to empty `[]`. This prevents noisy permission-denied errors
1309
+ * and endless retry loops for users with limited permissions (e.g., org-scoped SaaS roles).
1310
+ *
1311
+ * On the server side with a system user (who has all permissions), this method returns
1312
+ * the original configs unchanged — no behavior change for privileged users.
1313
+ *
1314
+ * @returns The original configs array (all permissions pass) or an empty array (any denied)
1315
+ */
1316
+ CheckPermissionsOrSkipAll(configs, contextUser) {
1317
+ if (configs.length === 0)
1318
+ return configs;
1319
+ // Determine the user to check permissions for — on the client side contextUser
1320
+ // may be undefined, so fall back to the current logged-in user from Metadata.
1321
+ const user = contextUser || this.ProviderToUse?.CurrentUser;
1322
+ if (!user)
1323
+ return configs; // Can't check without a user — proceed with normal loading
1324
+ const md = this.ProviderToUse;
1325
+ const deniedEntities = [];
1326
+ for (const config of configs) {
1327
+ if (!config.EntityName)
1328
+ continue;
1329
+ const entityInfo = md.EntityByName(config.EntityName);
1330
+ if (!entityInfo)
1331
+ continue; // Entity not in metadata — let RunView handle it
1332
+ const perms = entityInfo.GetUserPermisions(user);
1333
+ if (!perms || !perms.CanRead) {
1334
+ deniedEntities.push(config.EntityName);
1335
+ }
1336
+ }
1337
+ if (deniedEntities.length > 0) {
1338
+ LogStatus(`${this.constructor.name}: Skipping ${configs.length} entity config(s) — user ${user.Email} lacks read permission on: ${deniedEntities.join(', ')}`);
1339
+ // Mark all entity configs as successfully loaded with empty data.
1340
+ // This is not a failure — it's expected behavior for limited-permission users.
1341
+ // Marking as successful allows the engine to set _loaded = true and avoid
1342
+ // endless retry loops from EnsureLoaded().
1343
+ for (const config of configs) {
1344
+ if (config.AddToObject !== false) {
1345
+ this[config.PropertyName] = [];
1346
+ }
1347
+ this._dataMap.set(config.PropertyName, {
1348
+ entityName: config.EntityName,
1349
+ data: [],
1350
+ loadedSuccessfully: true,
1351
+ permissionDenied: true,
1352
+ });
1353
+ }
1354
+ this._isPermissionConstrained = true;
1355
+ this._deniedEntityNames = deniedEntities;
1356
+ return []; // Return empty — don't attempt any RunView calls
1357
+ }
1358
+ return configs;
1359
+ }
1022
1360
  /**
1023
1361
  * Loads a single metadata configuration.
1024
1362
  * @param config - The metadata configuration to load
@@ -1031,6 +1369,25 @@ export class BaseEngine extends BaseSingleton {
1031
1369
  else
1032
1370
  return await this.LoadSingleEntityConfig(config, contextUser, bypassCache);
1033
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
+ }
1034
1391
  /**
1035
1392
  * Handles the process of loading a single config of type 'entity'.
1036
1393
  * @param config
@@ -1038,6 +1395,12 @@ export class BaseEngine extends BaseSingleton {
1038
1395
  * @param bypassCache - When true, bypasses server-side cache to get fresh data from the database
1039
1396
  */
1040
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);
1041
1404
  const p = this.RunViewProviderToUse;
1042
1405
  const rv = new RunView(p);
1043
1406
  const result = await rv.RunView({
@@ -1051,6 +1414,14 @@ export class BaseEngine extends BaseSingleton {
1051
1414
  CacheLocalTTL: config.CacheLocalTTL,
1052
1415
  BypassCache: bypassCache
1053
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
+ }
1054
1425
  this.HandleSingleViewResult(config, result, contextUser);
1055
1426
  this.emitPropertyChange(config.PropertyName);
1056
1427
  }