@memberjunction/core 6.2.0-edge.4 → 6.2.0-edge.6

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 (150) hide show
  1. package/dist/generic/BaseEntitySaveQueue.d.ts +22 -0
  2. package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
  3. package/dist/generic/BaseEntitySaveQueue.js +43 -4
  4. package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
  5. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +18 -0
  6. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
  7. package/dist/generic/alternateBaseViews.d.ts +119 -0
  8. package/dist/generic/alternateBaseViews.d.ts.map +1 -0
  9. package/dist/generic/alternateBaseViews.js +223 -0
  10. package/dist/generic/alternateBaseViews.js.map +1 -0
  11. package/dist/generic/baseEngine.d.ts +82 -14
  12. package/dist/generic/baseEngine.d.ts.map +1 -1
  13. package/dist/generic/baseEngine.js +311 -46
  14. package/dist/generic/baseEngine.js.map +1 -1
  15. package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
  16. package/dist/generic/baseEngineRegistry.js +2 -1
  17. package/dist/generic/baseEngineRegistry.js.map +1 -1
  18. package/dist/generic/baseEntity.d.ts +194 -14
  19. package/dist/generic/baseEntity.d.ts.map +1 -1
  20. package/dist/generic/baseEntity.js +638 -218
  21. package/dist/generic/baseEntity.js.map +1 -1
  22. package/dist/generic/baseInfo.d.ts +31 -3
  23. package/dist/generic/baseInfo.d.ts.map +1 -1
  24. package/dist/generic/baseInfo.js +138 -40
  25. package/dist/generic/baseInfo.js.map +1 -1
  26. package/dist/generic/contextTokens.d.ts +136 -0
  27. package/dist/generic/contextTokens.d.ts.map +1 -0
  28. package/dist/generic/contextTokens.js +319 -0
  29. package/dist/generic/contextTokens.js.map +1 -0
  30. package/dist/generic/databaseProviderBase.d.ts +103 -13
  31. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  32. package/dist/generic/databaseProviderBase.js +321 -77
  33. package/dist/generic/databaseProviderBase.js.map +1 -1
  34. package/dist/generic/embeddedRecord.d.ts +6 -0
  35. package/dist/generic/embeddedRecord.d.ts.map +1 -1
  36. package/dist/generic/embeddedRecord.js +15 -1
  37. package/dist/generic/embeddedRecord.js.map +1 -1
  38. package/dist/generic/entityCompanion.d.ts +7 -0
  39. package/dist/generic/entityCompanion.d.ts.map +1 -1
  40. package/dist/generic/entityCompanion.js +8 -0
  41. package/dist/generic/entityCompanion.js.map +1 -1
  42. package/dist/generic/entityConfiguration.d.ts +8 -2
  43. package/dist/generic/entityConfiguration.d.ts.map +1 -1
  44. package/dist/generic/entityConfiguration.js +9 -0
  45. package/dist/generic/entityConfiguration.js.map +1 -1
  46. package/dist/generic/entityInfo.d.ts +306 -12
  47. package/dist/generic/entityInfo.d.ts.map +1 -1
  48. package/dist/generic/entityInfo.js +894 -92
  49. package/dist/generic/entityInfo.js.map +1 -1
  50. package/dist/generic/entityValueFieldSecurity.d.ts +139 -0
  51. package/dist/generic/entityValueFieldSecurity.d.ts.map +1 -0
  52. package/dist/generic/entityValueFieldSecurity.js +250 -0
  53. package/dist/generic/entityValueFieldSecurity.js.map +1 -0
  54. package/dist/generic/fieldSecuritySQLGate.d.ts +350 -0
  55. package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -0
  56. package/dist/generic/fieldSecuritySQLGate.js +898 -0
  57. package/dist/generic/fieldSecuritySQLGate.js.map +1 -0
  58. package/dist/generic/indexRestrictedFields.d.ts +47 -0
  59. package/dist/generic/indexRestrictedFields.d.ts.map +1 -0
  60. package/dist/generic/indexRestrictedFields.js +129 -0
  61. package/dist/generic/indexRestrictedFields.js.map +1 -0
  62. package/dist/generic/infoProviderResolution.d.ts +16 -0
  63. package/dist/generic/infoProviderResolution.d.ts.map +1 -0
  64. package/dist/generic/infoProviderResolution.js +17 -0
  65. package/dist/generic/infoProviderResolution.js.map +1 -0
  66. package/dist/generic/interfaces.d.ts +39 -2
  67. package/dist/generic/interfaces.d.ts.map +1 -1
  68. package/dist/generic/interfaces.js.map +1 -1
  69. package/dist/generic/localCacheManager.d.ts +318 -51
  70. package/dist/generic/localCacheManager.d.ts.map +1 -1
  71. package/dist/generic/localCacheManager.js +675 -109
  72. package/dist/generic/localCacheManager.js.map +1 -1
  73. package/dist/generic/logging.d.ts.map +1 -1
  74. package/dist/generic/logging.js +7 -1
  75. package/dist/generic/logging.js.map +1 -1
  76. package/dist/generic/metadata.d.ts +3 -2
  77. package/dist/generic/metadata.d.ts.map +1 -1
  78. package/dist/generic/metadata.js +5 -2
  79. package/dist/generic/metadata.js.map +1 -1
  80. package/dist/generic/mjapiUrl.d.ts +21 -0
  81. package/dist/generic/mjapiUrl.d.ts.map +1 -0
  82. package/dist/generic/mjapiUrl.js +25 -0
  83. package/dist/generic/mjapiUrl.js.map +1 -0
  84. package/dist/generic/platformSQL.d.ts +54 -0
  85. package/dist/generic/platformSQL.d.ts.map +1 -1
  86. package/dist/generic/platformSQL.js +55 -0
  87. package/dist/generic/platformSQL.js.map +1 -1
  88. package/dist/generic/providerBase.d.ts +370 -57
  89. package/dist/generic/providerBase.d.ts.map +1 -1
  90. package/dist/generic/providerBase.js +1085 -284
  91. package/dist/generic/providerBase.js.map +1 -1
  92. package/dist/generic/queryInfo.d.ts +23 -9
  93. package/dist/generic/queryInfo.d.ts.map +1 -1
  94. package/dist/generic/queryInfo.js +73 -33
  95. package/dist/generic/queryInfo.js.map +1 -1
  96. package/dist/generic/queryInfoInterfaces.d.ts +8 -0
  97. package/dist/generic/queryInfoInterfaces.d.ts.map +1 -1
  98. package/dist/generic/queryTemplateContext.d.ts +49 -0
  99. package/dist/generic/queryTemplateContext.d.ts.map +1 -0
  100. package/dist/generic/queryTemplateContext.js +113 -0
  101. package/dist/generic/queryTemplateContext.js.map +1 -0
  102. package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -1
  103. package/dist/generic/recordChangeFieldSecurity.js +13 -2
  104. package/dist/generic/recordChangeFieldSecurity.js.map +1 -1
  105. package/dist/generic/relatedRecordCollection.d.ts +13 -0
  106. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  107. package/dist/generic/relatedRecordCollection.js +44 -5
  108. package/dist/generic/relatedRecordCollection.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.map +1 -1
  114. package/dist/generic/runQuerySQLFilterImplementations.js +38 -2
  115. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  116. package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
  117. package/dist/generic/saveEntityGraphOperation.js +3 -0
  118. package/dist/generic/saveEntityGraphOperation.js.map +1 -1
  119. package/dist/generic/scoring/ReciprocalRankFusion.d.ts.map +1 -1
  120. package/dist/generic/scoring/ReciprocalRankFusion.js +10 -3
  121. package/dist/generic/scoring/ReciprocalRankFusion.js.map +1 -1
  122. package/dist/generic/securityInfo.d.ts +17 -6
  123. package/dist/generic/securityInfo.d.ts.map +1 -1
  124. package/dist/generic/securityInfo.js +25 -10
  125. package/dist/generic/securityInfo.js.map +1 -1
  126. package/dist/generic/telemetryManager.d.ts +9 -0
  127. package/dist/generic/telemetryManager.d.ts.map +1 -1
  128. package/dist/generic/telemetryManager.js +5 -1
  129. package/dist/generic/telemetryManager.js.map +1 -1
  130. package/dist/generic/util.d.ts.map +1 -1
  131. package/dist/generic/util.js +3 -0
  132. package/dist/generic/util.js.map +1 -1
  133. package/dist/generic/wellKnownUserSource.d.ts +3 -2
  134. package/dist/generic/wellKnownUserSource.d.ts.map +1 -1
  135. package/dist/generic/wellKnownUserSource.js +18 -9
  136. package/dist/generic/wellKnownUserSource.js.map +1 -1
  137. package/dist/index.d.ts +8 -0
  138. package/dist/index.d.ts.map +1 -1
  139. package/dist/index.js +8 -0
  140. package/dist/index.js.map +1 -1
  141. package/dist/views/runView.d.ts +89 -0
  142. package/dist/views/runView.d.ts.map +1 -1
  143. package/dist/views/runView.js +36 -0
  144. package/dist/views/runView.js.map +1 -1
  145. package/dist/views/viewProperties.d.ts +55 -0
  146. package/dist/views/viewProperties.d.ts.map +1 -0
  147. package/dist/views/viewProperties.js +258 -0
  148. package/dist/views/viewProperties.js.map +1 -0
  149. package/package.json +3 -3
  150. package/readme.md +29 -4
@@ -39,6 +39,14 @@ export class BaseEnginePropertyConfig extends BaseInfo {
39
39
  * environment, and the same is true for MJAPI/Server based environments. If you need network based notification, additional infrastructure will be needed to implement that.
40
40
  */
41
41
  this.AutoRefresh = true;
42
+ /**
43
+ * When set to true, the engine will use the LocalCacheManager to cache results locally.
44
+ * On subsequent loads (unless forceRefresh is true), cached results will be validated
45
+ * via smart cache check and returned from local storage if still current.
46
+ *
47
+ * @default false
48
+ */
49
+ this.CacheLocal = false;
42
50
  // now copy the values from init to this object
43
51
  if (init)
44
52
  Object.assign(this, init);
@@ -190,8 +198,20 @@ export class BaseEngine extends BaseSingleton {
190
198
  * reconciles it. Keyed by config PropertyName. See {@link beginConfigRefresh}.
191
199
  */
192
200
  this._configRefreshGeneration = new Map();
201
+ /**
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}.
205
+ */
206
+ this._externalChangeReloads = new Map();
193
207
  this._entityEventDebounceTime = 1500; // Default debounce time in milliseconds (1.5 seconds)
194
208
  this._eventRefreshRetryTimers = new Map();
209
+ /**
210
+ * Maps normalized entity names (`entityName.trim().toLowerCase()`) to dataset item properties
211
+ * loaded via `DatasetResultHandling === 'single_property'`, allowing in-place auto-refresh of
212
+ * dataset-backed engines (such as `TemplateEngineBase`) on entity save/delete events.
213
+ */
214
+ this._datasetItemMappings = new Map();
195
215
  /** Configs waiting for the cache manager to initialize before their callbacks can register. */
196
216
  this._deferredCacheCallbackConfigs = null;
197
217
  }
@@ -659,14 +679,19 @@ export class BaseEngine extends BaseSingleton {
659
679
  }
660
680
  if (event.type === 'delete' || event.type === 'save') {
661
681
  const eName = event.baseEntity.EntityInfo.Name.toLowerCase().trim();
662
- const matchingConfigs = this.Configs.filter((config) => {
682
+ const matchingConfigs = this._metadataConfigs.filter((config) => {
663
683
  return config.AutoRefresh && config.EntityName && config.EntityName.trim().toLowerCase() === eName;
664
684
  });
665
- if (matchingConfigs.length === 0) {
685
+ const matchingDatasetItems = (this._datasetItemMappings.get(eName) ?? []).filter(m => m.config.AutoRefresh !== false);
686
+ if (matchingConfigs.length === 0 && matchingDatasetItems.length === 0) {
666
687
  return true;
667
688
  }
668
- // Check if ALL matching configs can use immediate mutation
669
- const allCanUseImmediate = matchingConfigs.every(config => this.canUseImmediateMutation(config));
689
+ // Check if ALL matching entity configs can use immediate mutation. A saved object
690
+ // that lacks columns (its saver could not read them) would put a narrowed row into
691
+ // an engine every user reads, so that save takes the full-refresh path instead,
692
+ // which reloads the row as this engine's own user.
693
+ const allCanUseImmediate = matchingConfigs.every(config => this.canUseImmediateMutation(config))
694
+ && this.savedRowIsComplete(event);
670
695
  if (allCanUseImmediate) {
671
696
  const applyAll = async () => {
672
697
  // mutation requires await because the entity must be cloned (with its
@@ -674,6 +699,9 @@ export class BaseEngine extends BaseSingleton {
674
699
  for (const config of matchingConfigs) {
675
700
  await this.applyImmediateMutation(config, event);
676
701
  }
702
+ if (matchingDatasetItems.length > 0) {
703
+ await this.applyDatasetItemMutations(matchingDatasetItems, event);
704
+ }
677
705
  };
678
706
  // The entity raises save/delete as soon as its own write returns, while an
679
707
  // enclosing transaction may still be open. Mutating the cache now would keep a
@@ -700,6 +728,21 @@ export class BaseEngine extends BaseSingleton {
700
728
  return true;
701
729
  }
702
730
  else {
731
+ if (matchingDatasetItems.length > 0) {
732
+ const provider = event.baseEntity.ProviderToUse;
733
+ if (provider instanceof DatabaseProviderBase && provider.TransactionDepth > 0) {
734
+ const token = provider.CapturePostCommitToken();
735
+ const savedKey = event.type === 'save' ? event.baseEntity.PrimaryKey.ToString() : null;
736
+ provider.RunAfterCommit(async () => {
737
+ if (savedKey !== null && event.baseEntity.PrimaryKey.ToString() !== savedKey)
738
+ return;
739
+ await this.applyDatasetItemMutations(matchingDatasetItems, event);
740
+ }, `BaseEngine dataset cache mutation for ${eName}`, token);
741
+ }
742
+ else {
743
+ await this.applyDatasetItemMutations(matchingDatasetItems, event);
744
+ }
745
+ }
703
746
  // At least one config requires full refresh, use debouncing
704
747
  return this.DebounceIndividualBaseEntityEvent(event);
705
748
  }
@@ -726,7 +769,7 @@ export class BaseEngine extends BaseSingleton {
726
769
  if (!entityName) {
727
770
  return true; // No entity name, nothing to do
728
771
  }
729
- const matchingConfigs = this.Configs.filter((config) => {
772
+ const matchingConfigs = this._metadataConfigs.filter((config) => {
730
773
  return config.AutoRefresh && config.EntityName && config.EntityName.trim().toLowerCase() === entityName;
731
774
  });
732
775
  if (matchingConfigs.length === 0) {
@@ -875,7 +918,7 @@ export class BaseEngine extends BaseSingleton {
875
918
  const entityName = event.baseEntity.EntityInfo.Name.toLowerCase().trim();
876
919
  if (!this._entityEventSubjects.has(entityName)) {
877
920
  // Find matching config to get custom debounce time
878
- const matchingConfig = this.Configs.find(c => c.EntityName?.trim().toLowerCase() === entityName);
921
+ const matchingConfig = this._metadataConfigs.find(c => c.EntityName?.trim().toLowerCase() === entityName);
879
922
  // Use config-specific debounce time or fall back to default
880
923
  const debounceTimeValue = matchingConfig?.DebounceTime ?? this.EntityEventDebounceTime;
881
924
  const subject = new Subject();
@@ -947,13 +990,15 @@ export class BaseEngine extends BaseSingleton {
947
990
  try {
948
991
  const entityName = events[0].baseEntity.EntityInfo.Name.toLowerCase().trim();
949
992
  let refreshCount = 0;
950
- for (const config of this.Configs) {
993
+ for (const config of this._metadataConfigs) {
951
994
  if (config.AutoRefresh && config.Type === 'entity' && config.EntityName?.trim().toLowerCase() === entityName) {
952
995
  const classified = events.map(e => ({ event: e, disposition: this.classifyEventForConfig(config, e) }));
953
996
  const needingWork = classified.filter(c => c.disposition.action === 'refresh');
954
997
  if (needingWork.length > 0) {
955
998
  // Check if we can use immediate array mutation instead of running a view
956
- if (this.canUseImmediateMutation(config)) {
999
+ // A save carrying a row with missing columns can't replace an engine row
1000
+ // in place (see savedRowIsComplete), so the whole window refreshes instead.
1001
+ if (this.canUseImmediateMutation(config) && needingWork.every(c => this.savedRowIsComplete(c.event))) {
957
1002
  // Apply, in order, every event the array hasn't seen yet
958
1003
  for (const c of needingWork) {
959
1004
  await this.applyImmediateMutation(config, c.event);
@@ -1018,7 +1063,11 @@ export class BaseEngine extends BaseSingleton {
1018
1063
  classifyEventForConfig(config, event) {
1019
1064
  if (event.type === 'save') {
1020
1065
  if (event.saveSubType === 'update' || event.saveSubType === 'create') {
1021
- if (this.isEntityAlreadyInArray(config, event.baseEntity)) {
1066
+ // When AlternateBaseView is set on the config, the SQL view applies a canonical
1067
+ // WHERE/JOIN predicate — an in-memory update can cause the record to fall out of
1068
+ // the alternate view (e.g. Status changed from 'Active' to 'Inactive'), so we
1069
+ // must refresh from the database rather than only notifying in place.
1070
+ if (!config.AlternateBaseView && this.isEntityAlreadyInArray(config, event.baseEntity)) {
1022
1071
  return { action: 'notify', changeType: event.saveSubType === 'create' ? 'add' : 'update' };
1023
1072
  }
1024
1073
  }
@@ -1203,6 +1252,11 @@ export class BaseEngine extends BaseSingleton {
1203
1252
  if (config.Filter) {
1204
1253
  return false;
1205
1254
  }
1255
+ // An AlternateBaseView applies a canonical SQL WHERE/JOIN in the database view;
1256
+ // immediate in-memory mutations cannot evaluate that SQL predicate.
1257
+ if (config.AlternateBaseView) {
1258
+ return false;
1259
+ }
1206
1260
  // If there's an OrderBy, we can't safely do immediate mutations because:
1207
1261
  // - For creates: we'd need to insert at the correct position
1208
1262
  // - For updates: the entity might need to move to a different position
@@ -1241,6 +1295,21 @@ export class BaseEngine extends BaseSingleton {
1241
1295
  // it means the subclass has overridden it
1242
1296
  return instanceProto.AdditionalLoading !== baseProto.AdditionalLoading;
1243
1297
  }
1298
+ /**
1299
+ * Whether a save event's entity carries every column, so it can replace an engine row in
1300
+ * place. Deletes and non-save events always qualify.
1301
+ */
1302
+ savedRowIsComplete(event) {
1303
+ if (event.type !== 'save' || !event.baseEntity) {
1304
+ return true;
1305
+ }
1306
+ const entity = event.baseEntity;
1307
+ // Asked of the object directly, so no throwaway GetAll() row is built per event. An object
1308
+ // that is not a BaseEntity (a test double) is judged on its row, as before.
1309
+ return typeof entity.HoldsEveryField === 'function'
1310
+ ? entity.HoldsEveryField()
1311
+ : LocalCacheManager.RowHasEveryField(entity.EntityInfo, entity.GetAll());
1312
+ }
1244
1313
  /**
1245
1314
  * Applies an immediate array mutation based on the entity event type.
1246
1315
  * This is faster than running a full view refresh for simple add/update/delete operations.
@@ -1390,16 +1459,9 @@ export class BaseEngine extends BaseSingleton {
1390
1459
  if (cache.IsBatchingEntityEvents(entity?.ProviderToUse) || cache.WasTakenByEntityEventBatch(event)) {
1391
1460
  return;
1392
1461
  }
1393
- // Get the connection string from the provider for fingerprint generation
1394
- // The provider is needed because fingerprints include connection prefix
1395
- const provider = this.ProviderToUse;
1396
- let connectionString;
1397
- if (provider && 'InstanceConnectionString' in provider) {
1398
- connectionString = provider.InstanceConnectionString;
1399
- }
1400
- // Use the shared builder so the fingerprint matches what LoadSingleEntityConfig
1401
- // and RegisterCacheChangeCallbacks produce — prevents silent cache-slot mismatches
1402
- const fingerprint = LocalCacheManager.Instance.GenerateRunViewFingerprint(this.BuildRunViewParamsForConfig(config), connectionString);
1462
+ // The same builder as LoadSingleEntityConfig and RegisterCacheChangeCallbacks, so the
1463
+ // slot maintained here is the one this engine reads and listens on.
1464
+ const fingerprint = this.configFingerprint(config);
1403
1465
  // Build CompositeKey from the entity's primary key fields
1404
1466
  const key = entity.PrimaryKey;
1405
1467
  if (!key || key.KeyValuePairs.length === 0 || key.KeyValuePairs.some(kv => kv.Value == null)) {
@@ -1421,6 +1483,12 @@ export class BaseEngine extends BaseSingleton {
1421
1483
  }
1422
1484
  else {
1423
1485
  const entityData = entity.GetAll();
1486
+ // A row-filtered or field-secured slot, or a saved row missing columns, cannot take the
1487
+ // saver's values in place: clear it and let the next read refill it.
1488
+ if (!LocalCacheManager.Instance.CanUpsertRowIntoSlot(fingerprint, entity.EntityInfo, entityData)) {
1489
+ await LocalCacheManager.Instance.InvalidateRunViewResult(fingerprint);
1490
+ return;
1491
+ }
1424
1492
  await LocalCacheManager.Instance.UpsertSingleEntity(fingerprint, entityData, key, updatedAt);
1425
1493
  }
1426
1494
  }
@@ -1484,6 +1552,7 @@ export class BaseEngine extends BaseSingleton {
1484
1552
  // 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()
1485
1553
  const entityConfigs = this._metadataConfigs.filter(c => c.Type === 'entity');
1486
1554
  const datasetConfigs = this._metadataConfigs.filter(c => c.Type === 'dataset');
1555
+ this.WarnAboutFieldSecuredEntities(entityConfigs);
1487
1556
  // All-or-nothing permission check: if the user lacks read access on ANY
1488
1557
  // entity config, skip ALL entity configs for this engine. Returns the
1489
1558
  // original array unchanged when permissions pass, or an empty array when
@@ -1494,6 +1563,35 @@ export class BaseEngine extends BaseSingleton {
1494
1563
  // Register cross-server cache change callbacks for entity configs
1495
1564
  this.RegisterCacheChangeCallbacks(entityConfigs);
1496
1565
  }
1566
+ /** Engine-class and entity pairs already warned about by {@link WarnAboutFieldSecuredEntities}. */
1567
+ static { this._fieldSecurityWarned = new Set(); }
1568
+ /**
1569
+ * On a server, says once per engine class and entity when an entity this engine caches has
1570
+ * field-level security (its own, or an IS-A ancestor's). Changes nothing about the load.
1571
+ *
1572
+ * A server-side engine loads as the system user (see {@link ResolveContextUser}), so its
1573
+ * cache holds every column, including ones some users are denied. That is correct for a
1574
+ * process-wide cache, and it makes every path that hands engine data to a person responsible
1575
+ * for projecting it to that person first.
1576
+ */
1577
+ WarnAboutFieldSecuredEntities(entityConfigs) {
1578
+ const provider = this.ProviderToUse;
1579
+ if (provider?.ProviderType !== ProviderType.Database) {
1580
+ return;
1581
+ }
1582
+ for (const config of entityConfigs) {
1583
+ const entityInfo = config.EntityName ? provider.EntityByName(config.EntityName) : undefined;
1584
+ if (!entityInfo?.FieldLevelSecurityApplies) {
1585
+ continue;
1586
+ }
1587
+ const key = `${this.constructor.name}|${entityInfo.Name}`.toLowerCase();
1588
+ if (BaseEngine._fieldSecurityWarned.has(key)) {
1589
+ continue;
1590
+ }
1591
+ BaseEngine._fieldSecurityWarned.add(key);
1592
+ 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.`);
1593
+ }
1594
+ }
1497
1595
  /**
1498
1596
  * All-or-nothing permission gate: checks `CanRead` on every entity config. If ANY
1499
1597
  * entity is denied, ALL configs are skipped — the engine is marked permission-constrained
@@ -1508,6 +1606,29 @@ export class BaseEngine extends BaseSingleton {
1508
1606
  CheckPermissionsOrSkipAll(configs, contextUser) {
1509
1607
  if (configs.length === 0)
1510
1608
  return configs;
1609
+ // First check whether any configured entity is missing from metadata altogether
1610
+ // (e.g., an optional OpenApp engine registered in the bundle whose database schema
1611
+ // is not installed in the target environment). Skip cleanly instead of firing N
1612
+ // guaranteed-to-fail RunView calls and logging N error stack traces at startup.
1613
+ const missingEntities = this.findMissingEntities(configs);
1614
+ if (missingEntities.length > 0) {
1615
+ LogStatusEx({
1616
+ message: `${this.constructor.name}: Skipping ${configs.length} entity config(s) — entity metadata not installed for: ${missingEntities.join(', ')}`,
1617
+ verboseOnly: true,
1618
+ category: 'Cache',
1619
+ });
1620
+ for (const config of configs) {
1621
+ if (config.AddToObject !== false) {
1622
+ this[config.PropertyName] = [];
1623
+ }
1624
+ this._dataMap.set(config.PropertyName, {
1625
+ entityName: config.EntityName,
1626
+ data: [],
1627
+ loadedSuccessfully: true,
1628
+ });
1629
+ }
1630
+ return [];
1631
+ }
1511
1632
  // Determine the user to check permissions for — on the client side contextUser
1512
1633
  // may be undefined, so fall back to the current logged-in user from Metadata.
1513
1634
  const user = contextUser || this.ProviderToUse?.CurrentUser;
@@ -1537,6 +1658,26 @@ export class BaseEngine extends BaseSingleton {
1537
1658
  }
1538
1659
  return configs;
1539
1660
  }
1661
+ /**
1662
+ * Returns entity names among `configs` that do not exist in the loaded metadata provider.
1663
+ * Only checks when the metadata provider actually has entities loaded (`md.Entities.length > 0`)
1664
+ * so unit tests with empty mock providers are unaffected.
1665
+ */
1666
+ findMissingEntities(configs) {
1667
+ const md = this.ProviderToUse;
1668
+ if (!md?.Entities || md.Entities.length === 0) {
1669
+ return [];
1670
+ }
1671
+ const missing = [];
1672
+ for (const config of configs) {
1673
+ if (!config.EntityName)
1674
+ continue;
1675
+ if (!md.EntityByName(config.EntityName)) {
1676
+ missing.push(config.EntityName);
1677
+ }
1678
+ }
1679
+ return missing;
1680
+ }
1540
1681
  /** The entity names among `configs` that `user` cannot read, per current metadata. */
1541
1682
  findUnreadableEntities(configs, user) {
1542
1683
  const md = this.ProviderToUse;
@@ -1602,14 +1743,8 @@ export class BaseEngine extends BaseSingleton {
1602
1743
  * IgnoreMaxRows, IncludeBinaryFields) are always consistent — preventing cache key mismatches that break
1603
1744
  * cross-server invalidation via Redis pub/sub and local cache upsert/remove operations.
1604
1745
  *
1605
- * **Row-level security is not part of the fingerprints built from these params.** The engine
1606
- * builds them with `GenerateRunViewFingerprint(params, connection)`, without the RLS clause the
1607
- * provider adds for the reading user. On a server that is correct: engines load as the system
1608
- * user, whose RLS clause is empty, so the provider's fingerprint for the same read matches. An
1609
- * engine loaded by an RLS-scoped user (possible on a client) would compute an unscoped
1610
- * fingerprint for a slot the provider stored under a scoped one, so its cache callbacks and
1611
- * `syncLocalCacheForConfig` would target a different slot. Pass the RLS clause here if such an
1612
- * engine is ever needed.
1746
+ * The slot fingerprint for these params also carries the engine user's row-level-security
1747
+ * and field-security segments; see {@link configFingerprint}.
1613
1748
  */
1614
1749
  BuildRunViewParamsForConfig(config, bypassCache = false) {
1615
1750
  return {
@@ -1617,6 +1752,7 @@ export class BaseEngine extends BaseSingleton {
1617
1752
  ResultType: config.ResultType || this.EngineDefaultResultType,
1618
1753
  ExtraFilter: config.Filter,
1619
1754
  OrderBy: config.OrderBy,
1755
+ ...(config.AlternateBaseView ? { AlternateBaseView: config.AlternateBaseView } : {}),
1620
1756
  IgnoreMaxRows: true, // Engines always need ALL data — bypass entity-level UserViewMaxRows caps
1621
1757
  _fromEngine: true, // Mark as engine-initiated to avoid false positive telemetry warnings
1622
1758
  CacheLocal: config.CacheLocal,
@@ -1777,6 +1913,58 @@ export class BaseEngine extends BaseSingleton {
1777
1913
  }
1778
1914
  }
1779
1915
  }
1916
+ /**
1917
+ * Applies an immediate in-place mutation to dataset item arrays when a constituent entity is saved or deleted,
1918
+ * then invokes {@link AdditionalLoading} so derived associations (e.g. Template Content / Params) stay current.
1919
+ */
1920
+ async applyDatasetItemMutations(mappings, event) {
1921
+ const entity = event.baseEntity;
1922
+ let mutated = false;
1923
+ const notifiedProperties = new Set();
1924
+ for (const mapping of mappings) {
1925
+ const container = this[mapping.config.PropertyName];
1926
+ const currentData = container?.[mapping.itemCode];
1927
+ if (!Array.isArray(currentData)) {
1928
+ continue;
1929
+ }
1930
+ if (event.type === 'save') {
1931
+ const provider = this.ProviderToUse;
1932
+ if (!provider)
1933
+ continue;
1934
+ const cached = await provider.GetEntityObject(mapping.entityName, this._contextUser);
1935
+ if (!cached)
1936
+ continue;
1937
+ await cached.LoadFromData(entity.GetAll());
1938
+ const existsByRef = currentData.indexOf(entity) >= 0;
1939
+ if (!existsByRef) {
1940
+ const index = this.findEntityIndexByPrimaryKeys(currentData, entity);
1941
+ if (index >= 0) {
1942
+ currentData[index] = cached;
1943
+ }
1944
+ else {
1945
+ currentData.push(cached);
1946
+ }
1947
+ }
1948
+ mutated = true;
1949
+ notifiedProperties.add(mapping.config.PropertyName);
1950
+ }
1951
+ else if (event.type === 'delete') {
1952
+ const oldValues = event.payload?.OldValues;
1953
+ const index = this.indexOfEntityByRefOrKey(currentData, entity, oldValues);
1954
+ if (index >= 0) {
1955
+ currentData.splice(index, 1);
1956
+ mutated = true;
1957
+ notifiedProperties.add(mapping.config.PropertyName);
1958
+ }
1959
+ }
1960
+ }
1961
+ if (mutated) {
1962
+ await this.AdditionalLoading(this._contextUser);
1963
+ for (const propName of notifiedProperties) {
1964
+ this.emitPropertyChange(propName);
1965
+ }
1966
+ }
1967
+ }
1780
1968
  /**
1781
1969
  * Handles the process of loading a single config of type 'dataset'.
1782
1970
  * @param config
@@ -1796,7 +1984,7 @@ export class BaseEngine extends BaseSingleton {
1796
1984
  await p.CacheDataset(config.DatasetName, config.DatasetItemFilters, result);
1797
1985
  }
1798
1986
  else {
1799
- result = await p.GetAndCacheDatasetByName(config.DatasetName, config.DatasetItemFilters);
1987
+ result = await p.GetAndCacheDatasetByName(config.DatasetName, config.DatasetItemFilters, contextUser);
1800
1988
  }
1801
1989
  if (!result) {
1802
1990
  const errorMsg = `GetAndCacheDatasetByName("${config.DatasetName}") returned undefined/null — provider: ${p?.constructor?.name}`;
@@ -1823,18 +2011,31 @@ export class BaseEngine extends BaseSingleton {
1823
2011
  if (result.Success) {
1824
2012
  if (config.AddToObject !== false) {
1825
2013
  if (config.DatasetResultHandling === 'single_property') {
2014
+ // Clear previous dataset item mappings for this property before re-indexing
2015
+ for (const [key, list] of this._datasetItemMappings.entries()) {
2016
+ const filtered = list.filter(m => m.config.PropertyName !== config.PropertyName);
2017
+ if (filtered.length === 0) {
2018
+ this._datasetItemMappings.delete(key);
2019
+ }
2020
+ else if (filtered.length !== list.length) {
2021
+ this._datasetItemMappings.set(key, filtered);
2022
+ }
2023
+ }
1826
2024
  const singleObject = {};
1827
- for (const item of result.Results) {
1828
- //convert the results to entity objects before
1829
- //adding them to the singleObject
1830
- const entities = [];
1831
- for (const entityData of item.Results) {
2025
+ await Promise.all(result.Results.map(async (item) => {
2026
+ const entities = await Promise.all(item.Results.map(async (entityData) => {
1832
2027
  const entity = await p.GetEntityObject(item.EntityName, contextUser);
1833
2028
  await entity.LoadFromData(entityData);
1834
- entities.push(entity);
1835
- }
2029
+ return entity;
2030
+ }));
1836
2031
  singleObject[item.Code] = entities;
1837
- }
2032
+ if (item.EntityName && item.Code) {
2033
+ const normName = item.EntityName.trim().toLowerCase();
2034
+ const existing = this._datasetItemMappings.get(normName) ?? [];
2035
+ existing.push({ config, itemCode: item.Code, entityName: item.EntityName });
2036
+ this._datasetItemMappings.set(normName, existing);
2037
+ }
2038
+ }));
1838
2039
  this[config.PropertyName] = singleObject;
1839
2040
  }
1840
2041
  else {
@@ -1925,6 +2126,14 @@ export class BaseEngine extends BaseSingleton {
1925
2126
  LogStatusEx({ message: `BaseEngine.OnExternalCacheChange: '${config.PropertyName}' already holds the payload's rows — skipped`, verboseOnly: true });
1926
2127
  return;
1927
2128
  }
2129
+ // Rows written under a different field list (a server on the other side of a
2130
+ // migration) lack columns this process has, or carry ones it does not. Loading
2131
+ // them would blank the missing columns on every row, so read the rows instead.
2132
+ if (this.payloadSchemaDiffers(config, parsed.schemaHash)) {
2133
+ LogStatusEx({ message: `BaseEngine.OnExternalCacheChange: payload for '${config.PropertyName}' was written under a different schema — reloading instead`, verboseOnly: true });
2134
+ await this.reloadAfterExternalChange(config);
2135
+ return;
2136
+ }
1928
2137
  // Claim a refresh generation BEFORE the awaited materialization — the same
1929
2138
  // protocol LoadSingleConfig uses around its awaited RunView. Without it, two
1930
2139
  // overlapping cache events (or an event racing a full reload) can resolve out
@@ -1977,13 +2186,54 @@ export class BaseEngine extends BaseSingleton {
1977
2186
  LogWarning(`BaseEngine.OnExternalCacheChange: payload for '${config.PropertyName}' could not be applied (${e instanceof Error ? e.message : String(e)}) — falling back to full reload`, 'Cache');
1978
2187
  }
1979
2188
  }
1980
- // Fallback: reload this config from the database. This also replaces the property, so the
1981
- // derived state has to be rebuilt here too — and the reload must NOT emit on its own, or a
1982
- // subscriber wakes between the new rows and the rebuild and reads derived state still
1983
- // pointing at the instances this reload discarded.
1984
- await this.LoadSingleConfig(config, this._contextUser, /*bypassCache*/ false, /*suppressEmit*/ true);
1985
- await this.RebuildDerivedState(this._contextUser);
1986
- this.emitPropertyChange(config.PropertyName);
2189
+ await this.reloadAfterExternalChange(config);
2190
+ }
2191
+ /**
2192
+ * True when a cache-change payload carries a schema hash and it differs from this engine's
2193
+ * hash for the config's entity. A payload without a hash, or an entity this process cannot
2194
+ * resolve, does not count as differing.
2195
+ */
2196
+ payloadSchemaDiffers(config, payloadSchemaHash) {
2197
+ if (typeof payloadSchemaHash !== 'string' || !payloadSchemaHash || !config.EntityName) {
2198
+ return false;
2199
+ }
2200
+ const ownHash = LocalCacheManager.Instance.ComputeSchemaHash(this.ProviderToUse, config.EntityName);
2201
+ return ownHash !== undefined && ownHash !== payloadSchemaHash;
2202
+ }
2203
+ /**
2204
+ * Reloads a config after a peer's cache change, coalescing overlapping requests: an event that
2205
+ * arrives while a reload for the same property is running asks for one more reload after it
2206
+ * instead of starting its own. Every caller's promise settles only after a reload that began
2207
+ * after its event, so no change is missed, but N events in a burst cost at most two reloads.
2208
+ */
2209
+ reloadAfterExternalChange(config) {
2210
+ const running = this._externalChangeReloads.get(config.PropertyName);
2211
+ if (running) {
2212
+ running.Again = true;
2213
+ return running.Done;
2214
+ }
2215
+ const reload = { Again: false, Done: Promise.resolve() };
2216
+ this._externalChangeReloads.set(config.PropertyName, reload);
2217
+ reload.Done = this.runExternalChangeReloads(config, reload);
2218
+ return reload.Done;
2219
+ }
2220
+ /** Runs reloads for {@link reloadAfterExternalChange} until no further event asked for one. */
2221
+ async runExternalChangeReloads(config, reload) {
2222
+ try {
2223
+ do {
2224
+ reload.Again = false;
2225
+ // The reload replaces the property, so the derived state has to be rebuilt too —
2226
+ // and the reload must NOT emit on its own, or a subscriber wakes between the new
2227
+ // rows and the rebuild and reads derived state still pointing at the instances
2228
+ // this reload discarded.
2229
+ await this.LoadSingleConfig(config, this._contextUser, /*bypassCache*/ false, /*suppressEmit*/ true);
2230
+ await this.RebuildDerivedState(this._contextUser);
2231
+ this.emitPropertyChange(config.PropertyName);
2232
+ } while (reload.Again);
2233
+ }
2234
+ finally {
2235
+ this._externalChangeReloads.delete(config.PropertyName);
2236
+ }
1987
2237
  }
1988
2238
  /**
1989
2239
  * True when `payloadRows` are the same row versions this config currently holds: the same
@@ -2231,11 +2481,26 @@ export class BaseEngine extends BaseSingleton {
2231
2481
  // read-modify-write of this slot, and an unlocked replace would clobber it.
2232
2482
  await cache.ReplaceRunViewResultLocked(this.configFingerprint(config), params, plainRows, maxUpdatedAt, plainRows.length, this.ProviderToUse);
2233
2483
  }
2234
- /** The cache fingerprint of a config's slot, as {@link RegisterCacheChangeCallbacks} computes it. */
2484
+ /**
2485
+ * The cache fingerprint of a config's slot: the one {@link RegisterCacheChangeCallbacks} listens
2486
+ * on, {@link syncLocalCacheForConfig} maintains and {@link storeConfigRows} writes.
2487
+ *
2488
+ * Built by the provider for this engine's user, with that user's row-level-security and
2489
+ * field-security segments, so it names the same slot the provider's own RunView read used. An
2490
+ * engine running as a restricted user holds only what that user may see; without the segments
2491
+ * its rows would be written into, and its callbacks registered on, the slot unrestricted
2492
+ * readers share.
2493
+ */
2235
2494
  configFingerprint(config) {
2495
+ const params = this.BuildRunViewParamsForConfig(config);
2236
2496
  const provider = this.RunViewProviderToUse;
2237
- const connectionPrefix = provider instanceof ProviderBase ? provider.InstanceConnectionString : undefined;
2238
- return LocalCacheManager.Instance.GenerateRunViewFingerprint(this.BuildRunViewParamsForConfig(config), connectionPrefix);
2497
+ if (provider instanceof ProviderBase) {
2498
+ return provider.ComputeRunViewCacheFingerprint(params, this._contextUser);
2499
+ }
2500
+ const connection = provider && 'InstanceConnectionString' in provider
2501
+ ? provider.InstanceConnectionString
2502
+ : undefined;
2503
+ return LocalCacheManager.Instance.GenerateRunViewFingerprint(params, connection);
2239
2504
  }
2240
2505
  /**
2241
2506
  * Snapshot of what this engine holds: per property, the row count, newest `__mj_UpdatedAt`,