@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
@@ -8,16 +8,17 @@ var __metadata = (this && this.__metadata) || function (k, v) {
8
8
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
9
  };
10
10
  var EntityField_1;
11
- import { Base64DecodedByteLength, DeserializeValidationErrors, Float32VectorToBase64, IsValidBase64, IsMemberOverridden, MJEventType, MJGlobal, NormalizeUUID, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, WarningManager, ComputeContentHashAsync } from '@memberjunction/global';
11
+ import { Base64DecodedByteLength, DeserializeValidationErrors, Float32VectorToBase64, IsValidBase64, IsMemberOverridden, MJEventType, MJGlobal, NormalizeUUID, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, UserCacheKeySegment, WarningManager, ComputeContentHashAsync, EscapeSQLString } from '@memberjunction/global';
12
12
  import { GetDataHooks } from './dataHooks.js';
13
13
  import { EntityFieldInfo, EntityInfo, EntityFieldTSType, EntityPermissionType, FieldSecurityError, RecordChange, ValidationErrorInfo, ValidationResult } from './entityInfo.js';
14
14
  import { EntitySubtypeResolver } from './entitySubtypeResolver.js';
15
+ import { CanonicalPrimaryKeyValue } from './util.js';
15
16
  import { BaseEngineRegistry } from './baseEngineRegistry.js';
16
17
  import { IsPermittedImageFieldValue, IsValidCssColor, TryParseJsonText } from './extendedTypeValue.js';
17
18
  import { EntityDeleteOptions, EntitySaveOptions, ProviderType } from './interfaces.js';
18
19
  import { Metadata } from './metadata.js';
19
20
  import { RunView } from '../views/runView.js';
20
- import { LogDebug, LogError, LogStatusEx } from './logging.js';
21
+ import { LogDebug, LogError, LogStatusEx, LogWarning } from './logging.js';
21
22
  import { CompositeKey, KeyValuePair } from './compositeKey.js';
22
23
  import { RelatedRecordCollection } from './relatedRecordCollection.js';
23
24
  import { COMPANION_PAYLOAD_KEY } from './entityCompanion.js';
@@ -25,6 +26,7 @@ import { EmbeddedRecord } from './embeddedRecord.js';
25
26
  import { EntitySavePlan, ExecuteEntitySavePlan } from './entitySavePlan.js';
26
27
  import { JSONFieldBinding, ValidateJSONFieldValue } from './jsonFieldBinding.js';
27
28
  import { BaseRemotableOperation } from './baseRemotableOperation.js';
29
+ import { OmitDeniedFieldKeys, OmitFieldsUnreadableByActiveUser } from './entityValueFieldSecurity.js';
28
30
  import { SAVE_ENTITY_GRAPH_OPERATION_KEY, } from './saveEntityGraphOperation.js';
29
31
  import { finalize, firstValueFrom, from, of, shareReplay, Subject, switchMap } from 'rxjs';
30
32
  /**
@@ -79,8 +81,9 @@ let EntityField = class EntityField {
79
81
  * `InnerLoad` — reusing it would conflate defaults with omissions. This flag is set only
80
82
  * by the hydration paths (via {@link MarkNotLoaded}) when a source omits the key, is
81
83
  * cleared by ANY explicit set (an intentional blind write to a read-denied field is a
82
- * legitimate write-only update and must save), and is never present on new (unhydrated)
83
- * entities — their fields legitimately hold metadata defaults for INSERT.
84
+ * legitimate write-only update and must save). On a new record it marks only the fields the
85
+ * record's user may not read; every other field legitimately holds its metadata default for
86
+ * INSERT.
84
87
  */
85
88
  get NotLoaded() {
86
89
  return this._notLoaded;
@@ -128,6 +131,18 @@ let EntityField = class EntityField {
128
131
  SetCreateSuppressed(suppressed) {
129
132
  this._createSuppressed = suppressed;
130
133
  }
134
+ /**
135
+ * Framework-internal: resets all mutable field state in place to match a freshly constructed
136
+ * `EntityField` instance without allocating a new object or invalidating `BaseEntity` field maps.
137
+ */
138
+ ResetForInPlaceHydration() {
139
+ this._oldValue = null;
140
+ this._value = null;
141
+ this._neverSet = true;
142
+ this._notLoaded = false;
143
+ this._writtenAfterNotLoaded = false;
144
+ this._createSuppressed = false;
145
+ }
131
146
  get Name() {
132
147
  return this._entityFieldInfo.Name;
133
148
  }
@@ -183,7 +198,18 @@ let EntityField = class EntityField {
183
198
  return this._entityFieldInfo.HasDefaultValue;
184
199
  }
185
200
  /**
186
- * Sets the value of the field. If the field is read only, nothing happens. If the field is not read only, the value is set and the internal representation of the dirty flag is flipped if the value is different from the old value.
201
+ * Sets the value of the field.
202
+ *
203
+ * - A read-only field accepts ONE set after construction or {@link ResetNeverSetFlag} (how a
204
+ * load populates it); later sets are ignored.
205
+ * - Dirty state is not stored here: {@link Dirty} compares the value with {@link OldValue} when
206
+ * it is read. A set during record setup (the first set of a never-set field) also becomes
207
+ * the old value, so it is not dirty.
208
+ * - A set onto a {@link NotLoaded} field clears that flag and is always dirty, whatever the
209
+ * value, even one equal to the field's construction state. The write therefore reaches the
210
+ * save and its update-permission check.
211
+ * - No field-level security or active-status check happens here. Those live at
212
+ * `BaseEntity.Get`/`Set` and at the save; this accessor is framework-internal.
187
213
  */
188
214
  set Value(value) {
189
215
  // No active-status assertion here — see the Value getter note. Deprecation/disabled checks
@@ -527,6 +553,11 @@ let EntityField = class EntityField {
527
553
  }
528
554
  /**
529
555
  * This method will set the internal Old Value which is used to track dirty state, to the current value of the field. This effectively resets the dirty state of the field to false. Use this method sparingly.
556
+ *
557
+ * It also clears the written-after-not-loaded flag: the current value becomes the field's
558
+ * loaded baseline. The hydration paths rely on that (a reload that supplies the field must not
559
+ * leave it dirty), so code outside hydration must not call this to "accept" a write onto a
560
+ * not-loaded field, or the write would be saved without being seen as a change.
530
561
  */
531
562
  ResetOldValue() {
532
563
  this._oldValue = this.Value;
@@ -1059,6 +1090,7 @@ export class BaseEntity {
1059
1090
  * pay nothing for the feature, not even an empty Map per instance.
1060
1091
  */
1061
1092
  this._companions = null;
1093
+ this._finalizeGeneration = 0;
1062
1094
  /**
1063
1095
  * Returns true if the object is Dirty, meaning something has changed since it was last saved to the database, and false otherwise. For new records, this will always return true.
1064
1096
  *
@@ -1649,20 +1681,35 @@ export class BaseEntity {
1649
1681
  return e instanceof Error ? e.message : String(e);
1650
1682
  }
1651
1683
  /**
1652
- * The create path's SubtypeSelector lookup memo, partitioned by the provider that ran the lookup. A memo
1653
- * shared across providers would hand provider B a row loaded through provider A (another connection,
1654
- * another user's context) or a `null` miss recorded against A's database. Keyed weakly so a
1655
- * request-scoped provider's memo is collected with it.
1684
+ * The create path's SubtypeSelector lookup memo, partitioned by the provider that ran the lookup and
1685
+ * then by the user it ran for. A memo shared across providers would hand provider B a row loaded
1686
+ * through provider A (another connection) or a `null` miss recorded against A's database. A memo
1687
+ * shared across users would hand one user an entity object shaped to another: an object holds only
1688
+ * the fields its own user may read, so a record loaded for an administrator carries values a
1689
+ * restricted user is denied. Keyed weakly so a request-scoped provider's memo is collected with it.
1656
1690
  */
1657
1691
  static { this._subtypeLookupCache = new WeakMap(); }
1658
1692
  /** Partition key for lookups made by an entity that has no provider at all. */
1659
1693
  static { this._noProviderSubtypeKey = {}; }
1660
- static subtypeLookupCacheFor(provider) {
1694
+ /**
1695
+ * The memo for one provider and user, or null when no user resolves: an entry keyed without a
1696
+ * user would be shared by every caller, so such a lookup is not memoized at all.
1697
+ */
1698
+ static subtypeLookupCacheFor(provider, user) {
1699
+ const userKey = UserCacheKeySegment(user?.ID);
1700
+ if (userKey === null) {
1701
+ return null;
1702
+ }
1661
1703
  const partitionKey = provider ?? BaseEntity._noProviderSubtypeKey;
1662
- let cache = BaseEntity._subtypeLookupCache.get(partitionKey);
1704
+ let byUser = BaseEntity._subtypeLookupCache.get(partitionKey);
1705
+ if (!byUser) {
1706
+ byUser = new Map();
1707
+ BaseEntity._subtypeLookupCache.set(partitionKey, byUser);
1708
+ }
1709
+ let cache = byUser.get(userKey);
1663
1710
  if (!cache) {
1664
1711
  cache = new Map();
1665
- BaseEntity._subtypeLookupCache.set(partitionKey, cache);
1712
+ byUser.set(userKey, cache);
1666
1713
  }
1667
1714
  return cache;
1668
1715
  }
@@ -1866,12 +1913,16 @@ export class BaseEntity {
1866
1913
  return null;
1867
1914
  let currentEntity = this;
1868
1915
  let currentEntityInfo = this.EntityInfo;
1916
+ const actingUser = this.resolveActiveUserOrNull();
1869
1917
  for (let i = 0; i < segments.length - 1; i++) {
1870
1918
  const fieldName = segments[i];
1871
1919
  const fieldInfo = currentEntityInfo.Fields.find(f => f.Name.trim().toLowerCase() === fieldName.toLowerCase());
1872
1920
  if (!fieldInfo) {
1873
1921
  throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': field '${fieldName}' was not found on entity '${currentEntityInfo.Name}'.`);
1874
1922
  }
1923
+ if (!this.subtypeSelectorMayRead(currentEntity, fieldInfo.Name, actingUser)) {
1924
+ return null; // a hop the user may not read gives no subtype, as a denied name field gives no name
1925
+ }
1875
1926
  const fkValue = currentEntity.Get(fieldInfo.Name);
1876
1927
  if (fkValue == null || fkValue === '') {
1877
1928
  return null;
@@ -1892,34 +1943,54 @@ export class BaseEntity {
1892
1943
  if (!terminalField) {
1893
1944
  throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': terminal field '${terminalSegment}' was not found on entity '${currentEntityInfo.Name}'.`);
1894
1945
  }
1946
+ if (!this.subtypeSelectorMayRead(currentEntity, terminalField.Name, actingUser)) {
1947
+ return null;
1948
+ }
1895
1949
  const terminalValue = currentEntity.Get(terminalField.Name);
1896
1950
  if (terminalValue == null || typeof terminalValue !== 'string' || terminalValue.trim() === '') {
1897
1951
  return null;
1898
1952
  }
1899
1953
  return terminalValue.trim();
1900
1954
  }
1955
+ /**
1956
+ * Whether a `SubtypeSelector` hop may read `fieldName` on `record`, which is this record or
1957
+ * one the path reached. Both the record's own user and the user resolving the subtype must be
1958
+ * allowed: a record reached through an engine cache was loaded as the system user and holds
1959
+ * every column, so its own answer says nothing about the acting user.
1960
+ */
1961
+ subtypeSelectorMayRead(record, fieldName, actingUser) {
1962
+ if (!record.IsFieldReadableByActiveUser(fieldName)) {
1963
+ return false;
1964
+ }
1965
+ return record === this || !actingUser || record.EntityInfo.IsFieldReadableByUser(fieldName, actingUser);
1966
+ }
1901
1967
  async getSubtypePathTargetEntity(entityName, pkValue) {
1902
1968
  const cacheKey = `${entityName.trim().toLowerCase()}|${String(pkValue).trim().toLowerCase()}`;
1903
1969
  const provider = this.ProviderToUse;
1904
- const lookupCache = BaseEntity.subtypeLookupCacheFor(provider);
1905
- if (lookupCache.has(cacheKey)) {
1970
+ const lookupCache = BaseEntity.subtypeLookupCacheFor(provider, this.resolveActiveUserOrNull());
1971
+ if (lookupCache?.has(cacheKey)) {
1906
1972
  return lookupCache.get(cacheKey) ?? null;
1907
1973
  }
1908
- // 1. Check BaseEngineRegistry for loaded cached entities
1974
+ const found = await this.findSubtypePathTarget(provider, entityName, pkValue);
1975
+ lookupCache?.set(cacheKey, found);
1976
+ return found;
1977
+ }
1978
+ /**
1979
+ * Finds the record a create-time `SubtypeSelector` hop points at: in a loaded engine cache
1980
+ * first, then by loading it through the provider for this record's user. Null when neither
1981
+ * has it, or the load fails.
1982
+ */
1983
+ async findSubtypePathTarget(provider, entityName, pkValue) {
1909
1984
  const cached = BaseEntity.findSubtypePathTargetInEngineCache(entityName, pkValue);
1910
1985
  if (cached) {
1911
- lookupCache.set(cacheKey, cached);
1912
1986
  return cached;
1913
1987
  }
1914
- // 2. Fall back to loading via provider
1915
1988
  if (!provider?.GetEntityObject) {
1916
- lookupCache.set(cacheKey, null);
1917
1989
  return null;
1918
1990
  }
1919
1991
  try {
1920
1992
  const targetObj = await provider.GetEntityObject(entityName, this._contextCurrentUser);
1921
1993
  if (!targetObj) {
1922
- lookupCache.set(cacheKey, null);
1923
1994
  return null;
1924
1995
  }
1925
1996
  let key;
@@ -1931,17 +2002,11 @@ export class BaseEntity {
1931
2002
  const pkName = targetObj.FirstPrimaryKey.Name; // first-pk-ok: FK target — pkValue is one SubtypeSelector FK column's value
1932
2003
  key.KeyValuePairs.push(new KeyValuePair(pkName, pkValue));
1933
2004
  }
1934
- const loaded = await targetObj.InnerLoad(key);
1935
- if (loaded) {
1936
- lookupCache.set(cacheKey, targetObj);
1937
- return targetObj;
1938
- }
2005
+ return (await targetObj.InnerLoad(key)) ? targetObj : null;
1939
2006
  }
1940
2007
  catch {
1941
- // On load failure, return null
2008
+ return null; // on load failure, no target
1942
2009
  }
1943
- lookupCache.set(cacheKey, null);
1944
- return null;
1945
2010
  }
1946
2011
  /**
1947
2012
  * Finds the record a `SubtypeSelector` hop points at among the entity objects that loaded
@@ -2142,23 +2207,102 @@ export class BaseEntity {
2142
2207
  * (and potentially parent entities in the IS-A chain).
2143
2208
  */
2144
2209
  Hydrate(data) {
2145
- // Reset this entity to pristine state: clears _compositeKey, _recordLoaded,
2146
- // _everSaved, and recreates all EntityField instances with _NeverSet = true
2147
- this.init();
2148
2210
  this.discardJSONFieldObjects();
2211
+ // Field security: denied values never enter this object (see narrowHydrationSource).
2212
+ data = this.narrowHydrationSource(data);
2149
2213
  // Recursively hydrate parent entities first (deepest ancestor resets first).
2150
- // After init(), parent fields have fresh _NeverSet=true, so SetMany can set PKs.
2151
2214
  if (this._parentEntity) {
2152
2215
  this._parentEntity.Hydrate(data);
2153
2216
  }
2154
- // Populate this entity's fields. SetMany also routes parent field values to
2155
- // _parentEntity via the IS-A routing block (which now includes PK fields).
2156
- // replaceOldValues=true ensures OldValue matches Value (no false dirty flags).
2157
- // ignoreNonExistentFields=true remains as a safety net; ownedFieldsFrom already
2158
- // dropped columns that belong to another level of the IS-A chain.
2159
- this.SetMany(this.ownedFieldsFrom(data), true, true, true);
2160
- // Hydrate is a hydration entry point: keys the source omitted are not-loaded.
2161
- this.markFieldsOmittedBySourceAsNotLoaded(data);
2217
+ // Populate this entity's fields in place without destroying hydrated EntityField instances
2218
+ // or invalidating _fieldCache / _codeNameCache.
2219
+ this.resetAndApplyOwnedFieldsInPlace(data);
2220
+ }
2221
+ /**
2222
+ * Resets transient record state and applies `fieldData` to this entity's own fields in a
2223
+ * single pass over `this._fields`, reusing existing `EntityField` instances and preserving
2224
+ * `_fieldCache` / `_codeNameCache` when already hydrated.
2225
+ *
2226
+ * Equivalent to `init() + SetMany(ownedFieldsFrom(fieldData), true, true, true) + markFieldsOmittedBySourceAsNotLoaded(fieldData)`
2227
+ * with zero `EntityField` reallocations and zero intermediate `ownedFieldsFrom` object allocation.
2228
+ */
2229
+ resetAndApplyOwnedFieldsInPlace(fieldData) {
2230
+ this._finalizeGeneration++;
2231
+ this._compositeKey = null;
2232
+ this._resultHistory = [];
2233
+ this._recordLoaded = false;
2234
+ this._raw = null;
2235
+ this._rawConverted = null;
2236
+ this.hydrateFieldsIfNeeded();
2237
+ if (!fieldData || typeof fieldData !== 'object' || Array.isArray(fieldData)) {
2238
+ for (let i = 0; i < this._fields.length; i++) {
2239
+ const field = this._fields[i];
2240
+ field.ResetForInPlaceHydration();
2241
+ field.ClearAsNotLoaded();
2242
+ }
2243
+ return;
2244
+ }
2245
+ let normalizedMap = null;
2246
+ let sharedParentMirrors = null;
2247
+ const parentFieldNames = this._parentEntity ? this._parentEntityFieldNames : null;
2248
+ for (let i = 0; i < this._fields.length; i++) {
2249
+ const field = this._fields[i];
2250
+ const match = BaseEntity.findOwnedFieldValueInSource(field, fieldData, () => {
2251
+ if (normalizedMap === null) {
2252
+ normalizedMap = BaseEntity.buildNormalizedHydrationMap(fieldData);
2253
+ }
2254
+ return normalizedMap;
2255
+ });
2256
+ field.ResetForInPlaceHydration();
2257
+ if (match.found) {
2258
+ this.setFieldValue(field, match.val);
2259
+ field.ResetOldValue();
2260
+ if (parentFieldNames) {
2261
+ if (parentFieldNames.has(match.key)) {
2262
+ (sharedParentMirrors ??= {})[match.key] = match.val;
2263
+ }
2264
+ else if (parentFieldNames.has(field.Name)) {
2265
+ (sharedParentMirrors ??= {})[field.Name] = match.val;
2266
+ }
2267
+ }
2268
+ }
2269
+ else {
2270
+ field.ClearAsNotLoaded();
2271
+ }
2272
+ }
2273
+ if (sharedParentMirrors && this._parentEntity) {
2274
+ this._parentEntity.SetMany(sharedParentMirrors, true, true, true);
2275
+ }
2276
+ this.updateSavedStateFromPrimaryKeys();
2277
+ }
2278
+ static findOwnedFieldValueInSource(field, fieldData, getNormalizedMap) {
2279
+ if (BaseEntity.ownValuePresent(fieldData, field.Name)) {
2280
+ return { found: true, key: field.Name, val: fieldData[field.Name] };
2281
+ }
2282
+ if (field.CodeName !== field.Name && BaseEntity.ownValuePresent(fieldData, field.CodeName)) {
2283
+ return { found: true, key: field.CodeName, val: fieldData[field.CodeName] };
2284
+ }
2285
+ const normalizedMap = getNormalizedMap();
2286
+ const hit = normalizedMap.get(field.Name.trim().toLowerCase()) ??
2287
+ (field.CodeName !== field.Name
2288
+ ? normalizedMap.get(field.CodeName.trim().toLowerCase())
2289
+ : undefined);
2290
+ if (hit !== undefined) {
2291
+ return { found: true, key: hit.key, val: hit.val };
2292
+ }
2293
+ return { found: false, key: field.Name, val: undefined };
2294
+ }
2295
+ static buildNormalizedHydrationMap(fieldData) {
2296
+ const normalizedMap = new Map();
2297
+ const keys = Object.keys(fieldData);
2298
+ for (let kIdx = 0; kIdx < keys.length; kIdx++) {
2299
+ const k = keys[kIdx];
2300
+ const v = fieldData[k];
2301
+ if (v !== undefined) {
2302
+ normalizedMap.set(k.trim().toLowerCase(), { key: k, val: v });
2303
+ }
2304
+ }
2305
+ return normalizedMap;
2162
2306
  }
2163
2307
  /**
2164
2308
  * Columns on `data` that this entity actually defines (by field name or CodeName).
@@ -2410,7 +2554,9 @@ export class BaseEntity {
2410
2554
  }
2411
2555
  const result = await operation.Execute({
2412
2556
  EntityName: this.EntityInfo.Name,
2413
- Fields: this.GetAll(),
2557
+ // Without the fields this user may not read: the server refuses a restricted
2558
+ // caller's input that names one, as the transaction-group serializer knows.
2559
+ Fields: OmitFieldsUnreadableByActiveUser(this, this.GetAll()),
2414
2560
  Companions: await this.SerializeCompanions(),
2415
2561
  IsExistingRecord: this.IsSaved,
2416
2562
  }, { provider: this.ProviderToUse, user: this.ActiveUser });
@@ -2449,9 +2595,14 @@ export class BaseEntity {
2449
2595
  // 'save' event both carry it; hardcoding 'update' here made every remote graph CREATE
2450
2596
  // report (and notify subscribers) as an update.
2451
2597
  const saveSubType = this.IsSaved ? 'update' : 'create';
2598
+ const changedFieldNames = this.dirtyFieldNames();
2452
2599
  // Root: rebase field values and dirty state onto what the database now holds.
2453
2600
  this.init();
2454
- this.SetMany(output.Fields, false, true, true);
2601
+ const fields = this.narrowHydrationSource(output.Fields);
2602
+ this.SetMany(fields, false, true, true);
2603
+ // A hydration source like any other: a field the result omitted (the server withheld it, or
2604
+ // this user may not hold it) is not loaded, so its construction state is never resent as data.
2605
+ this.markFieldsOmittedBySourceAsNotLoaded(fields);
2455
2606
  this._recordLoaded = true;
2456
2607
  this._everSaved = true;
2457
2608
  // Children: adopt server-assigned primary keys and computed values. 'result' mode adopts the
@@ -2465,7 +2616,28 @@ export class BaseEntity {
2465
2616
  result.StartedAt = new Date();
2466
2617
  result.EndedAt = new Date();
2467
2618
  this.RegisterResultHistoryEntry(result);
2468
- this.RaiseEvent('save', null, saveSubType);
2619
+ this.RaiseEvent('save', null, saveSubType, changedFieldNames);
2620
+ }
2621
+ /**
2622
+ * The names of the fields that are dirty right now. Cheaper than walking {@link Fields}: an
2623
+ * object still in raw mode (loaded and never written to) cannot be dirty, so it answers `[]`
2624
+ * without building its field objects.
2625
+ */
2626
+ GetDirtyFieldNames() {
2627
+ if (!this._fieldsHydrated && this._raw !== null && !this._parentEntity) {
2628
+ return [];
2629
+ }
2630
+ const names = [];
2631
+ for (const f of this.Fields) {
2632
+ if (f.Dirty) {
2633
+ names.push(f.Name);
2634
+ }
2635
+ }
2636
+ return names;
2637
+ }
2638
+ /** The names of the fields that are dirty right now, for the `save` event's {@link BaseEntityEvent.ChangedFieldNames}. */
2639
+ dirtyFieldNames() {
2640
+ return this.GetDirtyFieldNames();
2469
2641
  }
2470
2642
  /**
2471
2643
  * Gives every eager companion a chance to populate itself.
@@ -2825,6 +2997,7 @@ export class BaseEntity {
2825
2997
  entity: level,
2826
2998
  wasSaved: level._everSaved,
2827
2999
  wasLoaded: level._recordLoaded,
3000
+ finalizeGeneration: level._finalizeGeneration,
2828
3001
  resultHistory: [...level._resultHistory],
2829
3002
  fields: level.Fields.map(field => ({ field, state: field.GetState() })),
2830
3003
  });
@@ -2844,7 +3017,7 @@ export class BaseEntity {
2844
3017
  * Puts this record back as a snapshot captured it, after the unit of work that finalized it
2845
3018
  * rolled back.
2846
3019
  *
2847
- * A save's `finalizeSave()` rebuilds the record's fields from what its write returned, marks it
3020
+ * A save's `finalizeSave()` updates the record's fields from what its write returned, marks it
2848
3021
  * saved and empties its result history. The database undid the write, so the record gets back
2849
3022
  * its saved and loaded flags, its result history, and each field's value and tracking state. A
2850
3023
  * field edited since then keeps the edit, and is compared with its captured baseline again, since
@@ -2854,14 +3027,16 @@ export class BaseEntity {
2854
3027
  * The history matters to the caller: a save records its failure only when nothing else did,
2855
3028
  * judged by the history's length when it started, which an emptied history would make wrong.
2856
3029
  *
2857
- * A record still holding the captured field objects was never finalized, so nothing about it
2858
- * changed and it is left alone.
3030
+ * A record whose `_finalizeGeneration` (and field object identities) still matches the snapshot
3031
+ * was never finalized, so nothing about it changed and it is left alone.
2859
3032
  */
2860
3033
  restoreSnapshot(snapshot) {
2861
- const finalized = snapshot.fields.some(({ field }) => this.GetFieldByName(field.Name) !== field);
3034
+ const finalized = this._finalizeGeneration !== snapshot.finalizeGeneration ||
3035
+ snapshot.fields.some(({ field }) => this.GetFieldByName(field.Name) !== field);
2862
3036
  if (!finalized) {
2863
3037
  return;
2864
3038
  }
3039
+ this._finalizeGeneration = snapshot.finalizeGeneration;
2865
3040
  this._everSaved = snapshot.wasSaved;
2866
3041
  this._recordLoaded = snapshot.wasLoaded;
2867
3042
  this._resultHistory = snapshot.resultHistory;
@@ -3080,7 +3255,7 @@ export class BaseEntity {
3080
3255
  /**
3081
3256
  * Used for raising events within the BaseEntity and can be used by sub-classes to raise events that are specific to the entity.
3082
3257
  */
3083
- RaiseEvent(type, payload, saveSubType = undefined) {
3258
+ RaiseEvent(type, payload, saveSubType = undefined, changedFieldNames) {
3084
3259
  // Resolve the entity's bound provider once so every consumer (local subscribers
3085
3260
  // AND MJGlobal listeners) receives consistent provider context. This matters in
3086
3261
  // multi-provider scenarios — listeners like LocalCacheManager / BaseEngine need to
@@ -3091,7 +3266,7 @@ export class BaseEntity {
3091
3266
  const provider = this.ProviderToUse;
3092
3267
  // this is the local event handler that is specific to THIS instance of the entity object
3093
3268
  LogDebug(`BaseEntity.RaiseEvent() - ${type === 'save' ? 'save:' + saveSubType : type} event raised for ${this.EntityInfo.Name}, about to call this._eventSubject.next()`);
3094
- this._eventSubject?.next({ type: type, payload: payload, saveSubType: saveSubType, baseEntity: this, provider });
3269
+ this._eventSubject?.next({ type: type, payload: payload, saveSubType: saveSubType, baseEntity: this, provider, ChangedFieldNames: changedFieldNames });
3095
3270
  // this next call is to MJGlobal to let everyone who cares knows that we had an event on an entity object
3096
3271
  // we broadcast save/delete/load events and their _started counterparts
3097
3272
  if (BaseEntity._globalEventTypes.has(type)) {
@@ -3101,6 +3276,7 @@ export class BaseEntity {
3101
3276
  event.type = type;
3102
3277
  event.saveSubType = saveSubType;
3103
3278
  event.provider = provider;
3279
+ event.ChangedFieldNames = changedFieldNames;
3104
3280
  LogDebug(`BaseEntity.RaiseEvent() - ${type === 'save' ? 'save:' + saveSubType : type} event raised for ${this.EntityInfo.Name}, about to call MJGlobal.RaiseEvent()`);
3105
3281
  MJGlobal.Instance.RaiseEvent({
3106
3282
  component: this,
@@ -3241,6 +3417,7 @@ export class BaseEntity {
3241
3417
  get TransactionGroup() {
3242
3418
  return this._transactionGroup;
3243
3419
  }
3420
+ /** Set `null` to detach the entity from a group, e.g. once the group has been submitted. */
3244
3421
  set TransactionGroup(group) {
3245
3422
  this._transactionGroup = group;
3246
3423
  }
@@ -3492,11 +3669,14 @@ export class BaseEntity {
3492
3669
  * nothing like field security.
3493
3670
  *
3494
3671
  * Framework-internal value machinery (`Dirty`, `Validate`, `GetAll`, hydration, save-SQL
3495
- * build) reads `EntityField.Value` directly and never routes through here — that exemption
3496
- * is load-bearing, not an oversight. Do not "fix" it.
3672
+ * build) reads `EntityField.Value` directly and never routes through here. It needs no gate
3673
+ * of its own: every hydration point leaves a denied field not-loaded and empty for a
3674
+ * restricted user (see {@link narrowHydrationSource}), so there is no denied value for that
3675
+ * machinery to read. This assertion is what turns asking for one into an error rather than a
3676
+ * silent null.
3497
3677
  */
3498
3678
  assertFieldReadable(fieldName) {
3499
- const denied = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedReadFields(u));
3679
+ const denied = this.deniedReadFieldsForActiveUser();
3500
3680
  if (denied?.has(fieldName?.trim().toLowerCase())) {
3501
3681
  throw new FieldSecurityError(fieldName, this.EntityInfo.Name);
3502
3682
  }
@@ -3812,6 +3992,53 @@ export class BaseEntity {
3812
3992
  }
3813
3993
  return obj;
3814
3994
  }
3995
+ /**
3996
+ * Whether {@link GetAll} would carry a key for every non-binary field of this entity: the
3997
+ * object holds a value for each one (none is not loaded, or withheld from its user), here or,
3998
+ * for an IS-A child, on an ancestor. Answers the question
3999
+ * `LocalCacheManager.RowHasEveryField(this.EntityInfo, this.GetAll())` answers, without
4000
+ * building the row, and without building field objects for an object in raw mode.
4001
+ */
4002
+ HoldsEveryField() {
4003
+ const infos = this.EntityInfo?.Fields;
4004
+ if (!Array.isArray(infos) || infos.length === 0) {
4005
+ return true;
4006
+ }
4007
+ if (!this._fieldsHydrated && this._raw !== null) {
4008
+ for (const fi of infos) {
4009
+ if (!fi.IsBinaryFieldType && this.rawValueFor(this._raw, fi) === undefined && !this.ancestorHoldsField(fi.Name)) {
4010
+ return false;
4011
+ }
4012
+ }
4013
+ return true;
4014
+ }
4015
+ for (const field of this.Fields) {
4016
+ if (!field.EntityFieldInfo.IsBinaryFieldType && field.NotLoaded && !this.ancestorHoldsField(field.Name)) {
4017
+ return false;
4018
+ }
4019
+ }
4020
+ return true;
4021
+ }
4022
+ /** Whether an IS-A ancestor's {@link GetAll} would carry the key `name` (an exact field name). */
4023
+ ancestorHoldsField(name) {
4024
+ return this._parentEntity?.holdsFieldNamed(name) ?? false;
4025
+ }
4026
+ /** Whether this object's {@link GetAll} would carry the key `name`, counting its own ancestors. */
4027
+ holdsFieldNamed(name) {
4028
+ if (!this._fieldsHydrated && this._raw !== null) {
4029
+ const fi = this.EntityInfo?.FieldByName(name);
4030
+ if (fi && fi.Name === name && this.rawValueFor(this._raw, fi) !== undefined) {
4031
+ return true;
4032
+ }
4033
+ }
4034
+ else {
4035
+ const field = this.GetFieldByName(name);
4036
+ if (field && field.Name === name && !field.NotLoaded) {
4037
+ return true;
4038
+ }
4039
+ }
4040
+ return this.ancestorHoldsField(name);
4041
+ }
3815
4042
  /**
3816
4043
  * Returns a partial object that contains only the fields that have changed since the last time the record was saved. This is useful for scenarios where you want to send only the changes to the server or to a client.
3817
4044
  * It is also helpful for quickly finding the fields that are "dirty".
@@ -4039,19 +4266,43 @@ export class BaseEntity {
4039
4266
  * Called only by the hydration entry points ({@link LoadFromData} both modes, {@link Hydrate},
4040
4267
  * {@link InnerLoad}) — never by plain {@link SetMany}, which is an incremental mutation API
4041
4268
  * where omitting a field means "leave it alone," not "this field was never loaded."
4042
- * Both the field Name and CodeName are checked, matching SetMany's key acceptance.
4269
+ * Both the field Name and CodeName are checked, case-insensitively, matching SetMany's key
4270
+ * acceptance: SetMany applies `{name: 'b'}` to a field called `Name`, so an exact-case lookup
4271
+ * here would mark the value it just applied as not loaded, and GetAll and the save would then
4272
+ * drop it.
4043
4273
  */
4044
4274
  markFieldsOmittedBySourceAsNotLoaded(source) {
4045
4275
  if (!source || typeof source !== 'object')
4046
4276
  return;
4277
+ // Exact keys first: a source keyed by the field names, the usual case, never builds the
4278
+ // case-insensitive key set. It is built once, on the first field the exact check misses.
4279
+ let presentKeys = null;
4047
4280
  for (const field of this.Fields) {
4048
- if (source[field.Name] === undefined && source[field.CodeName] === undefined) {
4281
+ if (BaseEntity.ownValuePresent(source, field.Name) || BaseEntity.ownValuePresent(source, field.CodeName)) {
4282
+ continue;
4283
+ }
4284
+ presentKeys ??= BaseEntity.normalizedPresentKeys(source);
4285
+ if (!presentKeys.has(field.Name.trim().toLowerCase()) && !presentKeys.has(field.CodeName.trim().toLowerCase())) {
4049
4286
  // Cleared, not just flagged: otherwise a default from construction, or the previous
4050
4287
  // record's value on a reload, reads back through Get() as if it had been loaded.
4051
4288
  field.ClearAsNotLoaded();
4052
4289
  }
4053
4290
  }
4054
4291
  }
4292
+ /** Whether `source` holds `key` as its own property with a value (an inherited `constructor` does not count). */
4293
+ static ownValuePresent(source, key) {
4294
+ return source[key] !== undefined && Object.prototype.hasOwnProperty.call(source, key);
4295
+ }
4296
+ /** The keys of `source` that carry a value, trimmed and lowercased. */
4297
+ static normalizedPresentKeys(source) {
4298
+ const keys = new Set();
4299
+ for (const key of Object.keys(source)) {
4300
+ if (source[key] !== undefined) {
4301
+ keys.add(key.trim().toLowerCase());
4302
+ }
4303
+ }
4304
+ return keys;
4305
+ }
4055
4306
  /**
4056
4307
  * This method will copy the values from the other entity object into the current one. This is useful for things like cloning a record.
4057
4308
  * This method will ONLY copy values for fields that exist in the current entity object. If the other object has fields that don't exist in the current object, they will be ignored.
@@ -4061,8 +4312,15 @@ export class BaseEntity {
4061
4312
  */
4062
4313
  CopyFrom(other, includePrimaryKeys = false, replaceOldValues = false) {
4063
4314
  try {
4315
+ // Field security: a field THIS object's user may not read is skipped, not copied. The
4316
+ // source can be full width (another user's object, or a server-internal one), and
4317
+ // Set() refuses a denied field, which used to end the copy at the first one.
4318
+ const denied = this.deniedReadFieldsForActiveUser();
4064
4319
  // iterate through all of OUR fields and set them to the value of the other object, if they exist in the other object
4065
4320
  for (let field of this.Fields) {
4321
+ if (denied?.has(field.Name.trim().toLowerCase())) {
4322
+ continue;
4323
+ }
4066
4324
  if (!field.IsPrimaryKey || includePrimaryKeys) {
4067
4325
  const otherField = other.GetFieldByName(field.Name);
4068
4326
  // Skip fields the SOURCE never loaded (D-3): copying their construction
@@ -4086,9 +4344,27 @@ export class BaseEntity {
4086
4344
  * The ContextCurrentUser is a property used to manually set the "current" user for scenarios, primarily on the server side, where the user changes per request. For situations where there is no global CurrentUser in the Metadata.Provider,
4087
4345
  * you MUST set this property to the user you want to use for the current operation. If you used Metadata.GetEntityObject() to get the entity object, this property will be set automatically for you as that method has a parameter that can
4088
4346
  * be provided for the ContextCurrentUser.
4347
+ *
4348
+ * **Field security.** An object holds only what its user may read, so assigning a different
4349
+ * user to an object that already holds a record narrows it to the new user: values the new
4350
+ * user may not read are dropped (the fields become not loaded) from this object, its IS-A
4351
+ * parent, its embedded records and the loaded children of its related-record collections,
4352
+ * which are given the same user. A read-only cache collection's children are the engine's own
4353
+ * instances and are left as they are. Nothing is widened: a field
4354
+ * the previous user could not read stays not loaded for a user who can, because its value was
4355
+ * never loaded. Set the user before loading wherever possible, so the record is loaded at the
4356
+ * right width in the first place.
4089
4357
  */
4090
4358
  set ContextCurrentUser(user) {
4359
+ if (user === this._contextCurrentUser) {
4360
+ return;
4361
+ }
4091
4362
  this._contextCurrentUser = user;
4363
+ this.narrowHeldValuesForActiveUser(false);
4364
+ if (this._parentEntity) {
4365
+ this._parentEntity.ContextCurrentUser = user;
4366
+ }
4367
+ this.notifyCompanionsContextUserChanged(user);
4092
4368
  }
4093
4369
  get ContextCurrentUser() {
4094
4370
  return this._contextCurrentUser;
@@ -4155,6 +4431,11 @@ export class BaseEntity {
4155
4431
  }
4156
4432
  }
4157
4433
  }
4434
+ // Field security: a new record holds no default for a field its user may not read. The
4435
+ // field is not loaded instead, so GetAll() and every payload built from it leave it out
4436
+ // (the server refuses a restricted caller's input that names it), and the save omits it,
4437
+ // so the column takes its database default.
4438
+ this.narrowHeldValuesForActiveUser(true);
4158
4439
  // Apply caller-supplied values LAST so an explicit PK (or any routed
4159
4440
  // parent field) wins over the generated/adopted value instead of being
4160
4441
  // clobbered by the parent's NewRecord().
@@ -4181,6 +4462,53 @@ export class BaseEntity {
4181
4462
  }
4182
4463
  }
4183
4464
  }
4465
+ /**
4466
+ * Gives every companion this object's new user: embedded records and the loaded children of
4467
+ * related-record collections take it. See {@link ContextCurrentUser}.
4468
+ */
4469
+ notifyCompanionsContextUserChanged(user) {
4470
+ if (!this.HasCompanions) {
4471
+ return;
4472
+ }
4473
+ for (const companion of this.Companions) {
4474
+ companion.OnOwnerContextUserChanged(user);
4475
+ }
4476
+ }
4477
+ /**
4478
+ * Drops every value this object holds that its user may not read: the field is left not
4479
+ * loaded and empty, or, before the fields are built, the key leaves the raw row (as a copy;
4480
+ * the row can be shared). A no-op when field security does not apply or the user is denied
4481
+ * nothing, which is the one boolean most entities pay.
4482
+ *
4483
+ * For the points where the user or the record changes WITHOUT a hydration source to narrow:
4484
+ * a user assigned to an object that already holds a record, and a new record's defaults.
4485
+ *
4486
+ * @param buildFields true to build the fields first when they are not built yet, as a new
4487
+ * record needs: its defaults live in fields that are only created on first use.
4488
+ */
4489
+ narrowHeldValuesForActiveUser(buildFields) {
4490
+ const denied = this.deniedReadFieldsForActiveUser();
4491
+ if (!denied) {
4492
+ return;
4493
+ }
4494
+ if (!this._fieldsHydrated && !buildFields) {
4495
+ if (this._raw) {
4496
+ this._raw = OmitDeniedFieldKeys(this._raw, denied);
4497
+ this._rawConverted = null;
4498
+ }
4499
+ return;
4500
+ }
4501
+ let cleared = false;
4502
+ for (const field of this.Fields) {
4503
+ if (denied.has(field.Name.trim().toLowerCase()) && (!field.NotLoaded || field.Value !== null)) {
4504
+ field.ClearAsNotLoaded();
4505
+ cleared = true;
4506
+ }
4507
+ }
4508
+ if (cleared) {
4509
+ this.discardJSONFieldObjects(); // a parsed JSON object can hold a value just dropped
4510
+ }
4511
+ }
4184
4512
  /**
4185
4513
  * Returns the active restore context for the next save, if any.
4186
4514
  *
@@ -4681,6 +5009,8 @@ export class BaseEntity {
4681
5009
  }
4682
5010
  finalizeSave(data, saveSubType, options) {
4683
5011
  if (data) {
5012
+ // The 'save' event names the fields this save wrote; init() below clears every dirty flag.
5013
+ const changedFieldNames = this.dirtyFieldNames();
4684
5014
  const effectiveReturnMode = options?.SaveReturnMode ?? this.EntityInfo.SaveReturnMode;
4685
5015
  const hasGetAll = (val) => val instanceof BaseEntity || typeof val.GetAll === 'function';
4686
5016
  const rawFieldData = hasGetAll(data) ? data.GetAll() : data;
@@ -4709,26 +5039,22 @@ export class BaseEntity {
4709
5039
  else {
4710
5040
  fieldData = rawFieldData;
4711
5041
  }
4712
- this.init(); // wipe out the current data to flush out the DIRTY flags, load the ID as part of this too
4713
- // IS-A GetAll() merges the parent, including parent virtuals this entity
4714
- // does not own (e.g. OrderHeader on Event Order Line). Keep only columns
4715
- // this entity defines, and ignore anything leftover.
4716
- this.SetMany(this.ownedFieldsFrom(fieldData), true, true, true);
4717
- // finalizeSave re-hydrates from the save RESPONSE — a hydration source. Keys it
4718
- // omitted (e.g. fields the server stripped for field security) must be marked
4719
- // not-loaded, or the defaults init() just produced would masquerade as confirmed
4720
- // values and be resent on the NEXT save (the create/update-response corner: a
4721
- // trigger/system adjustment or concurrent change would be silently stomped).
4722
- if (typeof fieldData === 'object' && !Array.isArray(fieldData)) {
4723
- this.markFieldsOmittedBySourceAsNotLoaded(fieldData);
4724
- }
5042
+ // The save returns the whole row, whoever saved it. The provider has already built the
5043
+ // Record Changes payload from this entity's pre-save state, so narrowing the response
5044
+ // here costs the audit nothing; it only keeps denied values out of this object.
5045
+ fieldData = this.narrowHydrationSource(fieldData);
5046
+ // Reset transient record state and update owned EntityField values in place so
5047
+ // hydrated EntityField instances and _fieldCache/_codeNameCache are preserved across saves.
5048
+ // Deliberately do NOT call discardJSONFieldObjects() here so JSONType references held
5049
+ // across Save() remain attached and live.
5050
+ this.resetAndApplyOwnedFieldsInPlace(fieldData);
4725
5051
  this._everSaved = true; // Mark as saved after successful save
4726
5052
  const result = this.LatestResult;
4727
5053
  if (result)
4728
5054
  result.NewValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.Value }; }); // set the latest values here
4729
5055
  // Cache the record name for faster lookups (updates cache with potentially new name)
4730
5056
  this.cacheRecordName();
4731
- this.RaiseEvent('save', null, saveSubType);
5057
+ this.RaiseEvent('save', null, saveSubType, changedFieldNames);
4732
5058
  return true;
4733
5059
  }
4734
5060
  else {
@@ -4864,16 +5190,17 @@ export class BaseEntity {
4864
5190
  * entity was hydrated FROM THE DATABASE on every FLS entity. Two distinct resolver behaviours
4865
5191
  * carry that premise, and BOTH are load-bearing:
4866
5192
  *
4867
- * 1. `StripDeniedReadFieldsFromClientInput` removes client-sent values for fields the caller
4868
- * cannot READ, which `SetMany` would otherwise make genuinely dirty with fabricated data.
4869
- * 2. `entityInfo.EnableFieldLevelSecurity` forces the truth-load branch, so the entity's
5193
+ * 1. `AssertClientInputFieldSecurity` (through `AssertClientInputWritableForUser`) rejects
5194
+ * client input naming a field the caller cannot READ, or no field at all, which `SetMany`
5195
+ * would otherwise make genuinely dirty with fabricated data.
5196
+ * 2. `entityInfo.FieldLevelSecurityApplies` forces the truth-load branch, so the entity's
4870
5197
  * non-dirty baseline is the real stored row rather than the client's `OldValues___`.
4871
5198
  *
4872
5199
  * (2) is not redundant with (1). A value arriving through `LoadFromData` is recorded by the
4873
5200
  * EntityField setter as the field's INITIAL value, so it is not dirty — and this check would
4874
5201
  * never see it, while `GenerateSaveSQL` sends it anyway (it filters on `NotLoaded`, never on
4875
5202
  * `Dirty`). Without the forced truth-load, a caller with Read Allow + Update Deny — the
4876
- * canonical FLS configuration, and one that leaves (1) with nothing to strip — could write an
5203
+ * canonical FLS configuration, and one that leaves (1) with nothing to reject — could write an
4877
5204
  * update-denied field just by pinning its value in `OldValues___` and never naming it in the
4878
5205
  * mutation. If you are considering relaxing that branch condition, this check is what breaks.
4879
5206
  *
@@ -4906,7 +5233,7 @@ export class BaseEntity {
4906
5233
  // deliberately skips the readability assertion (hydration / resolver-apply path),
4907
5234
  // so server-side code can dirty a read-denied field and land here.
4908
5235
  if (deniedRead === undefined) {
4909
- deniedRead = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedReadFields(u));
5236
+ deniedRead = this.deniedReadFieldsForActiveUser();
4910
5237
  }
4911
5238
  throw deniedRead?.has(key)
4912
5239
  ? new FieldSecurityError(field.Name, this.EntityInfo.Name)
@@ -4955,15 +5282,32 @@ export class BaseEntity {
4955
5282
  `omitted field(s) ${suppressed.join(', ')}; each column takes its default`);
4956
5283
  }
4957
5284
  }
5285
+ /**
5286
+ * Whether this object's user may READ the named field: the question `Get()` asks before it
5287
+ * throws a `FieldSecurityError`, answered without throwing.
5288
+ *
5289
+ * For code that reads a field it did not choose, such as a configured path or a display
5290
+ * column, and must treat a denied field as having no value rather than fail. Fails open the
5291
+ * way `Get()` does: true when field security does not apply to this entity (or any IS-A
5292
+ * ancestor) or no user resolves.
5293
+ *
5294
+ * @param fieldName the field's Name, matched case-insensitively
5295
+ */
5296
+ IsFieldReadableByActiveUser(fieldName) {
5297
+ if (!fieldName) {
5298
+ return true;
5299
+ }
5300
+ return !this.deniedReadFieldsForActiveUser()?.has(fieldName.trim().toLowerCase());
5301
+ }
4958
5302
  /**
4959
5303
  * The denied-field set for the acting user, or null when field security does not apply —
4960
- * the entity has it switched off, no user resolves, or the user is denied nothing.
5304
+ * it is off on the entity and every IS-A ancestor, no user resolves, or the user is denied nothing.
4961
5305
  *
4962
5306
  * Returning null rather than an empty Set lets callers skip their loop entirely, and keeps
4963
5307
  * the three cheap short-circuits in one place instead of repeated at each gate.
4964
5308
  */
4965
5309
  deniedFieldsForActiveUser(select) {
4966
- if (!this.EntityInfo?.EnableFieldLevelSecurity) {
5310
+ if (!this.EntityInfo?.FieldLevelSecurityApplies) {
4967
5311
  return null; // one boolean for the overwhelming majority of entities
4968
5312
  }
4969
5313
  const u = this.resolveActiveUserOrNull();
@@ -4973,6 +5317,34 @@ export class BaseEntity {
4973
5317
  const denied = select(u);
4974
5318
  return denied.size > 0 ? denied : null;
4975
5319
  }
5320
+ /**
5321
+ * The fields this object's user may not READ, lowercased by Name and CodeName, or null when
5322
+ * field security does not apply. Every readability question this class asks, at hydration,
5323
+ * at the typed accessors and at the save gates, goes through here.
5324
+ */
5325
+ deniedReadFieldsForActiveUser() {
5326
+ return this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedReadFields(u));
5327
+ }
5328
+ /**
5329
+ * A hydration source reduced to what this object's user may hold: the same object when the
5330
+ * user is denied nothing (or field security does not apply), otherwise a shallow copy without
5331
+ * the denied keys, matched case-insensitively under Name and CodeName.
5332
+ *
5333
+ * Every point that fills this object from a row passes the row through here first, which is
5334
+ * what makes the object's width a property of its user rather than of the path that built it.
5335
+ * A key absent from a hydration source already means "not loaded" everywhere (`GetAll`, field
5336
+ * building, the save), so omitting a denied key is all it takes for the value never to enter
5337
+ * the object, and for every accessor and serializer to be correct without a check of its own.
5338
+ *
5339
+ * The caller's row is never modified, because it can be a frozen row shared through a cache,
5340
+ * and an unrestricted user's row is returned by reference, so the raw load path stays zero-copy.
5341
+ */
5342
+ narrowHydrationSource(source) {
5343
+ if (!source || typeof source !== 'object' || Array.isArray(source) || source instanceof Date) {
5344
+ return source;
5345
+ }
5346
+ return OmitDeniedFieldKeys(source, this.deniedReadFieldsForActiveUser());
5347
+ }
4976
5348
  /**
4977
5349
  * {@link ActiveUser}, but null instead of throwing when no provider is configured to resolve
4978
5350
  * one from.
@@ -5040,7 +5412,7 @@ export class BaseEntity {
5040
5412
  if (!this.IsSaved) {
5041
5413
  this.init(); // wipe out current data if we're loading on top of existing record
5042
5414
  }
5043
- const data = await this.readRowForLoad(CompositeKey, EntityRelationshipsToLoad);
5415
+ let data = await this.readRowForLoad(CompositeKey, EntityRelationshipsToLoad);
5044
5416
  this.discardJSONFieldObjects(); // a reload replaces the record's data
5045
5417
  if (!data) {
5046
5418
  // A subtype-hint probe asks whether this row exists, so "no" is an answer, not an error
@@ -5049,6 +5421,9 @@ export class BaseEntity {
5049
5421
  }
5050
5422
  return false; // no data loaded, return false
5051
5423
  }
5424
+ // Field security: denied values never enter this object, even from a provider that
5425
+ // did not narrow its SELECT (see narrowHydrationSource).
5426
+ data = this.narrowHydrationSource(data);
5052
5427
  // IS-A: hydrate parent chain from loaded data before populating self.
5053
5428
  // Hydrate resets each parent via init() (giving fresh _NeverSet=true on PK fields)
5054
5429
  // then populates from data. This must happen before self's SetMany so parents
@@ -5199,6 +5574,16 @@ export class BaseEntity {
5199
5574
  */
5200
5575
  async LoadFromData(data, _replaceOldValues = false) {
5201
5576
  this.discardJSONFieldObjects(); // the record's data is being replaced: parsed JSON objects are stale
5577
+ // An entity object passed as the row is read through its own GetAll(): what ITS user may
5578
+ // read, as a plain row. Kept as the row itself, it would be read through its typed getters,
5579
+ // which answer for the source's user and so return values this object's user is denied
5580
+ // (narrowing below sees only an object's own keys, and an entity's values are not those).
5581
+ if (data instanceof BaseEntity) {
5582
+ data = data.GetAll();
5583
+ }
5584
+ // Field security: a restricted user's object holds a copy of the row without the keys they
5585
+ // are denied; anyone else's keeps the caller's row by reference (see narrowHydrationSource).
5586
+ data = this.narrowHydrationSource(data);
5202
5587
  // IS-A: hydrate parent chain from data before populating self.
5203
5588
  // Hydrate resets each parent via init() (giving fresh _NeverSet=true on PK fields)
5204
5589
  // then populates from data, ensuring correct PK and saved state on parents.
@@ -5302,6 +5687,398 @@ export class BaseEntity {
5302
5687
  await this.deserializeCompanionsFromData(data);
5303
5688
  return true;
5304
5689
  }
5690
+ /**
5691
+ * Framework-internal batch IS-A parent hydration entry point used by
5692
+ * `TransformSimpleObjectToEntityObject` when the provider supports batched `RunViews`.
5693
+ *
5694
+ * Collapses $O(2N)$ per-record child-discovery and child-load queries into at most
5695
+ * 1 batched discovery query + 1 batched `RunViews` call per hierarchy depth level
5696
+ * (and 0 discovery queries when a `SubtypeSelector` / `EntitySubtypeResolver` resolves all rows).
5697
+ */
5698
+ static { this.DEFAULT_ISA_BATCH_CHUNK_SIZE = 500; }
5699
+ /**
5700
+ * Maximum number of primary keys included in a single `WHERE PK IN (...)` batch query
5701
+ * during IS-A child discovery or hydration.
5702
+ */
5703
+ static get ISABatchChunkSize() {
5704
+ return BaseEntity.DEFAULT_ISA_BATCH_CHUNK_SIZE;
5705
+ }
5706
+ async LoadFromDataBatchAsISAParent(peers, md, contextUser) {
5707
+ const loadedParents = [];
5708
+ for (let i = 0; i < peers.length; i++) {
5709
+ const { entity, data } = peers[i];
5710
+ if (IsMemberOverridden(entity, 'LoadFromData', BaseEntity)) {
5711
+ await entity.LoadFromData(data);
5712
+ continue;
5713
+ }
5714
+ const allPksSet = await entity.hydrateSingleISAParentPeer(data);
5715
+ if (allPksSet) {
5716
+ loadedParents.push(entity);
5717
+ }
5718
+ }
5719
+ if (loadedParents.length > 0) {
5720
+ await BaseEntity.hydrateISAChildrenForLevel(loadedParents, md, contextUser);
5721
+ }
5722
+ }
5723
+ async hydrateSingleISAParentPeer(rawData) {
5724
+ this.discardJSONFieldObjects();
5725
+ const dataObj = rawData instanceof BaseEntity
5726
+ ? rawData.GetAll()
5727
+ : rawData;
5728
+ const narrowed = this.narrowHydrationSource(dataObj);
5729
+ if (this._parentEntity && narrowed && typeof narrowed === 'object' && !Array.isArray(narrowed)) {
5730
+ this._parentEntity.Hydrate(narrowed);
5731
+ }
5732
+ const isPlain = Boolean(narrowed && typeof narrowed === 'object' && !Array.isArray(narrowed) && !(narrowed instanceof Date));
5733
+ this.SetMany(narrowed, true, false, true);
5734
+ if (isPlain) {
5735
+ this.markFieldsOmittedBySourceAsNotLoaded(narrowed);
5736
+ }
5737
+ let allPksSet = false;
5738
+ if (this.PrimaryKeys && this.PrimaryKeys.length > 0) {
5739
+ allPksSet = true;
5740
+ for (const pkey of this.PrimaryKeys) {
5741
+ if (pkey.Value === null || pkey.Value === undefined) {
5742
+ allPksSet = false;
5743
+ break;
5744
+ }
5745
+ }
5746
+ this._recordLoaded = allPksSet;
5747
+ this._everSaved = allPksSet;
5748
+ if (allPksSet) {
5749
+ this.cacheRecordName();
5750
+ }
5751
+ }
5752
+ else {
5753
+ LogError(`BaseEntity.LoadFromDataBatchAsISAParent() called on ${this.EntityInfo.Name} with no primary keys defined. This is an error state and should not happen.`);
5754
+ this._recordLoaded = false;
5755
+ this._everSaved = false;
5756
+ }
5757
+ await this.deserializeCompanionsFromData(narrowed);
5758
+ return allPksSet;
5759
+ }
5760
+ static async hydrateISAChildrenForLevel(levelEntities, md, contextUser) {
5761
+ const activeParents = levelEntities.filter(e => e._recordLoaded && e.EntityInfo.IsParentType && !e._childEntityDiscoveryDone && e.EntityInfo.ChildEntities.length > 0);
5762
+ if (activeParents.length === 0) {
5763
+ return;
5764
+ }
5765
+ const groupsByEntityId = new Map();
5766
+ for (const p of activeParents) {
5767
+ p._childEntityDiscoveryDone = true;
5768
+ let group = groupsByEntityId.get(p.EntityInfo.ID);
5769
+ if (!group) {
5770
+ group = [];
5771
+ groupsByEntityId.set(p.EntityInfo.ID, group);
5772
+ }
5773
+ group.push(p);
5774
+ }
5775
+ const nextLevelChildren = [];
5776
+ for (const [, groupParents] of groupsByEntityId) {
5777
+ const sampleParent = groupParents[0];
5778
+ if (sampleParent.EntityInfo.AllowMultipleSubtypes) {
5779
+ await BaseEntity.hydrateOverlappingChildrenForGroup(groupParents, contextUser);
5780
+ }
5781
+ else {
5782
+ await BaseEntity.hydrateDisjointChildrenForGroup(groupParents, md, contextUser, nextLevelChildren);
5783
+ }
5784
+ }
5785
+ if (nextLevelChildren.some(c => c.EntityInfo.IsParentType)) {
5786
+ await BaseEntity.hydrateISAChildrenForLevel(nextLevelChildren, md, contextUser);
5787
+ }
5788
+ }
5789
+ static async hydrateOverlappingChildrenForGroup(groupParents, contextUser) {
5790
+ const sampleParent = groupParents[0];
5791
+ const parentInfo = sampleParent.EntityInfo;
5792
+ const entityProvider = sampleParent.ProviderToUse;
5793
+ if (typeof entityProvider?.FindISAChildEntitiesBatch === 'function') {
5794
+ const pks = groupParents.map(p => String(p.PrimaryKey.Values()));
5795
+ const batchMap = await entityProvider.FindISAChildEntitiesBatch(parentInfo, pks, contextUser ?? sampleParent._contextCurrentUser);
5796
+ const pkField = parentInfo.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
5797
+ for (const p of groupParents) {
5798
+ const rawPk = String(p.PrimaryKey.Values());
5799
+ const pkKey = pkField ? pkField.FormatCanonicalValue(rawPk) : CanonicalPrimaryKeyValue(rawPk, false);
5800
+ const matched = batchMap.get(pkKey) ?? [];
5801
+ p._childEntities = matched.map(name => ({ entityName: name }));
5802
+ }
5803
+ }
5804
+ else {
5805
+ await Promise.all(groupParents.map(p => p.discoverOverlappingChildren()));
5806
+ }
5807
+ }
5808
+ static async hydrateDisjointChildrenForGroup(groupParents, md, contextUser, nextLevelChildren) {
5809
+ const sampleParent = groupParents[0];
5810
+ const parentInfo = sampleParent.EntityInfo;
5811
+ const entityProvider = sampleParent.ProviderToUse;
5812
+ const rvProvider = sampleParent.RunViewProviderToUse;
5813
+ const effectiveUser = contextUser ?? sampleParent._contextCurrentUser;
5814
+ // 1) Evaluate in-memory SubtypeSelector / EntitySubtypeResolver load hints
5815
+ const { hintedByChild, hintedNameByParent, needsDiscovery } = await BaseEntity.partitionParentsBySubtypeLoadHint(groupParents, parentInfo, md, contextUser);
5816
+ // 2) Batch-load hinted child rows in 1 RunViews call; any missing/failed row falls back to discovery
5817
+ if (hintedByChild.size > 0) {
5818
+ const unlinkedFromHints = await BaseEntity.batchLoadAndLinkDisjointChildren(hintedByChild, md, rvProvider, effectiveUser, nextLevelChildren);
5819
+ for (const unlinked of unlinkedFromHints) {
5820
+ // Same diagnostic the per-row path emits when a SubtypeSelector hint names a
5821
+ // child that has no row: operators need the signal that a selector is wrong.
5822
+ const hinted = hintedNameByParent.get(unlinked);
5823
+ if (hinted) {
5824
+ BaseEntity.logLoadFoundNoRow(hinted, unlinked.PrimaryKey);
5825
+ }
5826
+ needsDiscovery.push(unlinked);
5827
+ }
5828
+ }
5829
+ // 3) Batch-discover remaining unresolved parents in 1 query, then batch-load their child rows in 1 RunViews call
5830
+ if (needsDiscovery.length > 0) {
5831
+ const discoveredByChild = await BaseEntity.batchDiscoverDisjointChildren(parentInfo, needsDiscovery, entityProvider, rvProvider, effectiveUser);
5832
+ if (discoveredByChild.size > 0) {
5833
+ await BaseEntity.batchLoadAndLinkDisjointChildren(discoveredByChild, md, rvProvider, effectiveUser, nextLevelChildren);
5834
+ }
5835
+ }
5836
+ }
5837
+ static async partitionParentsBySubtypeLoadHint(groupParents, parentInfo, md, contextUser) {
5838
+ const hintedByChild = new Map();
5839
+ const hintedNameByParent = new Map();
5840
+ const needsDiscovery = [];
5841
+ for (const p of groupParents) {
5842
+ const hintedName = await p.resolveSubtypeLoadHint();
5843
+ if (hintedName) {
5844
+ const childInfo = md.EntityByName?.(hintedName) ??
5845
+ parentInfo.ChildEntities.find(c => sameEntityName(c.Name, hintedName));
5846
+ const userForPerm = contextUser ?? p._contextCurrentUser ?? md.CurrentUser;
5847
+ const canRead = childInfo && userForPerm ? childInfo.GetUserPermisions(userForPerm).CanRead : true;
5848
+ if (childInfo && canRead) {
5849
+ BaseEntity.appendParentToChildGroup(hintedByChild, childInfo.Name, p);
5850
+ hintedNameByParent.set(p, childInfo.Name);
5851
+ }
5852
+ else {
5853
+ needsDiscovery.push(p);
5854
+ }
5855
+ }
5856
+ else {
5857
+ needsDiscovery.push(p);
5858
+ }
5859
+ }
5860
+ return { hintedByChild, hintedNameByParent, needsDiscovery };
5861
+ }
5862
+ static appendParentToChildGroup(grouped, childName, parent) {
5863
+ let bucket = grouped.get(childName);
5864
+ if (!bucket) {
5865
+ bucket = [];
5866
+ grouped.set(childName, bucket);
5867
+ }
5868
+ bucket.push(parent);
5869
+ }
5870
+ static buildChildPkInFilter(pkField, rawPkValues) {
5871
+ const formatted = rawPkValues.map(val => {
5872
+ if (!pkField.NeedsQuotes) {
5873
+ // Emit the digits as written: a bigint beyond 2^53 must not be rewritten by Number().
5874
+ const text = String(val).trim();
5875
+ if (/^-?\d+(\.\d+)?$/.test(text)) {
5876
+ return text;
5877
+ }
5878
+ }
5879
+ return `'${EscapeSQLString(String(val))}'`;
5880
+ });
5881
+ return `${pkField.Name} IN (${formatted.join(', ')})`;
5882
+ }
5883
+ static async batchDiscoverDisjointChildren(parentInfo, parents, entityProvider, rvProvider, contextUser) {
5884
+ const pkField = parentInfo.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
5885
+ const pkIsUuid = Boolean(pkField?.IsUniqueIdentifier);
5886
+ const grouped = new Map();
5887
+ const pkValues = parents.map(p => String(p.PrimaryKey.Values()));
5888
+ if (typeof entityProvider?.FindISAChildEntitiesBatch === 'function') {
5889
+ const batchMap = await entityProvider.FindISAChildEntitiesBatch(parentInfo, pkValues, contextUser);
5890
+ for (const p of parents) {
5891
+ const pkKey = CanonicalPrimaryKeyValue(String(p.PrimaryKey.Values()), pkIsUuid);
5892
+ const firstMatch = batchMap.get(pkKey)?.[0];
5893
+ if (firstMatch) {
5894
+ BaseEntity.appendParentToChildGroup(grouped, firstMatch, p);
5895
+ }
5896
+ }
5897
+ return grouped;
5898
+ }
5899
+ if (rvProvider && typeof rvProvider.RunViews === 'function') {
5900
+ return BaseEntity.discoverDisjointChildrenViaRunViews(parentInfo, parents, pkValues, pkIsUuid, rvProvider, contextUser);
5901
+ }
5902
+ // Last-resort per-parent fallback when neither FindISAChildEntitiesBatch nor RunViews exists
5903
+ if (typeof entityProvider?.FindISAChildEntity === 'function') {
5904
+ for (const p of parents) {
5905
+ const found = await entityProvider.FindISAChildEntity(parentInfo, String(p.PrimaryKey.Values()), contextUser);
5906
+ if (found?.ChildEntityName) {
5907
+ BaseEntity.appendParentToChildGroup(grouped, found.ChildEntityName, p);
5908
+ }
5909
+ }
5910
+ }
5911
+ return grouped;
5912
+ }
5913
+ static async discoverDisjointChildrenViaRunViews(parentInfo, parents, pkValues, pkIsUuid, rvProvider, contextUser) {
5914
+ const grouped = new Map();
5915
+ const validChildren = parentInfo.ChildEntities.filter(c => c.FirstPrimaryKey != null); // first-pk-ok: IS-A children share the parent's single key
5916
+ if (validChildren.length === 0)
5917
+ return grouped;
5918
+ const chunkSize = BaseEntity.ISABatchChunkSize;
5919
+ const winnerByPk = new Map();
5920
+ const rv = new RunView(rvProvider);
5921
+ for (let start = 0; start < pkValues.length; start += chunkSize) {
5922
+ const chunkPks = pkValues.slice(start, start + chunkSize);
5923
+ const probeParams = validChildren.map(child => {
5924
+ const childPk = child.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
5925
+ return {
5926
+ EntityName: child.Name,
5927
+ ExtraFilter: BaseEntity.buildChildPkInFilter(childPk, chunkPks),
5928
+ Fields: [childPk.Name],
5929
+ ResultType: 'simple',
5930
+ // Internal probe: the pre-batch path (InnerLoad) never touched the RunView
5931
+ // cache. Every distinct IN-list would otherwise become its own filtered
5932
+ // cache slot that is invalidated (never maintained) on each child save.
5933
+ BypassCache: true,
5934
+ };
5935
+ });
5936
+ const probeResults = await rv.RunViews(probeParams, contextUser);
5937
+ for (let cIdx = 0; cIdx < validChildren.length; cIdx++) {
5938
+ const child = validChildren[cIdx];
5939
+ const pkName = child.FirstPrimaryKey.Name; // first-pk-ok: IS-A children share the parent's single key
5940
+ const res = probeResults[cIdx];
5941
+ if (!res?.Success || !Array.isArray(res.Results))
5942
+ continue;
5943
+ for (const row of res.Results) {
5944
+ const rawVal = row[pkName];
5945
+ if (rawVal != null) {
5946
+ const norm = CanonicalPrimaryKeyValue(String(rawVal), pkIsUuid);
5947
+ if (!winnerByPk.has(norm)) {
5948
+ winnerByPk.set(norm, child.Name);
5949
+ }
5950
+ }
5951
+ }
5952
+ }
5953
+ }
5954
+ for (const p of parents) {
5955
+ const norm = CanonicalPrimaryKeyValue(String(p.PrimaryKey.Values()), pkIsUuid);
5956
+ const childName = winnerByPk.get(norm);
5957
+ if (childName) {
5958
+ BaseEntity.appendParentToChildGroup(grouped, childName, p);
5959
+ }
5960
+ }
5961
+ return grouped;
5962
+ }
5963
+ static async batchLoadAndLinkDisjointChildren(parentsByChildName, md, rvProvider, contextUser, outLinkedChildren) {
5964
+ const unlinkedParents = [];
5965
+ if (!rvProvider || typeof rvProvider.RunViews !== 'function') {
5966
+ for (const [childName, parents] of parentsByChildName) {
5967
+ for (const p of parents) {
5968
+ const linked = await p.createAndLinkChildEntity(childName);
5969
+ if (!linked) {
5970
+ unlinkedParents.push(p);
5971
+ }
5972
+ }
5973
+ }
5974
+ return unlinkedParents;
5975
+ }
5976
+ const deniedChildLogged = new Set();
5977
+ const slices = BaseEntity.buildDisjointChildQuerySlices(parentsByChildName, md, contextUser, unlinkedParents, deniedChildLogged);
5978
+ if (slices.length === 0) {
5979
+ return unlinkedParents;
5980
+ }
5981
+ const rv = new RunView(rvProvider);
5982
+ const batchResults = await rv.RunViews(slices.map(s => s.param), contextUser);
5983
+ for (let sIdx = 0; sIdx < slices.length; sIdx++) {
5984
+ await BaseEntity.linkDisjointChildSliceResults(slices[sIdx], batchResults[sIdx], unlinkedParents, outLinkedChildren, deniedChildLogged);
5985
+ }
5986
+ return unlinkedParents;
5987
+ }
5988
+ static buildDisjointChildQuerySlices(parentsByChildName, md, contextUser, unlinkedParents, deniedChildLogged) {
5989
+ const chunkSize = BaseEntity.ISABatchChunkSize;
5990
+ const slices = [];
5991
+ for (const [childName, parents] of parentsByChildName) {
5992
+ const childInfo = md.EntityByName?.(childName) ??
5993
+ parents[0]?.EntityInfo.ChildEntities.find(c => sameEntityName(c.Name, childName));
5994
+ const pkField = childInfo?.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
5995
+ if (!childInfo || !pkField) {
5996
+ for (const p of parents)
5997
+ unlinkedParents.push(p);
5998
+ continue;
5999
+ }
6000
+ const userForPerm = contextUser ?? parents[0]?._contextCurrentUser ?? md.CurrentUser;
6001
+ const canRead = userForPerm ? childInfo.GetUserPermisions(userForPerm).CanRead : true;
6002
+ if (!canRead) {
6003
+ if (!deniedChildLogged.has(childInfo.Name)) {
6004
+ deniedChildLogged.add(childInfo.Name);
6005
+ LogWarning(`BaseEntity: current user cannot read IS-A child '${childInfo.Name}'; matching parent records are returned without their subtype`, 'IS-A');
6006
+ }
6007
+ for (const p of parents)
6008
+ unlinkedParents.push(p);
6009
+ continue;
6010
+ }
6011
+ for (let start = 0; start < parents.length; start += chunkSize) {
6012
+ const sliceParents = parents.slice(start, start + chunkSize);
6013
+ const pks = sliceParents.map(p => String(p.PrimaryKey.Values()));
6014
+ slices.push({
6015
+ childName: childInfo.Name,
6016
+ pkFieldName: pkField.Name,
6017
+ pkIsUuid: Boolean(pkField.IsUniqueIdentifier),
6018
+ parents: sliceParents,
6019
+ param: {
6020
+ EntityName: childInfo.Name,
6021
+ ExtraFilter: BaseEntity.buildChildPkInFilter(pkField, pks),
6022
+ ResultType: 'simple',
6023
+ // Internal hydration read: see batchDiscoverDisjointChildren — keep these
6024
+ // per-batch IN-list reads out of the shared RunView cache.
6025
+ BypassCache: true,
6026
+ },
6027
+ });
6028
+ }
6029
+ }
6030
+ return slices;
6031
+ }
6032
+ static async linkDisjointChildSliceResults(slice, res, unlinkedParents, outLinkedChildren, deniedChildLogged) {
6033
+ if (!res?.Success || !Array.isArray(res.Results)) {
6034
+ // Not silent: the per-row path surfaced a failed child load; here the parents stay
6035
+ // unlinked (plain parent records) and the cause goes to the log.
6036
+ LogWarning(`BaseEntity: batched IS-A child load of '${slice.childName}' failed (${res?.ErrorMessage || 'no result'}); ${slice.parents.length} parent record(s) continue without their subtype`, 'IS-A');
6037
+ for (const p of slice.parents)
6038
+ unlinkedParents.push(p);
6039
+ return;
6040
+ }
6041
+ const rowByPk = new Map();
6042
+ for (const row of res.Results) {
6043
+ const pkVal = row[slice.pkFieldName];
6044
+ if (pkVal != null) {
6045
+ rowByPk.set(CanonicalPrimaryKeyValue(String(pkVal), slice.pkIsUuid), row);
6046
+ }
6047
+ }
6048
+ for (const p of slice.parents) {
6049
+ const pkKey = CanonicalPrimaryKeyValue(String(p.PrimaryKey.Values()), slice.pkIsUuid);
6050
+ const childRow = rowByPk.get(pkKey);
6051
+ if (!childRow) {
6052
+ unlinkedParents.push(p);
6053
+ continue;
6054
+ }
6055
+ const childEntity = await p.createChildEntityObject(slice.childName);
6056
+ if (!childEntity || !childEntity.CheckPermissions(EntityPermissionType.Read, false)) {
6057
+ // The per-row path threw here (InnerLoad → CheckPermissions(Read, true)). The batch
6058
+ // path degrades to a plain parent record instead, and says so once per slice.
6059
+ if (!deniedChildLogged.has(slice.childName)) {
6060
+ deniedChildLogged.add(slice.childName);
6061
+ LogWarning(`BaseEntity: current user cannot read IS-A child '${slice.childName}'; matching parent records are returned without their subtype`, 'IS-A');
6062
+ }
6063
+ unlinkedParents.push(p);
6064
+ continue;
6065
+ }
6066
+ await p.linkHydratedChildFromBatchRow(childEntity, childRow);
6067
+ outLinkedChildren.push(childEntity);
6068
+ }
6069
+ }
6070
+ async linkHydratedChildFromBatchRow(childEntity, childRow) {
6071
+ this.replaceChildParentChain(childEntity);
6072
+ this._childEntity = childEntity;
6073
+ childEntity.discardJSONFieldObjects();
6074
+ const narrowed = childEntity.narrowHydrationSource(childRow);
6075
+ childEntity.resetAndApplyOwnedFieldsInPlace(narrowed);
6076
+ childEntity._recordLoaded = true;
6077
+ childEntity._everSaved = true;
6078
+ childEntity._compositeKey = this.PrimaryKey;
6079
+ childEntity.cacheRecordName();
6080
+ await childEntity.deserializeCompanionsFromData(narrowed);
6081
+ }
5305
6082
  /**
5306
6083
  * Extracts and applies a companion payload carried inside a plain data object, if present.
5307
6084
  *
@@ -5795,20 +6572,53 @@ export class BaseEntity {
5795
6572
  if (childEntities.length === 0) {
5796
6573
  return { HasChildren: false, ChildEntityName: '' };
5797
6574
  }
5798
- // Use RunView on this instance's provider — a host RunView cannot see
5799
- // uncommitted child rows on a graph-scoped connection.
5800
- const rv = new RunView(this.RunViewProviderToUse);
5801
6575
  const pkValue = this.PrimaryKey.Values();
5802
- for (const childEntity of childEntities) {
6576
+ // Tier 1: Single UNION ALL SQL query on this entity's bound DatabaseProviderBase
6577
+ // (executes inside any active transaction on this.ProviderToUse in 1 round-trip instead of K).
6578
+ const entityProvider = this.ProviderToUse;
6579
+ if (typeof entityProvider?.FindISAChildEntity === 'function') {
6580
+ const found = await entityProvider.FindISAChildEntity(this.EntityInfo, String(pkValue), this._contextCurrentUser);
6581
+ if (found?.ChildEntityName) {
6582
+ return { HasChildren: true, ChildEntityName: found.ChildEntityName };
6583
+ }
6584
+ return { HasChildren: false, ChildEntityName: '' };
6585
+ }
6586
+ // Tier 2: Batched RunViews on this instance's RunView provider (GraphQLDataProvider or test mocks)
6587
+ const rvProvider = this.RunViewProviderToUse;
6588
+ const rv = new RunView(rvProvider);
6589
+ const validChildren = childEntities.filter(c => c.FirstPrimaryKey != null); // first-pk-ok: IS-A children share the parent's single key
6590
+ if (validChildren.length === 0) {
6591
+ return { HasChildren: false, ChildEntityName: '' };
6592
+ }
6593
+ if (rvProvider && typeof rvProvider.RunViews === 'function') {
6594
+ const viewParams = validChildren.map(childEntity => {
6595
+ const pkField = childEntity.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
6596
+ return {
6597
+ EntityName: childEntity.Name,
6598
+ ExtraFilter: `${pkField.Name} = '${EscapeSQLString(String(pkValue))}'`,
6599
+ ResultType: 'simple',
6600
+ Fields: [pkField.Name],
6601
+ MaxRows: 1,
6602
+ };
6603
+ });
6604
+ const results = await rv.RunViews(viewParams, this._contextCurrentUser);
6605
+ for (let i = 0; i < validChildren.length; i++) {
6606
+ const result = results[i];
6607
+ if (result?.Success && result.Results && result.Results.length > 0) {
6608
+ return { HasChildren: true, ChildEntityName: validChildren[i].Name };
6609
+ }
6610
+ }
6611
+ return { HasChildren: false, ChildEntityName: '' };
6612
+ }
6613
+ for (const childEntity of validChildren) {
5803
6614
  const pkField = childEntity.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
5804
- if (!pkField)
5805
- continue;
5806
6615
  const result = await rv.RunView({
5807
6616
  EntityName: childEntity.Name,
5808
- ExtraFilter: `${pkField.Name} = '${pkValue}'`,
6617
+ ExtraFilter: `${pkField.Name} = '${EscapeSQLString(String(pkValue))}'`,
5809
6618
  ResultType: 'simple',
5810
6619
  Fields: [pkField.Name],
5811
- MaxRows: 1
6620
+ MaxRows: 1,
6621
+ SkipTotalRowCount: true
5812
6622
  }, this._contextCurrentUser);
5813
6623
  if (result && result.Success && result.Results && result.Results.length > 0) {
5814
6624
  return { HasChildren: true, ChildEntityName: childEntity.Name };
@@ -5870,18 +6680,50 @@ export class BaseEntity {
5870
6680
  if (childEntities.length === 0) {
5871
6681
  return { LeafEntityName: entityInfo.Name, IsLeaf: true };
5872
6682
  }
5873
- const rv = new RunView((provider ?? BaseEntity.Provider));
5874
6683
  const pkValue = primaryKey.Values();
5875
- for (const child of childEntities) {
6684
+ const candidateEntityProvider = (provider ?? BaseEntity.Provider);
6685
+ if (typeof candidateEntityProvider?.FindISAChildEntity === 'function') {
6686
+ const found = await candidateEntityProvider.FindISAChildEntity(entityInfo, String(pkValue), contextUser);
6687
+ if (found?.ChildEntityName) {
6688
+ const matchedChild = childEntities.find(c => sameEntityName(c.Name, found.ChildEntityName));
6689
+ if (matchedChild) {
6690
+ return BaseEntity.resolveLeafEntityRecursive(matchedChild, primaryKey, contextUser, provider);
6691
+ }
6692
+ }
6693
+ return { LeafEntityName: entityInfo.Name, IsLeaf: true };
6694
+ }
6695
+ const rvProvider = (provider ?? BaseEntity.Provider);
6696
+ const rv = new RunView(rvProvider);
6697
+ const validChildren = childEntities.filter(c => c.FirstPrimaryKey != null); // first-pk-ok: IS-A children share the parent's single key
6698
+ if (rvProvider && typeof rvProvider.RunViews === 'function' && validChildren.length > 0) {
6699
+ const viewParams = validChildren.map(child => {
6700
+ const childPK = child.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
6701
+ return {
6702
+ EntityName: child.Name,
6703
+ ExtraFilter: `${childPK.Name} = '${EscapeSQLString(String(pkValue))}'`,
6704
+ ResultType: 'simple',
6705
+ Fields: [childPK.Name],
6706
+ MaxRows: 1,
6707
+ };
6708
+ });
6709
+ const results = await rv.RunViews(viewParams, contextUser);
6710
+ for (let i = 0; i < validChildren.length; i++) {
6711
+ const result = results[i];
6712
+ if (result?.Success && result.Results?.length > 0) {
6713
+ return BaseEntity.resolveLeafEntityRecursive(validChildren[i], primaryKey, contextUser, provider);
6714
+ }
6715
+ }
6716
+ return { LeafEntityName: entityInfo.Name, IsLeaf: true };
6717
+ }
6718
+ for (const child of validChildren) {
5876
6719
  const childPK = child.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
5877
- if (!childPK)
5878
- continue;
5879
6720
  const result = await rv.RunView({
5880
6721
  EntityName: child.Name,
5881
- ExtraFilter: `${childPK.Name} = '${pkValue}'`,
6722
+ ExtraFilter: `${childPK.Name} = '${EscapeSQLString(String(pkValue))}'`,
5882
6723
  ResultType: 'simple',
5883
6724
  Fields: [childPK.Name],
5884
- MaxRows: 1
6725
+ MaxRows: 1,
6726
+ SkipTotalRowCount: true
5885
6727
  }, contextUser);
5886
6728
  if (result?.Success && result.Results?.length > 0) {
5887
6729
  // Found a child — recurse to see if there's an even more specific leaf
@@ -5928,10 +6770,11 @@ export class BaseEntity {
5928
6770
  return;
5929
6771
  const viewParams = validSiblings.map(sibling => ({
5930
6772
  EntityName: sibling.Name,
5931
- ExtraFilter: `${sibling.FirstPrimaryKey.Name} = '${pkValue}'`, // first-pk-ok: IS-A shared key
6773
+ ExtraFilter: `${sibling.FirstPrimaryKey.Name} = '${EscapeSQLString(String(pkValue))}'`, // first-pk-ok: IS-A shared key
5932
6774
  ResultType: 'simple',
5933
6775
  Fields: [sibling.FirstPrimaryKey.Name], // first-pk-ok: IS-A shared key
5934
- MaxRows: 1
6776
+ MaxRows: 1,
6777
+ SkipTotalRowCount: true
5935
6778
  }));
5936
6779
  const results = await rv.RunViews(viewParams, this._contextCurrentUser);
5937
6780
  for (let i = 0; i < results.length; i++) {
@@ -6005,7 +6848,7 @@ export class BaseEntity {
6005
6848
  //
6006
6849
  // Returning null is the same answer callers already handle for "this entity has no name
6007
6850
  // field", and every one of them degrades to the primary key.
6008
- if (!this.EntityInfo.IsFieldReadableByUser(f.Name, this.ActiveUser)) {
6851
+ if (!this.IsFieldReadableByActiveUser(f.Name)) {
6009
6852
  return null;
6010
6853
  }
6011
6854
  return this.Get(f.Name);
@@ -6135,7 +6978,8 @@ export class BaseEntity {
6135
6978
  }
6136
6979
  /**
6137
6980
  * Generates a vector embedding for a single text field using AI engine.
6138
- * Only generates embeddings for new records or when the source field has changed.
6981
+ * Only generates embeddings for new records, when the source field has changed, or when
6982
+ * {@link IsEmbeddingStale} reports the stored vector came from another model.
6139
6983
  * Stores the vector embedding, the model ID used to generate it and, when the entity has one, the
6140
6984
  * vector's binary companion.
6141
6985
  *
@@ -6153,33 +6997,46 @@ export class BaseEntity {
6153
6997
  * @returns Promise that resolves to true if embedding was generated successfully, false otherwise
6154
6998
  */
6155
6999
  async GenerateEmbedding(field, vectorField, modelField, binaryVectorField) {
7000
+ const clearVector = () => {
7001
+ vectorField.Value = null;
7002
+ if (binaryVectorField)
7003
+ binaryVectorField.Value = null;
7004
+ if (modelField)
7005
+ modelField.Value = null;
7006
+ };
7007
+ // After an edit to a saved record, its stored vector describes the old text, so a failed
7008
+ // re-embed clears it rather than leaving it searchable.
7009
+ const edited = this.IsSaved && field.Dirty;
6156
7010
  try {
6157
7011
  if (this._skipEmbeddings)
6158
7012
  return true;
6159
- if (!this.IsSaved || field.Dirty) {
7013
+ const stale = vectorField.Value != null && await this.IsEmbeddingStale(modelField?.Value ?? null);
7014
+ if (!this.IsSaved || field.Dirty || stale) {
6160
7015
  if (field.Value?.trim().length > 0) {
6161
7016
  // recalc vector
6162
7017
  const e = await this.EmbedTextLocal(field.Value);
6163
- if (e && e.vector) {
7018
+ // An empty vector is a failed embed (the BaseEmbeddings convention); don't persist it.
7019
+ if (e?.vector?.length) {
6164
7020
  vectorField.Value = JSON.stringify(e.vector);
6165
7021
  if (binaryVectorField)
6166
7022
  binaryVectorField.Value = Float32VectorToBase64(e.vector);
6167
7023
  if (modelField)
6168
7024
  modelField.Value = e.modelID;
6169
7025
  }
7026
+ else if (edited) {
7027
+ clearVector();
7028
+ }
6170
7029
  }
6171
7030
  else {
6172
- vectorField.Value = null;
6173
- if (binaryVectorField)
6174
- binaryVectorField.Value = null;
6175
- if (modelField)
6176
- modelField.Value = null;
7031
+ clearVector();
6177
7032
  }
6178
7033
  }
6179
7034
  return true;
6180
7035
  }
6181
7036
  catch (e) {
6182
7037
  console.error("Error generating embedding:", e);
7038
+ if (edited)
7039
+ clearVector();
6183
7040
  return false;
6184
7041
  }
6185
7042
  }
@@ -6193,6 +7050,18 @@ export class BaseEntity {
6193
7050
  async EmbedTextLocal(textToEmbed) {
6194
7051
  throw new Error("EmbedTextLocal not implemented in BaseEntity, sub-classes must implement this functionality to use it");
6195
7052
  }
7053
+ /**
7054
+ * Whether a stored vector is stale: it came from a model other than the one this entity embeds
7055
+ * with now, or from an unrecorded one. Asked only for a field that holds a vector. A stale vector
7056
+ * is regenerated on the next save even when its text is unchanged, which is how stored vectors
7057
+ * follow a change of embedding model. The base implementation returns false (only new or changed
7058
+ * text is embedded); server-side sub-classes that embed with the platform's default local model
7059
+ * override it.
7060
+ * @param embeddingModelID - The ID of the AI model that produced the stored vector, or null
7061
+ */
7062
+ async IsEmbeddingStale(embeddingModelID) {
7063
+ return false;
7064
+ }
6196
7065
  /**
6197
7066
  * Specifies if the current object supports the @see EmbedTextLocal method or not - useful to know before calling it for conditional
6198
7067
  * code that has fallbacks as needed. BaseEntity does not implement this method but server-side sub-classes often do, but it is not mandatory for