@memberjunction/core 6.2.0-edge.5 → 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 (121) 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/alternateBaseViews.d.ts +119 -0
  6. package/dist/generic/alternateBaseViews.d.ts.map +1 -0
  7. package/dist/generic/alternateBaseViews.js +223 -0
  8. package/dist/generic/alternateBaseViews.js.map +1 -0
  9. package/dist/generic/baseEngine.d.ts +53 -9
  10. package/dist/generic/baseEngine.d.ts.map +1 -1
  11. package/dist/generic/baseEngine.js +194 -35
  12. package/dist/generic/baseEngine.js.map +1 -1
  13. package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
  14. package/dist/generic/baseEngineRegistry.js +2 -1
  15. package/dist/generic/baseEngineRegistry.js.map +1 -1
  16. package/dist/generic/baseEntity.d.ts +153 -17
  17. package/dist/generic/baseEntity.d.ts.map +1 -1
  18. package/dist/generic/baseEntity.js +345 -46
  19. package/dist/generic/baseEntity.js.map +1 -1
  20. package/dist/generic/contextTokens.d.ts +136 -0
  21. package/dist/generic/contextTokens.d.ts.map +1 -0
  22. package/dist/generic/contextTokens.js +319 -0
  23. package/dist/generic/contextTokens.js.map +1 -0
  24. package/dist/generic/databaseProviderBase.d.ts +66 -5
  25. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  26. package/dist/generic/databaseProviderBase.js +159 -12
  27. package/dist/generic/databaseProviderBase.js.map +1 -1
  28. package/dist/generic/embeddedRecord.d.ts +6 -0
  29. package/dist/generic/embeddedRecord.d.ts.map +1 -1
  30. package/dist/generic/embeddedRecord.js +15 -1
  31. package/dist/generic/embeddedRecord.js.map +1 -1
  32. package/dist/generic/entityCompanion.d.ts +7 -0
  33. package/dist/generic/entityCompanion.d.ts.map +1 -1
  34. package/dist/generic/entityCompanion.js +8 -0
  35. package/dist/generic/entityCompanion.js.map +1 -1
  36. package/dist/generic/entityInfo.d.ts +228 -9
  37. package/dist/generic/entityInfo.d.ts.map +1 -1
  38. package/dist/generic/entityInfo.js +574 -65
  39. package/dist/generic/entityInfo.js.map +1 -1
  40. package/dist/generic/entityValueFieldSecurity.d.ts +139 -0
  41. package/dist/generic/entityValueFieldSecurity.d.ts.map +1 -0
  42. package/dist/generic/entityValueFieldSecurity.js +250 -0
  43. package/dist/generic/entityValueFieldSecurity.js.map +1 -0
  44. package/dist/generic/fieldSecuritySQLGate.d.ts +350 -0
  45. package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -0
  46. package/dist/generic/fieldSecuritySQLGate.js +898 -0
  47. package/dist/generic/fieldSecuritySQLGate.js.map +1 -0
  48. package/dist/generic/indexRestrictedFields.d.ts +47 -0
  49. package/dist/generic/indexRestrictedFields.d.ts.map +1 -0
  50. package/dist/generic/indexRestrictedFields.js +129 -0
  51. package/dist/generic/indexRestrictedFields.js.map +1 -0
  52. package/dist/generic/interfaces.d.ts +14 -1
  53. package/dist/generic/interfaces.d.ts.map +1 -1
  54. package/dist/generic/interfaces.js.map +1 -1
  55. package/dist/generic/localCacheManager.d.ts +80 -11
  56. package/dist/generic/localCacheManager.d.ts.map +1 -1
  57. package/dist/generic/localCacheManager.js +209 -38
  58. package/dist/generic/localCacheManager.js.map +1 -1
  59. package/dist/generic/logging.d.ts.map +1 -1
  60. package/dist/generic/logging.js +7 -1
  61. package/dist/generic/logging.js.map +1 -1
  62. package/dist/generic/metadata.d.ts +3 -2
  63. package/dist/generic/metadata.d.ts.map +1 -1
  64. package/dist/generic/metadata.js +5 -2
  65. package/dist/generic/metadata.js.map +1 -1
  66. package/dist/generic/platformSQL.d.ts +54 -0
  67. package/dist/generic/platformSQL.d.ts.map +1 -1
  68. package/dist/generic/platformSQL.js +55 -0
  69. package/dist/generic/platformSQL.js.map +1 -1
  70. package/dist/generic/providerBase.d.ts +254 -56
  71. package/dist/generic/providerBase.d.ts.map +1 -1
  72. package/dist/generic/providerBase.js +698 -195
  73. package/dist/generic/providerBase.js.map +1 -1
  74. package/dist/generic/queryInfo.d.ts +13 -0
  75. package/dist/generic/queryInfo.d.ts.map +1 -1
  76. package/dist/generic/queryInfo.js +13 -0
  77. package/dist/generic/queryInfo.js.map +1 -1
  78. package/dist/generic/queryInfoInterfaces.d.ts +8 -0
  79. package/dist/generic/queryInfoInterfaces.d.ts.map +1 -1
  80. package/dist/generic/queryTemplateContext.d.ts +49 -0
  81. package/dist/generic/queryTemplateContext.d.ts.map +1 -0
  82. package/dist/generic/queryTemplateContext.js +113 -0
  83. package/dist/generic/queryTemplateContext.js.map +1 -0
  84. package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -1
  85. package/dist/generic/recordChangeFieldSecurity.js +13 -2
  86. package/dist/generic/recordChangeFieldSecurity.js.map +1 -1
  87. package/dist/generic/relatedRecordCollection.d.ts +13 -0
  88. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  89. package/dist/generic/relatedRecordCollection.js +44 -5
  90. package/dist/generic/relatedRecordCollection.js.map +1 -1
  91. package/dist/generic/runQuery.d.ts +42 -0
  92. package/dist/generic/runQuery.d.ts.map +1 -1
  93. package/dist/generic/runQuery.js +14 -0
  94. package/dist/generic/runQuery.js.map +1 -1
  95. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  96. package/dist/generic/runQuerySQLFilterImplementations.js +38 -2
  97. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  98. package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
  99. package/dist/generic/saveEntityGraphOperation.js +3 -0
  100. package/dist/generic/saveEntityGraphOperation.js.map +1 -1
  101. package/dist/generic/securityInfo.d.ts +11 -0
  102. package/dist/generic/securityInfo.d.ts.map +1 -1
  103. package/dist/generic/securityInfo.js +15 -0
  104. package/dist/generic/securityInfo.js.map +1 -1
  105. package/dist/generic/telemetryManager.d.ts +9 -0
  106. package/dist/generic/telemetryManager.d.ts.map +1 -1
  107. package/dist/generic/telemetryManager.js +5 -1
  108. package/dist/generic/telemetryManager.js.map +1 -1
  109. package/dist/index.d.ts +7 -0
  110. package/dist/index.d.ts.map +1 -1
  111. package/dist/index.js +7 -0
  112. package/dist/index.js.map +1 -1
  113. package/dist/views/runView.d.ts +89 -0
  114. package/dist/views/runView.d.ts.map +1 -1
  115. package/dist/views/runView.js +36 -0
  116. package/dist/views/runView.js.map +1 -1
  117. package/dist/views/viewProperties.d.ts +55 -0
  118. package/dist/views/viewProperties.d.ts.map +1 -0
  119. package/dist/views/viewProperties.js +258 -0
  120. package/dist/views/viewProperties.js.map +1 -0
  121. package/package.json +3 -3
@@ -8,7 +8,7 @@ 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 } 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';
@@ -25,6 +25,7 @@ import { EmbeddedRecord } from './embeddedRecord.js';
25
25
  import { EntitySavePlan, ExecuteEntitySavePlan } from './entitySavePlan.js';
26
26
  import { JSONFieldBinding, ValidateJSONFieldValue } from './jsonFieldBinding.js';
27
27
  import { BaseRemotableOperation } from './baseRemotableOperation.js';
28
+ import { OmitDeniedFieldKeys, OmitFieldsUnreadableByActiveUser } from './entityValueFieldSecurity.js';
28
29
  import { SAVE_ENTITY_GRAPH_OPERATION_KEY, } from './saveEntityGraphOperation.js';
29
30
  import { finalize, firstValueFrom, from, of, shareReplay, Subject, switchMap } from 'rxjs';
30
31
  /**
@@ -79,8 +80,9 @@ let EntityField = class EntityField {
79
80
  * `InnerLoad` — reusing it would conflate defaults with omissions. This flag is set only
80
81
  * by the hydration paths (via {@link MarkNotLoaded}) when a source omits the key, is
81
82
  * 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.
83
+ * legitimate write-only update and must save). On a new record it marks only the fields the
84
+ * record's user may not read; every other field legitimately holds its metadata default for
85
+ * INSERT.
84
86
  */
85
87
  get NotLoaded() {
86
88
  return this._notLoaded;
@@ -183,7 +185,18 @@ let EntityField = class EntityField {
183
185
  return this._entityFieldInfo.HasDefaultValue;
184
186
  }
185
187
  /**
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.
188
+ * Sets the value of the field.
189
+ *
190
+ * - A read-only field accepts ONE set after construction or {@link ResetNeverSetFlag} (how a
191
+ * load populates it); later sets are ignored.
192
+ * - Dirty state is not stored here: {@link Dirty} compares the value with {@link OldValue} when
193
+ * it is read. A set during record setup (the first set of a never-set field) also becomes
194
+ * the old value, so it is not dirty.
195
+ * - A set onto a {@link NotLoaded} field clears that flag and is always dirty, whatever the
196
+ * value, even one equal to the field's construction state. The write therefore reaches the
197
+ * save and its update-permission check.
198
+ * - No field-level security or active-status check happens here. Those live at
199
+ * `BaseEntity.Get`/`Set` and at the save; this accessor is framework-internal.
187
200
  */
188
201
  set Value(value) {
189
202
  // No active-status assertion here — see the Value getter note. Deprecation/disabled checks
@@ -527,6 +540,11 @@ let EntityField = class EntityField {
527
540
  }
528
541
  /**
529
542
  * 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.
543
+ *
544
+ * It also clears the written-after-not-loaded flag: the current value becomes the field's
545
+ * loaded baseline. The hydration paths rely on that (a reload that supplies the field must not
546
+ * leave it dirty), so code outside hydration must not call this to "accept" a write onto a
547
+ * not-loaded field, or the write would be saved without being seen as a change.
530
548
  */
531
549
  ResetOldValue() {
532
550
  this._oldValue = this.Value;
@@ -1649,20 +1667,35 @@ export class BaseEntity {
1649
1667
  return e instanceof Error ? e.message : String(e);
1650
1668
  }
1651
1669
  /**
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.
1670
+ * The create path's SubtypeSelector lookup memo, partitioned by the provider that ran the lookup and
1671
+ * then by the user it ran for. A memo shared across providers would hand provider B a row loaded
1672
+ * through provider A (another connection) or a `null` miss recorded against A's database. A memo
1673
+ * shared across users would hand one user an entity object shaped to another: an object holds only
1674
+ * the fields its own user may read, so a record loaded for an administrator carries values a
1675
+ * restricted user is denied. Keyed weakly so a request-scoped provider's memo is collected with it.
1656
1676
  */
1657
1677
  static { this._subtypeLookupCache = new WeakMap(); }
1658
1678
  /** Partition key for lookups made by an entity that has no provider at all. */
1659
1679
  static { this._noProviderSubtypeKey = {}; }
1660
- static subtypeLookupCacheFor(provider) {
1680
+ /**
1681
+ * The memo for one provider and user, or null when no user resolves: an entry keyed without a
1682
+ * user would be shared by every caller, so such a lookup is not memoized at all.
1683
+ */
1684
+ static subtypeLookupCacheFor(provider, user) {
1685
+ const userKey = UserCacheKeySegment(user?.ID);
1686
+ if (userKey === null) {
1687
+ return null;
1688
+ }
1661
1689
  const partitionKey = provider ?? BaseEntity._noProviderSubtypeKey;
1662
- let cache = BaseEntity._subtypeLookupCache.get(partitionKey);
1690
+ let byUser = BaseEntity._subtypeLookupCache.get(partitionKey);
1691
+ if (!byUser) {
1692
+ byUser = new Map();
1693
+ BaseEntity._subtypeLookupCache.set(partitionKey, byUser);
1694
+ }
1695
+ let cache = byUser.get(userKey);
1663
1696
  if (!cache) {
1664
1697
  cache = new Map();
1665
- BaseEntity._subtypeLookupCache.set(partitionKey, cache);
1698
+ byUser.set(userKey, cache);
1666
1699
  }
1667
1700
  return cache;
1668
1701
  }
@@ -1866,12 +1899,16 @@ export class BaseEntity {
1866
1899
  return null;
1867
1900
  let currentEntity = this;
1868
1901
  let currentEntityInfo = this.EntityInfo;
1902
+ const actingUser = this.resolveActiveUserOrNull();
1869
1903
  for (let i = 0; i < segments.length - 1; i++) {
1870
1904
  const fieldName = segments[i];
1871
1905
  const fieldInfo = currentEntityInfo.Fields.find(f => f.Name.trim().toLowerCase() === fieldName.toLowerCase());
1872
1906
  if (!fieldInfo) {
1873
1907
  throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': field '${fieldName}' was not found on entity '${currentEntityInfo.Name}'.`);
1874
1908
  }
1909
+ if (!this.subtypeSelectorMayRead(currentEntity, fieldInfo.Name, actingUser)) {
1910
+ return null; // a hop the user may not read gives no subtype, as a denied name field gives no name
1911
+ }
1875
1912
  const fkValue = currentEntity.Get(fieldInfo.Name);
1876
1913
  if (fkValue == null || fkValue === '') {
1877
1914
  return null;
@@ -1892,34 +1929,54 @@ export class BaseEntity {
1892
1929
  if (!terminalField) {
1893
1930
  throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': terminal field '${terminalSegment}' was not found on entity '${currentEntityInfo.Name}'.`);
1894
1931
  }
1932
+ if (!this.subtypeSelectorMayRead(currentEntity, terminalField.Name, actingUser)) {
1933
+ return null;
1934
+ }
1895
1935
  const terminalValue = currentEntity.Get(terminalField.Name);
1896
1936
  if (terminalValue == null || typeof terminalValue !== 'string' || terminalValue.trim() === '') {
1897
1937
  return null;
1898
1938
  }
1899
1939
  return terminalValue.trim();
1900
1940
  }
1941
+ /**
1942
+ * Whether a `SubtypeSelector` hop may read `fieldName` on `record`, which is this record or
1943
+ * one the path reached. Both the record's own user and the user resolving the subtype must be
1944
+ * allowed: a record reached through an engine cache was loaded as the system user and holds
1945
+ * every column, so its own answer says nothing about the acting user.
1946
+ */
1947
+ subtypeSelectorMayRead(record, fieldName, actingUser) {
1948
+ if (!record.IsFieldReadableByActiveUser(fieldName)) {
1949
+ return false;
1950
+ }
1951
+ return record === this || !actingUser || record.EntityInfo.IsFieldReadableByUser(fieldName, actingUser);
1952
+ }
1901
1953
  async getSubtypePathTargetEntity(entityName, pkValue) {
1902
1954
  const cacheKey = `${entityName.trim().toLowerCase()}|${String(pkValue).trim().toLowerCase()}`;
1903
1955
  const provider = this.ProviderToUse;
1904
- const lookupCache = BaseEntity.subtypeLookupCacheFor(provider);
1905
- if (lookupCache.has(cacheKey)) {
1956
+ const lookupCache = BaseEntity.subtypeLookupCacheFor(provider, this.resolveActiveUserOrNull());
1957
+ if (lookupCache?.has(cacheKey)) {
1906
1958
  return lookupCache.get(cacheKey) ?? null;
1907
1959
  }
1908
- // 1. Check BaseEngineRegistry for loaded cached entities
1960
+ const found = await this.findSubtypePathTarget(provider, entityName, pkValue);
1961
+ lookupCache?.set(cacheKey, found);
1962
+ return found;
1963
+ }
1964
+ /**
1965
+ * Finds the record a create-time `SubtypeSelector` hop points at: in a loaded engine cache
1966
+ * first, then by loading it through the provider for this record's user. Null when neither
1967
+ * has it, or the load fails.
1968
+ */
1969
+ async findSubtypePathTarget(provider, entityName, pkValue) {
1909
1970
  const cached = BaseEntity.findSubtypePathTargetInEngineCache(entityName, pkValue);
1910
1971
  if (cached) {
1911
- lookupCache.set(cacheKey, cached);
1912
1972
  return cached;
1913
1973
  }
1914
- // 2. Fall back to loading via provider
1915
1974
  if (!provider?.GetEntityObject) {
1916
- lookupCache.set(cacheKey, null);
1917
1975
  return null;
1918
1976
  }
1919
1977
  try {
1920
1978
  const targetObj = await provider.GetEntityObject(entityName, this._contextCurrentUser);
1921
1979
  if (!targetObj) {
1922
- lookupCache.set(cacheKey, null);
1923
1980
  return null;
1924
1981
  }
1925
1982
  let key;
@@ -1931,17 +1988,11 @@ export class BaseEntity {
1931
1988
  const pkName = targetObj.FirstPrimaryKey.Name; // first-pk-ok: FK target — pkValue is one SubtypeSelector FK column's value
1932
1989
  key.KeyValuePairs.push(new KeyValuePair(pkName, pkValue));
1933
1990
  }
1934
- const loaded = await targetObj.InnerLoad(key);
1935
- if (loaded) {
1936
- lookupCache.set(cacheKey, targetObj);
1937
- return targetObj;
1938
- }
1991
+ return (await targetObj.InnerLoad(key)) ? targetObj : null;
1939
1992
  }
1940
1993
  catch {
1941
- // On load failure, return null
1994
+ return null; // on load failure, no target
1942
1995
  }
1943
- lookupCache.set(cacheKey, null);
1944
- return null;
1945
1996
  }
1946
1997
  /**
1947
1998
  * Finds the record a `SubtypeSelector` hop points at among the entity objects that loaded
@@ -2146,6 +2197,8 @@ export class BaseEntity {
2146
2197
  // _everSaved, and recreates all EntityField instances with _NeverSet = true
2147
2198
  this.init();
2148
2199
  this.discardJSONFieldObjects();
2200
+ // Field security: denied values never enter this object (see narrowHydrationSource).
2201
+ data = this.narrowHydrationSource(data);
2149
2202
  // Recursively hydrate parent entities first (deepest ancestor resets first).
2150
2203
  // After init(), parent fields have fresh _NeverSet=true, so SetMany can set PKs.
2151
2204
  if (this._parentEntity) {
@@ -2410,7 +2463,9 @@ export class BaseEntity {
2410
2463
  }
2411
2464
  const result = await operation.Execute({
2412
2465
  EntityName: this.EntityInfo.Name,
2413
- Fields: this.GetAll(),
2466
+ // Without the fields this user may not read: the server refuses a restricted
2467
+ // caller's input that names one, as the transaction-group serializer knows.
2468
+ Fields: OmitFieldsUnreadableByActiveUser(this, this.GetAll()),
2414
2469
  Companions: await this.SerializeCompanions(),
2415
2470
  IsExistingRecord: this.IsSaved,
2416
2471
  }, { provider: this.ProviderToUse, user: this.ActiveUser });
@@ -2449,9 +2504,14 @@ export class BaseEntity {
2449
2504
  // 'save' event both carry it; hardcoding 'update' here made every remote graph CREATE
2450
2505
  // report (and notify subscribers) as an update.
2451
2506
  const saveSubType = this.IsSaved ? 'update' : 'create';
2507
+ const changedFieldNames = this.dirtyFieldNames();
2452
2508
  // Root: rebase field values and dirty state onto what the database now holds.
2453
2509
  this.init();
2454
- this.SetMany(output.Fields, false, true, true);
2510
+ const fields = this.narrowHydrationSource(output.Fields);
2511
+ this.SetMany(fields, false, true, true);
2512
+ // A hydration source like any other: a field the result omitted (the server withheld it, or
2513
+ // this user may not hold it) is not loaded, so its construction state is never resent as data.
2514
+ this.markFieldsOmittedBySourceAsNotLoaded(fields);
2455
2515
  this._recordLoaded = true;
2456
2516
  this._everSaved = true;
2457
2517
  // Children: adopt server-assigned primary keys and computed values. 'result' mode adopts the
@@ -2465,7 +2525,28 @@ export class BaseEntity {
2465
2525
  result.StartedAt = new Date();
2466
2526
  result.EndedAt = new Date();
2467
2527
  this.RegisterResultHistoryEntry(result);
2468
- this.RaiseEvent('save', null, saveSubType);
2528
+ this.RaiseEvent('save', null, saveSubType, changedFieldNames);
2529
+ }
2530
+ /**
2531
+ * The names of the fields that are dirty right now. Cheaper than walking {@link Fields}: an
2532
+ * object still in raw mode (loaded and never written to) cannot be dirty, so it answers `[]`
2533
+ * without building its field objects.
2534
+ */
2535
+ GetDirtyFieldNames() {
2536
+ if (!this._fieldsHydrated && this._raw !== null && !this._parentEntity) {
2537
+ return [];
2538
+ }
2539
+ const names = [];
2540
+ for (const f of this.Fields) {
2541
+ if (f.Dirty) {
2542
+ names.push(f.Name);
2543
+ }
2544
+ }
2545
+ return names;
2546
+ }
2547
+ /** The names of the fields that are dirty right now, for the `save` event's {@link BaseEntityEvent.ChangedFieldNames}. */
2548
+ dirtyFieldNames() {
2549
+ return this.GetDirtyFieldNames();
2469
2550
  }
2470
2551
  /**
2471
2552
  * Gives every eager companion a chance to populate itself.
@@ -3080,7 +3161,7 @@ export class BaseEntity {
3080
3161
  /**
3081
3162
  * Used for raising events within the BaseEntity and can be used by sub-classes to raise events that are specific to the entity.
3082
3163
  */
3083
- RaiseEvent(type, payload, saveSubType = undefined) {
3164
+ RaiseEvent(type, payload, saveSubType = undefined, changedFieldNames) {
3084
3165
  // Resolve the entity's bound provider once so every consumer (local subscribers
3085
3166
  // AND MJGlobal listeners) receives consistent provider context. This matters in
3086
3167
  // multi-provider scenarios — listeners like LocalCacheManager / BaseEngine need to
@@ -3091,7 +3172,7 @@ export class BaseEntity {
3091
3172
  const provider = this.ProviderToUse;
3092
3173
  // this is the local event handler that is specific to THIS instance of the entity object
3093
3174
  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 });
3175
+ this._eventSubject?.next({ type: type, payload: payload, saveSubType: saveSubType, baseEntity: this, provider, ChangedFieldNames: changedFieldNames });
3095
3176
  // this next call is to MJGlobal to let everyone who cares knows that we had an event on an entity object
3096
3177
  // we broadcast save/delete/load events and their _started counterparts
3097
3178
  if (BaseEntity._globalEventTypes.has(type)) {
@@ -3101,6 +3182,7 @@ export class BaseEntity {
3101
3182
  event.type = type;
3102
3183
  event.saveSubType = saveSubType;
3103
3184
  event.provider = provider;
3185
+ event.ChangedFieldNames = changedFieldNames;
3104
3186
  LogDebug(`BaseEntity.RaiseEvent() - ${type === 'save' ? 'save:' + saveSubType : type} event raised for ${this.EntityInfo.Name}, about to call MJGlobal.RaiseEvent()`);
3105
3187
  MJGlobal.Instance.RaiseEvent({
3106
3188
  component: this,
@@ -3241,6 +3323,7 @@ export class BaseEntity {
3241
3323
  get TransactionGroup() {
3242
3324
  return this._transactionGroup;
3243
3325
  }
3326
+ /** Set `null` to detach the entity from a group, e.g. once the group has been submitted. */
3244
3327
  set TransactionGroup(group) {
3245
3328
  this._transactionGroup = group;
3246
3329
  }
@@ -3492,11 +3575,14 @@ export class BaseEntity {
3492
3575
  * nothing like field security.
3493
3576
  *
3494
3577
  * 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.
3578
+ * build) reads `EntityField.Value` directly and never routes through here. It needs no gate
3579
+ * of its own: every hydration point leaves a denied field not-loaded and empty for a
3580
+ * restricted user (see {@link narrowHydrationSource}), so there is no denied value for that
3581
+ * machinery to read. This assertion is what turns asking for one into an error rather than a
3582
+ * silent null.
3497
3583
  */
3498
3584
  assertFieldReadable(fieldName) {
3499
- const denied = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedReadFields(u));
3585
+ const denied = this.deniedReadFieldsForActiveUser();
3500
3586
  if (denied?.has(fieldName?.trim().toLowerCase())) {
3501
3587
  throw new FieldSecurityError(fieldName, this.EntityInfo.Name);
3502
3588
  }
@@ -3812,6 +3898,53 @@ export class BaseEntity {
3812
3898
  }
3813
3899
  return obj;
3814
3900
  }
3901
+ /**
3902
+ * Whether {@link GetAll} would carry a key for every non-binary field of this entity: the
3903
+ * object holds a value for each one (none is not loaded, or withheld from its user), here or,
3904
+ * for an IS-A child, on an ancestor. Answers the question
3905
+ * `LocalCacheManager.RowHasEveryField(this.EntityInfo, this.GetAll())` answers, without
3906
+ * building the row, and without building field objects for an object in raw mode.
3907
+ */
3908
+ HoldsEveryField() {
3909
+ const infos = this.EntityInfo?.Fields;
3910
+ if (!Array.isArray(infos) || infos.length === 0) {
3911
+ return true;
3912
+ }
3913
+ if (!this._fieldsHydrated && this._raw !== null) {
3914
+ for (const fi of infos) {
3915
+ if (!fi.IsBinaryFieldType && this.rawValueFor(this._raw, fi) === undefined && !this.ancestorHoldsField(fi.Name)) {
3916
+ return false;
3917
+ }
3918
+ }
3919
+ return true;
3920
+ }
3921
+ for (const field of this.Fields) {
3922
+ if (!field.EntityFieldInfo.IsBinaryFieldType && field.NotLoaded && !this.ancestorHoldsField(field.Name)) {
3923
+ return false;
3924
+ }
3925
+ }
3926
+ return true;
3927
+ }
3928
+ /** Whether an IS-A ancestor's {@link GetAll} would carry the key `name` (an exact field name). */
3929
+ ancestorHoldsField(name) {
3930
+ return this._parentEntity?.holdsFieldNamed(name) ?? false;
3931
+ }
3932
+ /** Whether this object's {@link GetAll} would carry the key `name`, counting its own ancestors. */
3933
+ holdsFieldNamed(name) {
3934
+ if (!this._fieldsHydrated && this._raw !== null) {
3935
+ const fi = this.EntityInfo?.FieldByName(name);
3936
+ if (fi && fi.Name === name && this.rawValueFor(this._raw, fi) !== undefined) {
3937
+ return true;
3938
+ }
3939
+ }
3940
+ else {
3941
+ const field = this.GetFieldByName(name);
3942
+ if (field && field.Name === name && !field.NotLoaded) {
3943
+ return true;
3944
+ }
3945
+ }
3946
+ return this.ancestorHoldsField(name);
3947
+ }
3815
3948
  /**
3816
3949
  * 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
3950
  * It is also helpful for quickly finding the fields that are "dirty".
@@ -4039,19 +4172,43 @@ export class BaseEntity {
4039
4172
  * Called only by the hydration entry points ({@link LoadFromData} both modes, {@link Hydrate},
4040
4173
  * {@link InnerLoad}) — never by plain {@link SetMany}, which is an incremental mutation API
4041
4174
  * 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.
4175
+ * Both the field Name and CodeName are checked, case-insensitively, matching SetMany's key
4176
+ * acceptance: SetMany applies `{name: 'b'}` to a field called `Name`, so an exact-case lookup
4177
+ * here would mark the value it just applied as not loaded, and GetAll and the save would then
4178
+ * drop it.
4043
4179
  */
4044
4180
  markFieldsOmittedBySourceAsNotLoaded(source) {
4045
4181
  if (!source || typeof source !== 'object')
4046
4182
  return;
4183
+ // Exact keys first: a source keyed by the field names, the usual case, never builds the
4184
+ // case-insensitive key set. It is built once, on the first field the exact check misses.
4185
+ let presentKeys = null;
4047
4186
  for (const field of this.Fields) {
4048
- if (source[field.Name] === undefined && source[field.CodeName] === undefined) {
4187
+ if (BaseEntity.ownValuePresent(source, field.Name) || BaseEntity.ownValuePresent(source, field.CodeName)) {
4188
+ continue;
4189
+ }
4190
+ presentKeys ??= BaseEntity.normalizedPresentKeys(source);
4191
+ if (!presentKeys.has(field.Name.trim().toLowerCase()) && !presentKeys.has(field.CodeName.trim().toLowerCase())) {
4049
4192
  // Cleared, not just flagged: otherwise a default from construction, or the previous
4050
4193
  // record's value on a reload, reads back through Get() as if it had been loaded.
4051
4194
  field.ClearAsNotLoaded();
4052
4195
  }
4053
4196
  }
4054
4197
  }
4198
+ /** Whether `source` holds `key` as its own property with a value (an inherited `constructor` does not count). */
4199
+ static ownValuePresent(source, key) {
4200
+ return source[key] !== undefined && Object.prototype.hasOwnProperty.call(source, key);
4201
+ }
4202
+ /** The keys of `source` that carry a value, trimmed and lowercased. */
4203
+ static normalizedPresentKeys(source) {
4204
+ const keys = new Set();
4205
+ for (const key of Object.keys(source)) {
4206
+ if (source[key] !== undefined) {
4207
+ keys.add(key.trim().toLowerCase());
4208
+ }
4209
+ }
4210
+ return keys;
4211
+ }
4055
4212
  /**
4056
4213
  * 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
4214
  * 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 +4218,15 @@ export class BaseEntity {
4061
4218
  */
4062
4219
  CopyFrom(other, includePrimaryKeys = false, replaceOldValues = false) {
4063
4220
  try {
4221
+ // Field security: a field THIS object's user may not read is skipped, not copied. The
4222
+ // source can be full width (another user's object, or a server-internal one), and
4223
+ // Set() refuses a denied field, which used to end the copy at the first one.
4224
+ const denied = this.deniedReadFieldsForActiveUser();
4064
4225
  // iterate through all of OUR fields and set them to the value of the other object, if they exist in the other object
4065
4226
  for (let field of this.Fields) {
4227
+ if (denied?.has(field.Name.trim().toLowerCase())) {
4228
+ continue;
4229
+ }
4066
4230
  if (!field.IsPrimaryKey || includePrimaryKeys) {
4067
4231
  const otherField = other.GetFieldByName(field.Name);
4068
4232
  // Skip fields the SOURCE never loaded (D-3): copying their construction
@@ -4086,9 +4250,27 @@ export class BaseEntity {
4086
4250
  * 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
4251
  * 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
4252
  * be provided for the ContextCurrentUser.
4253
+ *
4254
+ * **Field security.** An object holds only what its user may read, so assigning a different
4255
+ * user to an object that already holds a record narrows it to the new user: values the new
4256
+ * user may not read are dropped (the fields become not loaded) from this object, its IS-A
4257
+ * parent, its embedded records and the loaded children of its related-record collections,
4258
+ * which are given the same user. A read-only cache collection's children are the engine's own
4259
+ * instances and are left as they are. Nothing is widened: a field
4260
+ * the previous user could not read stays not loaded for a user who can, because its value was
4261
+ * never loaded. Set the user before loading wherever possible, so the record is loaded at the
4262
+ * right width in the first place.
4089
4263
  */
4090
4264
  set ContextCurrentUser(user) {
4265
+ if (user === this._contextCurrentUser) {
4266
+ return;
4267
+ }
4091
4268
  this._contextCurrentUser = user;
4269
+ this.narrowHeldValuesForActiveUser(false);
4270
+ if (this._parentEntity) {
4271
+ this._parentEntity.ContextCurrentUser = user;
4272
+ }
4273
+ this.notifyCompanionsContextUserChanged(user);
4092
4274
  }
4093
4275
  get ContextCurrentUser() {
4094
4276
  return this._contextCurrentUser;
@@ -4155,6 +4337,11 @@ export class BaseEntity {
4155
4337
  }
4156
4338
  }
4157
4339
  }
4340
+ // Field security: a new record holds no default for a field its user may not read. The
4341
+ // field is not loaded instead, so GetAll() and every payload built from it leave it out
4342
+ // (the server refuses a restricted caller's input that names it), and the save omits it,
4343
+ // so the column takes its database default.
4344
+ this.narrowHeldValuesForActiveUser(true);
4158
4345
  // Apply caller-supplied values LAST so an explicit PK (or any routed
4159
4346
  // parent field) wins over the generated/adopted value instead of being
4160
4347
  // clobbered by the parent's NewRecord().
@@ -4181,6 +4368,53 @@ export class BaseEntity {
4181
4368
  }
4182
4369
  }
4183
4370
  }
4371
+ /**
4372
+ * Gives every companion this object's new user: embedded records and the loaded children of
4373
+ * related-record collections take it. See {@link ContextCurrentUser}.
4374
+ */
4375
+ notifyCompanionsContextUserChanged(user) {
4376
+ if (!this.HasCompanions) {
4377
+ return;
4378
+ }
4379
+ for (const companion of this.Companions) {
4380
+ companion.OnOwnerContextUserChanged(user);
4381
+ }
4382
+ }
4383
+ /**
4384
+ * Drops every value this object holds that its user may not read: the field is left not
4385
+ * loaded and empty, or, before the fields are built, the key leaves the raw row (as a copy;
4386
+ * the row can be shared). A no-op when field security does not apply or the user is denied
4387
+ * nothing, which is the one boolean most entities pay.
4388
+ *
4389
+ * For the points where the user or the record changes WITHOUT a hydration source to narrow:
4390
+ * a user assigned to an object that already holds a record, and a new record's defaults.
4391
+ *
4392
+ * @param buildFields true to build the fields first when they are not built yet, as a new
4393
+ * record needs: its defaults live in fields that are only created on first use.
4394
+ */
4395
+ narrowHeldValuesForActiveUser(buildFields) {
4396
+ const denied = this.deniedReadFieldsForActiveUser();
4397
+ if (!denied) {
4398
+ return;
4399
+ }
4400
+ if (!this._fieldsHydrated && !buildFields) {
4401
+ if (this._raw) {
4402
+ this._raw = OmitDeniedFieldKeys(this._raw, denied);
4403
+ this._rawConverted = null;
4404
+ }
4405
+ return;
4406
+ }
4407
+ let cleared = false;
4408
+ for (const field of this.Fields) {
4409
+ if (denied.has(field.Name.trim().toLowerCase()) && (!field.NotLoaded || field.Value !== null)) {
4410
+ field.ClearAsNotLoaded();
4411
+ cleared = true;
4412
+ }
4413
+ }
4414
+ if (cleared) {
4415
+ this.discardJSONFieldObjects(); // a parsed JSON object can hold a value just dropped
4416
+ }
4417
+ }
4184
4418
  /**
4185
4419
  * Returns the active restore context for the next save, if any.
4186
4420
  *
@@ -4681,6 +4915,8 @@ export class BaseEntity {
4681
4915
  }
4682
4916
  finalizeSave(data, saveSubType, options) {
4683
4917
  if (data) {
4918
+ // The 'save' event names the fields this save wrote; init() below clears every dirty flag.
4919
+ const changedFieldNames = this.dirtyFieldNames();
4684
4920
  const effectiveReturnMode = options?.SaveReturnMode ?? this.EntityInfo.SaveReturnMode;
4685
4921
  const hasGetAll = (val) => val instanceof BaseEntity || typeof val.GetAll === 'function';
4686
4922
  const rawFieldData = hasGetAll(data) ? data.GetAll() : data;
@@ -4709,6 +4945,10 @@ export class BaseEntity {
4709
4945
  else {
4710
4946
  fieldData = rawFieldData;
4711
4947
  }
4948
+ // The save returns the whole row, whoever saved it. The provider has already built the
4949
+ // Record Changes payload from this entity's pre-save state, so narrowing the response
4950
+ // here costs the audit nothing; it only keeps denied values out of this object.
4951
+ fieldData = this.narrowHydrationSource(fieldData);
4712
4952
  this.init(); // wipe out the current data to flush out the DIRTY flags, load the ID as part of this too
4713
4953
  // IS-A GetAll() merges the parent, including parent virtuals this entity
4714
4954
  // does not own (e.g. OrderHeader on Event Order Line). Keep only columns
@@ -4728,7 +4968,7 @@ export class BaseEntity {
4728
4968
  result.NewValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.Value }; }); // set the latest values here
4729
4969
  // Cache the record name for faster lookups (updates cache with potentially new name)
4730
4970
  this.cacheRecordName();
4731
- this.RaiseEvent('save', null, saveSubType);
4971
+ this.RaiseEvent('save', null, saveSubType, changedFieldNames);
4732
4972
  return true;
4733
4973
  }
4734
4974
  else {
@@ -4864,16 +5104,17 @@ export class BaseEntity {
4864
5104
  * entity was hydrated FROM THE DATABASE on every FLS entity. Two distinct resolver behaviours
4865
5105
  * carry that premise, and BOTH are load-bearing:
4866
5106
  *
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
5107
+ * 1. `AssertClientInputFieldSecurity` (through `AssertClientInputWritableForUser`) rejects
5108
+ * client input naming a field the caller cannot READ, or no field at all, which `SetMany`
5109
+ * would otherwise make genuinely dirty with fabricated data.
5110
+ * 2. `entityInfo.FieldLevelSecurityApplies` forces the truth-load branch, so the entity's
4870
5111
  * non-dirty baseline is the real stored row rather than the client's `OldValues___`.
4871
5112
  *
4872
5113
  * (2) is not redundant with (1). A value arriving through `LoadFromData` is recorded by the
4873
5114
  * EntityField setter as the field's INITIAL value, so it is not dirty — and this check would
4874
5115
  * never see it, while `GenerateSaveSQL` sends it anyway (it filters on `NotLoaded`, never on
4875
5116
  * `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
5117
+ * canonical FLS configuration, and one that leaves (1) with nothing to reject — could write an
4877
5118
  * update-denied field just by pinning its value in `OldValues___` and never naming it in the
4878
5119
  * mutation. If you are considering relaxing that branch condition, this check is what breaks.
4879
5120
  *
@@ -4906,7 +5147,7 @@ export class BaseEntity {
4906
5147
  // deliberately skips the readability assertion (hydration / resolver-apply path),
4907
5148
  // so server-side code can dirty a read-denied field and land here.
4908
5149
  if (deniedRead === undefined) {
4909
- deniedRead = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedReadFields(u));
5150
+ deniedRead = this.deniedReadFieldsForActiveUser();
4910
5151
  }
4911
5152
  throw deniedRead?.has(key)
4912
5153
  ? new FieldSecurityError(field.Name, this.EntityInfo.Name)
@@ -4955,15 +5196,32 @@ export class BaseEntity {
4955
5196
  `omitted field(s) ${suppressed.join(', ')}; each column takes its default`);
4956
5197
  }
4957
5198
  }
5199
+ /**
5200
+ * Whether this object's user may READ the named field: the question `Get()` asks before it
5201
+ * throws a `FieldSecurityError`, answered without throwing.
5202
+ *
5203
+ * For code that reads a field it did not choose, such as a configured path or a display
5204
+ * column, and must treat a denied field as having no value rather than fail. Fails open the
5205
+ * way `Get()` does: true when field security does not apply to this entity (or any IS-A
5206
+ * ancestor) or no user resolves.
5207
+ *
5208
+ * @param fieldName the field's Name, matched case-insensitively
5209
+ */
5210
+ IsFieldReadableByActiveUser(fieldName) {
5211
+ if (!fieldName) {
5212
+ return true;
5213
+ }
5214
+ return !this.deniedReadFieldsForActiveUser()?.has(fieldName.trim().toLowerCase());
5215
+ }
4958
5216
  /**
4959
5217
  * 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.
5218
+ * it is off on the entity and every IS-A ancestor, no user resolves, or the user is denied nothing.
4961
5219
  *
4962
5220
  * Returning null rather than an empty Set lets callers skip their loop entirely, and keeps
4963
5221
  * the three cheap short-circuits in one place instead of repeated at each gate.
4964
5222
  */
4965
5223
  deniedFieldsForActiveUser(select) {
4966
- if (!this.EntityInfo?.EnableFieldLevelSecurity) {
5224
+ if (!this.EntityInfo?.FieldLevelSecurityApplies) {
4967
5225
  return null; // one boolean for the overwhelming majority of entities
4968
5226
  }
4969
5227
  const u = this.resolveActiveUserOrNull();
@@ -4973,6 +5231,34 @@ export class BaseEntity {
4973
5231
  const denied = select(u);
4974
5232
  return denied.size > 0 ? denied : null;
4975
5233
  }
5234
+ /**
5235
+ * The fields this object's user may not READ, lowercased by Name and CodeName, or null when
5236
+ * field security does not apply. Every readability question this class asks, at hydration,
5237
+ * at the typed accessors and at the save gates, goes through here.
5238
+ */
5239
+ deniedReadFieldsForActiveUser() {
5240
+ return this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedReadFields(u));
5241
+ }
5242
+ /**
5243
+ * A hydration source reduced to what this object's user may hold: the same object when the
5244
+ * user is denied nothing (or field security does not apply), otherwise a shallow copy without
5245
+ * the denied keys, matched case-insensitively under Name and CodeName.
5246
+ *
5247
+ * Every point that fills this object from a row passes the row through here first, which is
5248
+ * what makes the object's width a property of its user rather than of the path that built it.
5249
+ * A key absent from a hydration source already means "not loaded" everywhere (`GetAll`, field
5250
+ * building, the save), so omitting a denied key is all it takes for the value never to enter
5251
+ * the object, and for every accessor and serializer to be correct without a check of its own.
5252
+ *
5253
+ * The caller's row is never modified, because it can be a frozen row shared through a cache,
5254
+ * and an unrestricted user's row is returned by reference, so the raw load path stays zero-copy.
5255
+ */
5256
+ narrowHydrationSource(source) {
5257
+ if (!source || typeof source !== 'object' || Array.isArray(source) || source instanceof Date) {
5258
+ return source;
5259
+ }
5260
+ return OmitDeniedFieldKeys(source, this.deniedReadFieldsForActiveUser());
5261
+ }
4976
5262
  /**
4977
5263
  * {@link ActiveUser}, but null instead of throwing when no provider is configured to resolve
4978
5264
  * one from.
@@ -5040,7 +5326,7 @@ export class BaseEntity {
5040
5326
  if (!this.IsSaved) {
5041
5327
  this.init(); // wipe out current data if we're loading on top of existing record
5042
5328
  }
5043
- const data = await this.readRowForLoad(CompositeKey, EntityRelationshipsToLoad);
5329
+ let data = await this.readRowForLoad(CompositeKey, EntityRelationshipsToLoad);
5044
5330
  this.discardJSONFieldObjects(); // a reload replaces the record's data
5045
5331
  if (!data) {
5046
5332
  // A subtype-hint probe asks whether this row exists, so "no" is an answer, not an error
@@ -5049,6 +5335,9 @@ export class BaseEntity {
5049
5335
  }
5050
5336
  return false; // no data loaded, return false
5051
5337
  }
5338
+ // Field security: denied values never enter this object, even from a provider that
5339
+ // did not narrow its SELECT (see narrowHydrationSource).
5340
+ data = this.narrowHydrationSource(data);
5052
5341
  // IS-A: hydrate parent chain from loaded data before populating self.
5053
5342
  // Hydrate resets each parent via init() (giving fresh _NeverSet=true on PK fields)
5054
5343
  // then populates from data. This must happen before self's SetMany so parents
@@ -5199,6 +5488,16 @@ export class BaseEntity {
5199
5488
  */
5200
5489
  async LoadFromData(data, _replaceOldValues = false) {
5201
5490
  this.discardJSONFieldObjects(); // the record's data is being replaced: parsed JSON objects are stale
5491
+ // An entity object passed as the row is read through its own GetAll(): what ITS user may
5492
+ // read, as a plain row. Kept as the row itself, it would be read through its typed getters,
5493
+ // which answer for the source's user and so return values this object's user is denied
5494
+ // (narrowing below sees only an object's own keys, and an entity's values are not those).
5495
+ if (data instanceof BaseEntity) {
5496
+ data = data.GetAll();
5497
+ }
5498
+ // Field security: a restricted user's object holds a copy of the row without the keys they
5499
+ // are denied; anyone else's keeps the caller's row by reference (see narrowHydrationSource).
5500
+ data = this.narrowHydrationSource(data);
5202
5501
  // IS-A: hydrate parent chain from data before populating self.
5203
5502
  // Hydrate resets each parent via init() (giving fresh _NeverSet=true on PK fields)
5204
5503
  // then populates from data, ensuring correct PK and saved state on parents.
@@ -6005,7 +6304,7 @@ export class BaseEntity {
6005
6304
  //
6006
6305
  // Returning null is the same answer callers already handle for "this entity has no name
6007
6306
  // field", and every one of them degrades to the primary key.
6008
- if (!this.EntityInfo.IsFieldReadableByUser(f.Name, this.ActiveUser)) {
6307
+ if (!this.IsFieldReadableByActiveUser(f.Name)) {
6009
6308
  return null;
6010
6309
  }
6011
6310
  return this.Get(f.Name);