@memberjunction/core 6.2.0-edge.5 → 6.2.0-edge.7

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