@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.
- package/dist/generic/BaseEntitySaveQueue.d.ts +6 -0
- package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
- package/dist/generic/BaseEntitySaveQueue.js +9 -0
- package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
- package/dist/generic/EntityFieldRules.js +2 -2
- package/dist/generic/EntityFieldRules.js.map +1 -1
- package/dist/generic/authTypes.d.ts +1 -0
- package/dist/generic/authTypes.d.ts.map +1 -1
- package/dist/generic/authTypes.js +1 -0
- package/dist/generic/authTypes.js.map +1 -1
- package/dist/generic/baseEngine.d.ts +216 -10
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +426 -55
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +35 -15
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/compositeKey.js +1 -1
- package/dist/generic/compositeKey.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +17 -1
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +21 -0
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +22 -1
- 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/graphqlTypeNames.d.ts.map +1 -1
- package/dist/generic/graphqlTypeNames.js +6 -1
- package/dist/generic/graphqlTypeNames.js.map +1 -1
- package/dist/generic/interfaces.d.ts +24 -0
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- 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/queryResultEnricher.d.ts +77 -0
- package/dist/generic/queryResultEnricher.d.ts.map +1 -0
- package/dist/generic/queryResultEnricher.js +41 -0
- package/dist/generic/queryResultEnricher.js.map +1 -0
- package/dist/generic/runQuery.d.ts +17 -0
- package/dist/generic/runQuery.d.ts.map +1 -1
- package/dist/generic/runQuery.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 +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- 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
|
|
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
|
-
*
|
|
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(
|
|
604
|
-
|
|
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
|
-
*
|
|
629
|
-
*
|
|
630
|
-
*
|
|
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 =
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
if (
|
|
643
|
-
if
|
|
644
|
-
|
|
645
|
-
//
|
|
646
|
-
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
|
|
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
|
-
//
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
*
|
|
713
|
-
*
|
|
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
|
-
//
|
|
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(
|
|
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
|
}
|