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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/dist/generic/BaseEntitySaveQueue.d.ts +22 -0
  2. package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
  3. package/dist/generic/BaseEntitySaveQueue.js +43 -4
  4. package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
  5. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +18 -0
  6. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
  7. package/dist/generic/alternateBaseViews.d.ts +119 -0
  8. package/dist/generic/alternateBaseViews.d.ts.map +1 -0
  9. package/dist/generic/alternateBaseViews.js +223 -0
  10. package/dist/generic/alternateBaseViews.js.map +1 -0
  11. package/dist/generic/baseEngine.d.ts +82 -14
  12. package/dist/generic/baseEngine.d.ts.map +1 -1
  13. package/dist/generic/baseEngine.js +311 -46
  14. package/dist/generic/baseEngine.js.map +1 -1
  15. package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
  16. package/dist/generic/baseEngineRegistry.js +2 -1
  17. package/dist/generic/baseEngineRegistry.js.map +1 -1
  18. package/dist/generic/baseEntity.d.ts +194 -14
  19. package/dist/generic/baseEntity.d.ts.map +1 -1
  20. package/dist/generic/baseEntity.js +638 -218
  21. package/dist/generic/baseEntity.js.map +1 -1
  22. package/dist/generic/baseInfo.d.ts +31 -3
  23. package/dist/generic/baseInfo.d.ts.map +1 -1
  24. package/dist/generic/baseInfo.js +138 -40
  25. package/dist/generic/baseInfo.js.map +1 -1
  26. package/dist/generic/contextTokens.d.ts +136 -0
  27. package/dist/generic/contextTokens.d.ts.map +1 -0
  28. package/dist/generic/contextTokens.js +319 -0
  29. package/dist/generic/contextTokens.js.map +1 -0
  30. package/dist/generic/databaseProviderBase.d.ts +103 -13
  31. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  32. package/dist/generic/databaseProviderBase.js +321 -77
  33. package/dist/generic/databaseProviderBase.js.map +1 -1
  34. package/dist/generic/embeddedRecord.d.ts +6 -0
  35. package/dist/generic/embeddedRecord.d.ts.map +1 -1
  36. package/dist/generic/embeddedRecord.js +15 -1
  37. package/dist/generic/embeddedRecord.js.map +1 -1
  38. package/dist/generic/entityCompanion.d.ts +7 -0
  39. package/dist/generic/entityCompanion.d.ts.map +1 -1
  40. package/dist/generic/entityCompanion.js +8 -0
  41. package/dist/generic/entityCompanion.js.map +1 -1
  42. package/dist/generic/entityConfiguration.d.ts +8 -2
  43. package/dist/generic/entityConfiguration.d.ts.map +1 -1
  44. package/dist/generic/entityConfiguration.js +9 -0
  45. package/dist/generic/entityConfiguration.js.map +1 -1
  46. package/dist/generic/entityInfo.d.ts +306 -12
  47. package/dist/generic/entityInfo.d.ts.map +1 -1
  48. package/dist/generic/entityInfo.js +894 -92
  49. package/dist/generic/entityInfo.js.map +1 -1
  50. package/dist/generic/entityValueFieldSecurity.d.ts +139 -0
  51. package/dist/generic/entityValueFieldSecurity.d.ts.map +1 -0
  52. package/dist/generic/entityValueFieldSecurity.js +250 -0
  53. package/dist/generic/entityValueFieldSecurity.js.map +1 -0
  54. package/dist/generic/fieldSecuritySQLGate.d.ts +350 -0
  55. package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -0
  56. package/dist/generic/fieldSecuritySQLGate.js +898 -0
  57. package/dist/generic/fieldSecuritySQLGate.js.map +1 -0
  58. package/dist/generic/indexRestrictedFields.d.ts +47 -0
  59. package/dist/generic/indexRestrictedFields.d.ts.map +1 -0
  60. package/dist/generic/indexRestrictedFields.js +129 -0
  61. package/dist/generic/indexRestrictedFields.js.map +1 -0
  62. package/dist/generic/infoProviderResolution.d.ts +16 -0
  63. package/dist/generic/infoProviderResolution.d.ts.map +1 -0
  64. package/dist/generic/infoProviderResolution.js +17 -0
  65. package/dist/generic/infoProviderResolution.js.map +1 -0
  66. package/dist/generic/interfaces.d.ts +39 -2
  67. package/dist/generic/interfaces.d.ts.map +1 -1
  68. package/dist/generic/interfaces.js.map +1 -1
  69. package/dist/generic/localCacheManager.d.ts +318 -51
  70. package/dist/generic/localCacheManager.d.ts.map +1 -1
  71. package/dist/generic/localCacheManager.js +675 -109
  72. package/dist/generic/localCacheManager.js.map +1 -1
  73. package/dist/generic/logging.d.ts.map +1 -1
  74. package/dist/generic/logging.js +7 -1
  75. package/dist/generic/logging.js.map +1 -1
  76. package/dist/generic/metadata.d.ts +3 -2
  77. package/dist/generic/metadata.d.ts.map +1 -1
  78. package/dist/generic/metadata.js +5 -2
  79. package/dist/generic/metadata.js.map +1 -1
  80. package/dist/generic/mjapiUrl.d.ts +21 -0
  81. package/dist/generic/mjapiUrl.d.ts.map +1 -0
  82. package/dist/generic/mjapiUrl.js +25 -0
  83. package/dist/generic/mjapiUrl.js.map +1 -0
  84. package/dist/generic/platformSQL.d.ts +54 -0
  85. package/dist/generic/platformSQL.d.ts.map +1 -1
  86. package/dist/generic/platformSQL.js +55 -0
  87. package/dist/generic/platformSQL.js.map +1 -1
  88. package/dist/generic/providerBase.d.ts +370 -57
  89. package/dist/generic/providerBase.d.ts.map +1 -1
  90. package/dist/generic/providerBase.js +1085 -284
  91. package/dist/generic/providerBase.js.map +1 -1
  92. package/dist/generic/queryInfo.d.ts +23 -9
  93. package/dist/generic/queryInfo.d.ts.map +1 -1
  94. package/dist/generic/queryInfo.js +73 -33
  95. package/dist/generic/queryInfo.js.map +1 -1
  96. package/dist/generic/queryInfoInterfaces.d.ts +8 -0
  97. package/dist/generic/queryInfoInterfaces.d.ts.map +1 -1
  98. package/dist/generic/queryTemplateContext.d.ts +49 -0
  99. package/dist/generic/queryTemplateContext.d.ts.map +1 -0
  100. package/dist/generic/queryTemplateContext.js +113 -0
  101. package/dist/generic/queryTemplateContext.js.map +1 -0
  102. package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -1
  103. package/dist/generic/recordChangeFieldSecurity.js +13 -2
  104. package/dist/generic/recordChangeFieldSecurity.js.map +1 -1
  105. package/dist/generic/relatedRecordCollection.d.ts +13 -0
  106. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  107. package/dist/generic/relatedRecordCollection.js +44 -5
  108. package/dist/generic/relatedRecordCollection.js.map +1 -1
  109. package/dist/generic/runQuery.d.ts +42 -0
  110. package/dist/generic/runQuery.d.ts.map +1 -1
  111. package/dist/generic/runQuery.js +14 -0
  112. package/dist/generic/runQuery.js.map +1 -1
  113. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  114. package/dist/generic/runQuerySQLFilterImplementations.js +38 -2
  115. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  116. package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
  117. package/dist/generic/saveEntityGraphOperation.js +3 -0
  118. package/dist/generic/saveEntityGraphOperation.js.map +1 -1
  119. package/dist/generic/scoring/ReciprocalRankFusion.d.ts.map +1 -1
  120. package/dist/generic/scoring/ReciprocalRankFusion.js +10 -3
  121. package/dist/generic/scoring/ReciprocalRankFusion.js.map +1 -1
  122. package/dist/generic/securityInfo.d.ts +17 -6
  123. package/dist/generic/securityInfo.d.ts.map +1 -1
  124. package/dist/generic/securityInfo.js +25 -10
  125. package/dist/generic/securityInfo.js.map +1 -1
  126. package/dist/generic/telemetryManager.d.ts +9 -0
  127. package/dist/generic/telemetryManager.d.ts.map +1 -1
  128. package/dist/generic/telemetryManager.js +5 -1
  129. package/dist/generic/telemetryManager.js.map +1 -1
  130. package/dist/generic/util.d.ts.map +1 -1
  131. package/dist/generic/util.js +3 -0
  132. package/dist/generic/util.js.map +1 -1
  133. package/dist/generic/wellKnownUserSource.d.ts +3 -2
  134. package/dist/generic/wellKnownUserSource.d.ts.map +1 -1
  135. package/dist/generic/wellKnownUserSource.js +18 -9
  136. package/dist/generic/wellKnownUserSource.js.map +1 -1
  137. package/dist/index.d.ts +8 -0
  138. package/dist/index.d.ts.map +1 -1
  139. package/dist/index.js +8 -0
  140. package/dist/index.js.map +1 -1
  141. package/dist/views/runView.d.ts +89 -0
  142. package/dist/views/runView.d.ts.map +1 -1
  143. package/dist/views/runView.js +36 -0
  144. package/dist/views/runView.js.map +1 -1
  145. package/dist/views/viewProperties.d.ts +55 -0
  146. package/dist/views/viewProperties.d.ts.map +1 -0
  147. package/dist/views/viewProperties.js +258 -0
  148. package/dist/views/viewProperties.js.map +1 -0
  149. package/package.json +3 -3
  150. package/readme.md +29 -4
@@ -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;
@@ -92,6 +94,18 @@ let EntityField = class EntityField {
92
94
  */
93
95
  MarkNotLoaded() {
94
96
  this._notLoaded = true;
97
+ this._writtenAfterNotLoaded = false;
98
+ }
99
+ /**
100
+ * Framework-internal, for the hydration paths: the source omitted this field, so it is
101
+ * not-loaded AND holds nothing. Unlike {@link MarkNotLoaded}, which only flags the field and keeps
102
+ * its in-memory value, this drops a default or a previous record's value that would otherwise
103
+ * read back through Get() as if it had been loaded.
104
+ */
105
+ ClearAsNotLoaded() {
106
+ this._value = null;
107
+ this._oldValue = null;
108
+ this.MarkNotLoaded();
95
109
  }
96
110
  /**
97
111
  * True when field-level security bars this user from supplying the field's value on INSERT,
@@ -171,7 +185,18 @@ let EntityField = class EntityField {
171
185
  return this._entityFieldInfo.HasDefaultValue;
172
186
  }
173
187
  /**
174
- * 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.
175
200
  */
176
201
  set Value(value) {
177
202
  // No active-status assertion here — see the Value getter note. Deprecation/disabled checks
@@ -198,6 +223,9 @@ let EntityField = class EntityField {
198
223
  // this field.
199
224
  const wasNotLoaded = this._notLoaded;
200
225
  this._notLoaded = false;
226
+ if (wasNotLoaded) {
227
+ this._writtenAfterNotLoaded = true;
228
+ }
201
229
  // in the below, we set the OldValue, but only if (a) we have never set the value before, or (b) the value or the old value is not null - which means that we are in a record setup scenario
202
230
  if (this._neverSet && !wasNotLoaded &&
203
231
  (value !== null || this._oldValue !== null)) {
@@ -215,6 +243,12 @@ let EntityField = class EntityField {
215
243
  ResetNeverSetFlag() {
216
244
  this._neverSet = true;
217
245
  }
246
+ /**
247
+ * True when the field has had no explicit write since construction or since {@link ResetNeverSetFlag}.
248
+ */
249
+ get NeverSet() {
250
+ return this._neverSet;
251
+ }
218
252
  /**
219
253
  * Returns true if the field is dirty, false otherwise. A field is considered dirty if the value is different from the old value. If the field is read only, it is never dirty.
220
254
  * A {@link NotLoaded} field is never dirty either — its in-memory state is a construction
@@ -223,9 +257,11 @@ let EntityField = class EntityField {
223
257
  get Dirty() {
224
258
  if (this.ReadOnly || this._notLoaded)
225
259
  return false;
260
+ else if (this._writtenAfterNotLoaded)
261
+ return true; // a write onto a value the record never loaded must reach the save, even null
226
262
  else {
227
263
  const oldNull = this._oldValue === null || this.OldValue === undefined || Number.isNaN(this.OldValue); // check for NaN because sometimes we have old values that are NaN and we need to account for that
228
- const curNull = this.Value === null || this.Value === undefined || Number.isNaN(this.OldValue);
264
+ const curNull = this.Value === null || this.Value === undefined || Number.isNaN(this.Value);
229
265
  if (oldNull && curNull)
230
266
  return false; // BOTH are null or undefined, not dirty
231
267
  else {
@@ -239,16 +275,16 @@ let EntityField = class EntityField {
239
275
  // and sometimes the new values are date objects, convert to UTC timestamp for comparison
240
276
  newCompare = this.Value.getTime();
241
277
  }
278
+ const tsType = this._entityFieldInfo.TSType;
242
279
  // Special handling for bit/Boolean types - treat truthy values as equivalent
243
- if (this._entityFieldInfo.TSType === EntityFieldTSType.Boolean ||
244
- this._entityFieldInfo.Type.toLowerCase() === 'bit') {
280
+ if (tsType === EntityFieldTSType.Boolean) {
245
281
  // Convert both values to boolean for comparison
246
282
  const oldBool = this.convertToBoolean(oldCompare);
247
283
  const newBool = this.convertToBoolean(newCompare);
248
284
  return oldBool !== newBool;
249
285
  }
250
286
  // Special handling for numeric types - treat numeric strings that convert to same value as equivalent
251
- if (this._entityFieldInfo.TSType === EntityFieldTSType.Number || this.isNumericType(this._entityFieldInfo.Type)) {
287
+ if (tsType === EntityFieldTSType.Number) {
252
288
  const oldNum = this.convertToNumber(oldCompare);
253
289
  const newNum = this.convertToNumber(newCompare);
254
290
  // Handle NaN cases - if both are NaN, they're equivalent
@@ -262,7 +298,7 @@ let EntityField = class EntityField {
262
298
  return oldNum !== newNum;
263
299
  }
264
300
  // for string types where the comparisons are not both strings
265
- if (this._entityFieldInfo.TSType === EntityFieldTSType.String) {
301
+ if (tsType === EntityFieldTSType.String) {
266
302
  if (typeof oldCompare === 'object') {
267
303
  // need to convert the object to a string for comparison
268
304
  oldCompare = JSON.stringify(oldCompare);
@@ -370,7 +406,7 @@ let EntityField = class EntityField {
370
406
  }
371
407
  else {
372
408
  // we do have a default value, but our current value is null. If we are in an EXISTING record, this is an error, check the OldValue to determine this
373
- if (this._oldValue !== null && this._oldValue !== undefined) {
409
+ if ((this._oldValue !== null && this._oldValue !== undefined) || this._writtenAfterNotLoaded) {
374
410
  result.Success = false;
375
411
  result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} cannot be null`, null));
376
412
  }
@@ -465,9 +501,16 @@ let EntityField = class EntityField {
465
501
  result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} cannot be longer than ${ef.MaxByteLength} bytes. Current value is ${byteLength} bytes`, this.Value));
466
502
  }
467
503
  }
468
- constructor(fieldInfo, Value) {
504
+ constructor(fieldInfo, Value, skipDefaultValue = false) {
469
505
  this._neverSet = true;
470
506
  this._notLoaded = false;
507
+ /**
508
+ * True once the field is explicitly set after being not-loaded: a write onto a value the record
509
+ * never loaded. The field is then dirty whatever it was set to — even null, which equals its
510
+ * cleared old value — so the write reaches the save, and Validate treats it as an existing
511
+ * record's value. Cleared when the old value is reset (load, save) or the field is not-loaded again.
512
+ */
513
+ this._writtenAfterNotLoaded = false;
471
514
  this._createSuppressed = false;
472
515
  // NOTE: constructing an EntityField for a deprecated/disabled field is always allowed — the
473
516
  // column is physically part of the entity and the instance must exist. Active-status is
@@ -477,102 +520,51 @@ let EntityField = class EntityField {
477
520
  if (Value) {
478
521
  this.Value = Value;
479
522
  }
480
- else if (fieldInfo.DefaultValue) {
481
- if (fieldInfo.TSType === EntityFieldTSType.Boolean) {
482
- // special handling for booleans as we don't want a string passed into a boolean field, we want a true boolean
483
- if (typeof fieldInfo.DefaultValue === "string" && fieldInfo.DefaultValue.trim() === "1" || fieldInfo.DefaultValue.trim().toLowerCase() === "true")
484
- this.Value = true;
485
- else
486
- this.Value = false;
487
- }
488
- else if (fieldInfo.TSType === EntityFieldTSType.Number) {
489
- // special handling for numbers as we don't want a string passed into a value for a numeric field
490
- if (!isNaN(Number(fieldInfo.DefaultValue))) {
491
- this.Value = Number(fieldInfo.DefaultValue);
492
- }
493
- else if (fieldInfo.DefaultValue.trim().toLowerCase() === "null") {
494
- this.Value = null;
495
- }
496
- }
497
- else if (fieldInfo.IsUniqueIdentifier) {
498
- // special handling for GUIDs (SQL Server `uniqueidentifier` / PostgreSQL `uuid`),
499
- // we don't want to populate anything here because the server always sets the value, leave blank
500
- this.Value = null;
501
- }
502
- else if (fieldInfo.TSType === EntityFieldTSType.Date) {
503
- if (fieldInfo.DefaultValue.trim().length > 0) {
504
- // special handling for dates as we don't want to use getdate() type defaults as is, we want to convert them to a JS Date object
505
- try {
506
- // attempt to convert the default value to a date
507
- const temp = new Date(fieldInfo.DefaultValue);
508
- // if we get here, that means it worked, but we could have an invalid date, so check for that
509
- if (isNaN(temp.getTime())) {
510
- // this is an invalid date, so throw an error
511
- throw new Error(); // blank error because we just end up going to the catch block anyway and in there we have logic to handle this properly
512
- }
513
- else
514
- this.Value = temp;
515
- }
516
- catch (e) {
517
- // if we get here, that means the default value is not a valid date, so we need to check to see if it's a SQL current date function
518
- if (EntityFieldInfo.IsDefaultValueSQLCurrentDateFunction(fieldInfo.DefaultValue)) {
519
- // we have a SQL current date function default, leave the field alone if its a special date field as the server (i.e. database) will handle
520
- //setting the value, otherwise set the value to the current date
521
- if (fieldInfo.IsSpecialDateField) {
522
- this.Value = null;
523
- }
524
- else {
525
- this.Value = new Date();
526
- }
527
- }
528
- else {
529
- // we have a default value that is not a valid date and not a getdate() type default, so we need to throw an error
530
- throw new Error(`Invalid default value for ${fieldInfo.Entity}.${fieldInfo.Name} of ${fieldInfo.DefaultValue}. Default values for date fields must be a valid date or a getdate() type default`);
531
- }
532
- }
533
- }
523
+ else if (!skipDefaultValue && fieldInfo.DefaultValue) {
524
+ const parsed = typeof fieldInfo.GetParsedDefaultValue === 'function'
525
+ ? fieldInfo.GetParsedDefaultValue()
526
+ : EntityFieldInfo.ParseDefaultValue(fieldInfo);
527
+ if (parsed.isCurrentDateFunction) {
528
+ this.Value = new Date();
534
529
  }
535
- else {
536
- // For strings: strip PostgreSQL's typed-literal wrapper.
537
- //
538
- // PG's pg_get_expr() (used by vwSQLColumnsAndEntityFields →
539
- // EntityField.DefaultValue) renders a string default like:
540
- // 'Single'::character varying
541
- // 'pending'::text
542
- // SQL Server stores the same default as just `'Single'` or
543
- // `'pending'`. If we set the field value to the raw PG form,
544
- // the value is `'Single'::character varying` (27 chars), and
545
- // a MaxLength=20 constraint immediately fails validation —
546
- // even though the actual content is `Single` (6 chars).
547
- //
548
- // Unwrap a leading single-quoted string followed by a `::type`
549
- // suffix. Function-call defaults (`nextval('...')`,
550
- // `now() AT TIME ZONE 'UTC'`) deliberately don't match this
551
- // shape and are left untouched — the database evaluates them
552
- // server-side at INSERT time.
553
- const dv = fieldInfo.DefaultValue.trim();
554
- const pgTypedLiteral = /^'((?:[^']|'')*)'::[A-Za-z][\w "()\[\],]*$/;
555
- const m = dv.match(pgTypedLiteral);
556
- if (m) {
557
- // Replace the SQL-escaped doubled quotes back to a single quote.
558
- this.Value = m[1].replace(/''/g, "'");
559
- }
560
- else {
561
- this.Value = fieldInfo.DefaultValue;
562
- }
530
+ else if (parsed.hasValue) {
531
+ this.Value = parsed.value instanceof Date ? new Date(parsed.value.getTime()) : parsed.value;
563
532
  }
564
533
  this._neverSet = true; // set this back to true because we are setting the default value and we want to be able to set this ONCE from BaseEntity when we load
565
534
  }
566
535
  else {
567
- this.Value = null; // we need to set the value to null instead of being undefined as the value is defined, it is NULL
536
+ this._value = null; // we need to set the value to null instead of being undefined as the value is defined, it is NULL
537
+ this._oldValue = null;
568
538
  this._neverSet = true; // set this back to true because we are setting the value to null;
569
539
  }
570
540
  }
571
541
  /**
572
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.
573
548
  */
574
549
  ResetOldValue() {
575
550
  this._oldValue = this.Value;
551
+ this._writtenAfterNotLoaded = false;
552
+ }
553
+ /**
554
+ * Framework-internal, for {@link BaseEntity.Revert}: puts the field back to its last loaded or
555
+ * saved state. A not-loaded field has nothing to revert and stays not-loaded; a field written
556
+ * after being not-loaded goes back to not-loaded, so the revert neither turns an unloaded field
557
+ * into "loaded" data a later update would write, nor leaves the write's dirty flag behind.
558
+ */
559
+ RevertToOldValue() {
560
+ if (this._notLoaded) {
561
+ return;
562
+ }
563
+ if (this._writtenAfterNotLoaded) {
564
+ this.ClearAsNotLoaded();
565
+ return;
566
+ }
567
+ this.Value = this.OldValue;
576
568
  }
577
569
  /**
578
570
  * Restores the dirty-tracking baseline to a previously captured value.
@@ -585,7 +577,13 @@ let EntityField = class EntityField {
585
577
  * back can put them back with {@link RestoreState}.
586
578
  */
587
579
  GetState() {
588
- return { Value: this._value, OldValue: this._oldValue, NotLoaded: this._notLoaded, NeverSet: this._neverSet };
580
+ return {
581
+ Value: this._value,
582
+ OldValue: this._oldValue,
583
+ NotLoaded: this._notLoaded,
584
+ NeverSet: this._neverSet,
585
+ WrittenAfterNotLoaded: this._writtenAfterNotLoaded,
586
+ };
589
587
  }
590
588
  /**
591
589
  * Framework-internal: puts back a state {@link GetState} captured. It writes the state as it was,
@@ -596,6 +594,7 @@ let EntityField = class EntityField {
596
594
  this._oldValue = state.OldValue;
597
595
  this._notLoaded = state.NotLoaded;
598
596
  this._neverSet = state.NeverSet;
597
+ this._writtenAfterNotLoaded = state.WrittenAfterNotLoaded ?? false;
599
598
  }
600
599
  /**
601
600
  * @deprecated No-op as of the active-status relocation. Active-status (deprecated/disabled)
@@ -620,7 +619,7 @@ let EntityField = class EntityField {
620
619
  };
621
620
  EntityField = EntityField_1 = __decorate([
622
621
  OptionalKeyedSpecialization(),
623
- __metadata("design:paramtypes", [EntityFieldInfo, Object])
622
+ __metadata("design:paramtypes", [EntityFieldInfo, Object, Boolean])
624
623
  ], EntityField);
625
624
  export { EntityField };
626
625
  /**
@@ -905,6 +904,12 @@ export class BaseEntity {
905
904
  * commits atomically.
906
905
  */
907
906
  this._transactionGroup = null;
907
+ /**
908
+ * RxJS `Subject` that fires `BaseEntityEvent`s (save, delete, field-change,
909
+ * remote-invalidate). Exposed publicly via `Event$` so engines and UI components can react
910
+ * to entity lifecycle events.
911
+ */
912
+ this._eventSubject = null;
908
913
  /**
909
914
  * Bounded ring of `BaseEntityResult` objects from each Save/Delete attempt. The most
910
915
  * recent entry is exposed via `LatestResult` for error inspection after a failure.
@@ -1112,7 +1117,7 @@ export class BaseEntity {
1112
1117
  * however it is possible for the string in the map to be any unique key relative to the object so you could have vectors
1113
1118
  * that embed multiple fields if desired.
1114
1119
  */
1115
- this._vectors = new Map();
1120
+ this._vectors = null;
1116
1121
  /**
1117
1122
  * When true, GenerateEmbedding() returns immediately without computing vectors.
1118
1123
  * Set this before calling Save() in batch/sync contexts where embedding computation
@@ -1120,7 +1125,6 @@ export class BaseEntity {
1120
1125
  * per-process is expensive and vectors can be computed later by the API server).
1121
1126
  */
1122
1127
  this._skipEmbeddings = false;
1123
- this._eventSubject = new Subject();
1124
1128
  this._entityInfo = Entity;
1125
1129
  EntityInfo.AssertEntityActiveStatus(Entity, 'BaseEntity::constructor');
1126
1130
  this._provider = Provider;
@@ -1662,7 +1666,39 @@ export class BaseEntity {
1662
1666
  static errorText(e) {
1663
1667
  return e instanceof Error ? e.message : String(e);
1664
1668
  }
1665
- static { this._subtypeLookupCache = new Map(); }
1669
+ /**
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.
1676
+ */
1677
+ static { this._subtypeLookupCache = new WeakMap(); }
1678
+ /** Partition key for lookups made by an entity that has no provider at all. */
1679
+ static { this._noProviderSubtypeKey = {}; }
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
+ }
1689
+ const partitionKey = provider ?? BaseEntity._noProviderSubtypeKey;
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);
1696
+ if (!cache) {
1697
+ cache = new Map();
1698
+ byUser.set(userKey, cache);
1699
+ }
1700
+ return cache;
1701
+ }
1666
1702
  /** Per entity (lowercased name): the resolver asked for load hints, decided once per registration. */
1667
1703
  static { this._subtypeLoadHintResolvers = new Map(); }
1668
1704
  /** Indexes over engine-cached arrays, keyed by the live array itself; see {@link findEngineCachedRow}. */
@@ -1674,7 +1710,7 @@ export class BaseEntity {
1674
1710
  * reported again.
1675
1711
  */
1676
1712
  static ClearSubtypeLookupCache() {
1677
- BaseEntity._subtypeLookupCache.clear();
1713
+ BaseEntity._subtypeLookupCache = new WeakMap();
1678
1714
  BaseEntity._subtypeLoadHintResolvers.clear();
1679
1715
  BaseEntity._engineCachedRowIndexes = new WeakMap();
1680
1716
  BaseEntity._loggedSubtypeLoadHintMessages.clear();
@@ -1863,12 +1899,16 @@ export class BaseEntity {
1863
1899
  return null;
1864
1900
  let currentEntity = this;
1865
1901
  let currentEntityInfo = this.EntityInfo;
1902
+ const actingUser = this.resolveActiveUserOrNull();
1866
1903
  for (let i = 0; i < segments.length - 1; i++) {
1867
1904
  const fieldName = segments[i];
1868
1905
  const fieldInfo = currentEntityInfo.Fields.find(f => f.Name.trim().toLowerCase() === fieldName.toLowerCase());
1869
1906
  if (!fieldInfo) {
1870
1907
  throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': field '${fieldName}' was not found on entity '${currentEntityInfo.Name}'.`);
1871
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
+ }
1872
1912
  const fkValue = currentEntity.Get(fieldInfo.Name);
1873
1913
  if (fkValue == null || fkValue === '') {
1874
1914
  return null;
@@ -1889,33 +1929,54 @@ export class BaseEntity {
1889
1929
  if (!terminalField) {
1890
1930
  throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': terminal field '${terminalSegment}' was not found on entity '${currentEntityInfo.Name}'.`);
1891
1931
  }
1932
+ if (!this.subtypeSelectorMayRead(currentEntity, terminalField.Name, actingUser)) {
1933
+ return null;
1934
+ }
1892
1935
  const terminalValue = currentEntity.Get(terminalField.Name);
1893
1936
  if (terminalValue == null || typeof terminalValue !== 'string' || terminalValue.trim() === '') {
1894
1937
  return null;
1895
1938
  }
1896
1939
  return terminalValue.trim();
1897
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
+ }
1898
1953
  async getSubtypePathTargetEntity(entityName, pkValue) {
1899
1954
  const cacheKey = `${entityName.trim().toLowerCase()}|${String(pkValue).trim().toLowerCase()}`;
1900
- if (BaseEntity._subtypeLookupCache.has(cacheKey)) {
1901
- return BaseEntity._subtypeLookupCache.get(cacheKey) ?? null;
1955
+ const provider = this.ProviderToUse;
1956
+ const lookupCache = BaseEntity.subtypeLookupCacheFor(provider, this.resolveActiveUserOrNull());
1957
+ if (lookupCache?.has(cacheKey)) {
1958
+ return lookupCache.get(cacheKey) ?? null;
1902
1959
  }
1903
- // 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) {
1904
1970
  const cached = BaseEntity.findSubtypePathTargetInEngineCache(entityName, pkValue);
1905
1971
  if (cached) {
1906
- BaseEntity._subtypeLookupCache.set(cacheKey, cached);
1907
1972
  return cached;
1908
1973
  }
1909
- // 2. Fall back to loading via provider
1910
- const provider = this.ProviderToUse;
1911
1974
  if (!provider?.GetEntityObject) {
1912
- BaseEntity._subtypeLookupCache.set(cacheKey, null);
1913
1975
  return null;
1914
1976
  }
1915
1977
  try {
1916
1978
  const targetObj = await provider.GetEntityObject(entityName, this._contextCurrentUser);
1917
1979
  if (!targetObj) {
1918
- BaseEntity._subtypeLookupCache.set(cacheKey, null);
1919
1980
  return null;
1920
1981
  }
1921
1982
  let key;
@@ -1927,17 +1988,11 @@ export class BaseEntity {
1927
1988
  const pkName = targetObj.FirstPrimaryKey.Name; // first-pk-ok: FK target — pkValue is one SubtypeSelector FK column's value
1928
1989
  key.KeyValuePairs.push(new KeyValuePair(pkName, pkValue));
1929
1990
  }
1930
- const loaded = await targetObj.InnerLoad(key);
1931
- if (loaded) {
1932
- BaseEntity._subtypeLookupCache.set(cacheKey, targetObj);
1933
- return targetObj;
1934
- }
1991
+ return (await targetObj.InnerLoad(key)) ? targetObj : null;
1935
1992
  }
1936
1993
  catch {
1937
- // On load failure, return null
1994
+ return null; // on load failure, no target
1938
1995
  }
1939
- BaseEntity._subtypeLookupCache.set(cacheKey, null);
1940
- return null;
1941
1996
  }
1942
1997
  /**
1943
1998
  * Finds the record a `SubtypeSelector` hop points at among the entity objects that loaded
@@ -2142,6 +2197,8 @@ export class BaseEntity {
2142
2197
  // _everSaved, and recreates all EntityField instances with _NeverSet = true
2143
2198
  this.init();
2144
2199
  this.discardJSONFieldObjects();
2200
+ // Field security: denied values never enter this object (see narrowHydrationSource).
2201
+ data = this.narrowHydrationSource(data);
2145
2202
  // Recursively hydrate parent entities first (deepest ancestor resets first).
2146
2203
  // After init(), parent fields have fresh _NeverSet=true, so SetMany can set PKs.
2147
2204
  if (this._parentEntity) {
@@ -2406,7 +2463,9 @@ export class BaseEntity {
2406
2463
  }
2407
2464
  const result = await operation.Execute({
2408
2465
  EntityName: this.EntityInfo.Name,
2409
- 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()),
2410
2469
  Companions: await this.SerializeCompanions(),
2411
2470
  IsExistingRecord: this.IsSaved,
2412
2471
  }, { provider: this.ProviderToUse, user: this.ActiveUser });
@@ -2445,9 +2504,14 @@ export class BaseEntity {
2445
2504
  // 'save' event both carry it; hardcoding 'update' here made every remote graph CREATE
2446
2505
  // report (and notify subscribers) as an update.
2447
2506
  const saveSubType = this.IsSaved ? 'update' : 'create';
2507
+ const changedFieldNames = this.dirtyFieldNames();
2448
2508
  // Root: rebase field values and dirty state onto what the database now holds.
2449
2509
  this.init();
2450
- 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);
2451
2515
  this._recordLoaded = true;
2452
2516
  this._everSaved = true;
2453
2517
  // Children: adopt server-assigned primary keys and computed values. 'result' mode adopts the
@@ -2461,7 +2525,28 @@ export class BaseEntity {
2461
2525
  result.StartedAt = new Date();
2462
2526
  result.EndedAt = new Date();
2463
2527
  this.RegisterResultHistoryEntry(result);
2464
- 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();
2465
2550
  }
2466
2551
  /**
2467
2552
  * Gives every eager companion a chance to populate itself.
@@ -3039,6 +3124,9 @@ export class BaseEntity {
3039
3124
  * @returns
3040
3125
  */
3041
3126
  RegisterEventHandler(callback) {
3127
+ if (!this._eventSubject) {
3128
+ this._eventSubject = new Subject();
3129
+ }
3042
3130
  return this._eventSubject.asObservable().subscribe(callback);
3043
3131
  }
3044
3132
  /**
@@ -3060,6 +3148,9 @@ export class BaseEntity {
3060
3148
  this.RaiseEvent('transaction_ready', null);
3061
3149
  }
3062
3150
  static { this._baseEventCode = 'BaseEntityEvent'; }
3151
+ static { this._globalEventTypes = new Set([
3152
+ 'save', 'delete', 'load_complete', 'save_started', 'delete_started', 'load_started'
3153
+ ]); }
3063
3154
  /**
3064
3155
  * When a BaseEntity class raises an event with MJGlobal, the eventCode property is set to this value. This is used to identify events that are raised by BaseEntity objects.
3065
3156
  * Any MJGlobal event that is raised by a BaseEntity class will use a BaseEntityEvent type as the args parameter
@@ -3070,7 +3161,7 @@ export class BaseEntity {
3070
3161
  /**
3071
3162
  * Used for raising events within the BaseEntity and can be used by sub-classes to raise events that are specific to the entity.
3072
3163
  */
3073
- RaiseEvent(type, payload, saveSubType = undefined) {
3164
+ RaiseEvent(type, payload, saveSubType = undefined, changedFieldNames) {
3074
3165
  // Resolve the entity's bound provider once so every consumer (local subscribers
3075
3166
  // AND MJGlobal listeners) receives consistent provider context. This matters in
3076
3167
  // multi-provider scenarios — listeners like LocalCacheManager / BaseEngine need to
@@ -3081,17 +3172,17 @@ export class BaseEntity {
3081
3172
  const provider = this.ProviderToUse;
3082
3173
  // this is the local event handler that is specific to THIS instance of the entity object
3083
3174
  LogDebug(`BaseEntity.RaiseEvent() - ${type === 'save' ? 'save:' + saveSubType : type} event raised for ${this.EntityInfo.Name}, about to call this._eventSubject.next()`);
3084
- 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 });
3085
3176
  // this next call is to MJGlobal to let everyone who cares knows that we had an event on an entity object
3086
3177
  // we broadcast save/delete/load events and their _started counterparts
3087
- const globalEventTypes = ['save', 'delete', 'load_complete', 'save_started', 'delete_started', 'load_started'];
3088
- if (globalEventTypes.includes(type)) {
3178
+ if (BaseEntity._globalEventTypes.has(type)) {
3089
3179
  const event = new BaseEntityEvent();
3090
3180
  event.baseEntity = this;
3091
3181
  event.payload = payload;
3092
3182
  event.type = type;
3093
3183
  event.saveSubType = saveSubType;
3094
3184
  event.provider = provider;
3185
+ event.ChangedFieldNames = changedFieldNames;
3095
3186
  LogDebug(`BaseEntity.RaiseEvent() - ${type === 'save' ? 'save:' + saveSubType : type} event raised for ${this.EntityInfo.Name}, about to call MJGlobal.RaiseEvent()`);
3096
3187
  MJGlobal.Instance.RaiseEvent({
3097
3188
  component: this,
@@ -3232,6 +3323,7 @@ export class BaseEntity {
3232
3323
  get TransactionGroup() {
3233
3324
  return this._transactionGroup;
3234
3325
  }
3326
+ /** Set `null` to detach the entity from a group, e.g. once the group has been submitted. */
3235
3327
  set TransactionGroup(group) {
3236
3328
  this._transactionGroup = group;
3237
3329
  }
@@ -3272,6 +3364,10 @@ export class BaseEntity {
3272
3364
  }
3273
3365
  // Lookup needs an EntityField instance to return — hydrate.
3274
3366
  this.hydrateFieldsIfNeeded();
3367
+ if (this._entityInfo && typeof this._entityInfo.FieldIndexByName === 'function' && this._fields.length === this._entityInfo.Fields.length && this._fieldCache === null) {
3368
+ const idx = this._entityInfo.FieldIndexByName(fieldName);
3369
+ return idx >= 0 ? (this._fields[idx] ?? null) : null;
3370
+ }
3275
3371
  const lcase = fieldName.trim().toLowerCase(); // do this once as we will use it multiple times
3276
3372
  if (this._fieldCache === null) {
3277
3373
  this._fieldCache = new Map();
@@ -3315,6 +3411,9 @@ export class BaseEntity {
3315
3411
  * ```
3316
3412
  */
3317
3413
  FieldIsDirty(fieldName, ...more) {
3414
+ if (!this._fieldsHydrated && this._raw !== null && !this._parentEntity) {
3415
+ return false;
3416
+ }
3318
3417
  const names = more.length === 0 ? [fieldName] : [fieldName, ...more];
3319
3418
  for (const name of names) {
3320
3419
  if (this.GetFieldByName(name)?.Dirty === true) {
@@ -3334,6 +3433,10 @@ export class BaseEntity {
3334
3433
  }
3335
3434
  // Lookup needs an EntityField instance to return — hydrate.
3336
3435
  this.hydrateFieldsIfNeeded();
3436
+ if (this._entityInfo && typeof this._entityInfo.FieldIndexByCodeName === 'function' && this._fields.length === this._entityInfo.Fields.length && this._codeNameCache === null) {
3437
+ const idx = this._entityInfo.FieldIndexByCodeName(codeName);
3438
+ return idx >= 0 ? (this._fields[idx] ?? null) : null;
3439
+ }
3337
3440
  const lcase = codeName.trim().toLowerCase();
3338
3441
  if (this._codeNameCache === null) {
3339
3442
  this._codeNameCache = new Map();
@@ -3472,11 +3575,14 @@ export class BaseEntity {
3472
3575
  * nothing like field security.
3473
3576
  *
3474
3577
  * Framework-internal value machinery (`Dirty`, `Validate`, `GetAll`, hydration, save-SQL
3475
- * build) reads `EntityField.Value` directly and never routes through here — that exemption
3476
- * 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.
3477
3583
  */
3478
3584
  assertFieldReadable(fieldName) {
3479
- const denied = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedReadFields(u));
3585
+ const denied = this.deniedReadFieldsForActiveUser();
3480
3586
  if (denied?.has(fieldName?.trim().toLowerCase())) {
3481
3587
  throw new FieldSecurityError(fieldName, this.EntityInfo.Name);
3482
3588
  }
@@ -3488,41 +3594,47 @@ export class BaseEntity {
3488
3594
  setLocal(FieldName, Value) {
3489
3595
  const field = this.GetFieldByName(FieldName);
3490
3596
  if (field != null) {
3491
- if (field.EntityFieldInfo.TSType === EntityFieldTSType.Date && (typeof Value === 'string' || typeof Value === 'number')) {
3492
- field.Value = new Date(Value);
3597
+ this.setFieldValue(field, Value);
3598
+ }
3599
+ }
3600
+ setFieldValue(field, Value) {
3601
+ const tsType = field.EntityFieldInfo.TSType;
3602
+ if (tsType === EntityFieldTSType.Date && (typeof Value === 'string' || typeof Value === 'number')) {
3603
+ field.Value = new Date(Value);
3604
+ }
3605
+ else if (tsType === EntityFieldTSType.Number && typeof Value === 'string' && Value !== null && Value !== undefined) {
3606
+ const numericValue = Number(Value);
3607
+ if (!isNaN(numericValue)) {
3608
+ field.Value = numericValue;
3493
3609
  }
3494
- else if (field.EntityFieldInfo.TSType === EntityFieldTSType.Number && typeof Value === 'string' && Value !== null && Value !== undefined) {
3495
- const numericValue = Number(Value);
3496
- if (!isNaN(numericValue)) {
3497
- field.Value = numericValue;
3498
- }
3499
- else {
3500
- field.Value = Value;
3501
- }
3610
+ else {
3611
+ field.Value = Value;
3502
3612
  }
3503
- else if (field.EntityFieldInfo.TSType === EntityFieldTSType.Boolean && Value !== null && Value !== undefined) {
3504
- if (typeof Value === 'string') {
3505
- if (Value.trim() === '1' || Value.trim().toLowerCase() === 'true') {
3506
- field.Value = true;
3507
- }
3508
- else if (Value.trim() === '0' || Value.trim().toLowerCase() === 'false') {
3509
- field.Value = false;
3510
- }
3511
- else {
3512
- field.Value = Value;
3513
- }
3613
+ }
3614
+ else if (tsType === EntityFieldTSType.Boolean && Value !== null && Value !== undefined) {
3615
+ if (typeof Value === 'string') {
3616
+ const trimmed = Value.trim();
3617
+ const lower = trimmed.toLowerCase();
3618
+ if (trimmed === '1' || lower === 'true') {
3619
+ field.Value = true;
3514
3620
  }
3515
- else if (typeof Value === 'number') {
3516
- field.Value = Value !== 0;
3621
+ else if (trimmed === '0' || lower === 'false') {
3622
+ field.Value = false;
3517
3623
  }
3518
3624
  else {
3519
3625
  field.Value = Value;
3520
3626
  }
3521
3627
  }
3628
+ else if (typeof Value === 'number') {
3629
+ field.Value = Value !== 0;
3630
+ }
3522
3631
  else {
3523
3632
  field.Value = Value;
3524
3633
  }
3525
3634
  }
3635
+ else {
3636
+ field.Value = Value;
3637
+ }
3526
3638
  }
3527
3639
  /**
3528
3640
  * Returns the value of the field with the given name. If the field is a date, and the value is a string, it will be converted to a date object.
@@ -3533,6 +3645,15 @@ export class BaseEntity {
3533
3645
  * @param FieldName
3534
3646
  * @returns
3535
3647
  */
3648
+ /**
3649
+ * A raw-mode row's value for a field: under its Name, or else its CodeName. Hydration accepts
3650
+ * both keys (as SetMany does), so every raw-mode read must too, or a CodeName-keyed row reads
3651
+ * back empty. `undefined` means the row omitted the field.
3652
+ */
3653
+ rawValueFor(raw, field) {
3654
+ const byName = raw[field.Name];
3655
+ return byName !== undefined || field.CodeName === field.Name ? byName : raw[field.CodeName];
3656
+ }
3536
3657
  /** Records a value derived from `_raw` so later reads skip the conversion. See {@link _rawConverted}. */
3537
3658
  memoizeRawConversion(fieldName, value) {
3538
3659
  (this._rawConverted ??= new Map()).set(fieldName, value);
@@ -3559,28 +3680,31 @@ export class BaseEntity {
3559
3680
  // hydration. Cache data uses exact SQL column names so no case-insensitive scan needed.
3560
3681
  if (!this._fieldsHydrated && this._raw) {
3561
3682
  let value = this._raw[FieldName];
3562
- if (value === undefined)
3563
- return null;
3564
3683
  // Conversions mirror the hydrated path, and memoize into `_rawConverted` rather than
3565
3684
  // back into `_raw` — the row may be shared, frozen cache state. Fields needing no
3566
3685
  // conversion (the vast majority) never touch the memo at all, so the fast path stays
3567
3686
  // a single property read.
3568
3687
  const fi = this._entityInfo?.FieldByName(FieldName);
3688
+ if (value === undefined && fi)
3689
+ value = this.rawValueFor(this._raw, fi);
3690
+ if (value === undefined)
3691
+ return null;
3692
+ const memoKey = fi ? fi.Name : FieldName;
3569
3693
  if (fi?.TSType === EntityFieldTSType.Date && (typeof value === 'string' || typeof value === 'number')) {
3570
- const memo = this._rawConverted?.get(FieldName);
3694
+ const memo = this._rawConverted?.get(memoKey);
3571
3695
  if (memo !== undefined)
3572
3696
  return memo;
3573
3697
  const d = new Date(value);
3574
- this.memoizeRawConversion(FieldName, d);
3698
+ this.memoizeRawConversion(memoKey, d);
3575
3699
  return d;
3576
3700
  }
3577
3701
  // Mirror the EntityField.Value setter: rtrim padding for fixed-width string columns.
3578
3702
  if (typeof value === 'string' && fi?.FixedWidthColumn) {
3579
- const memo = this._rawConverted?.get(FieldName);
3703
+ const memo = this._rawConverted?.get(memoKey);
3580
3704
  if (memo !== undefined)
3581
3705
  return memo;
3582
3706
  value = trimTrailingSpaces(value);
3583
- this.memoizeRawConversion(FieldName, value);
3707
+ this.memoizeRawConversion(memoKey, value);
3584
3708
  }
3585
3709
  return value;
3586
3710
  }
@@ -3624,8 +3748,8 @@ export class BaseEntity {
3624
3748
  if (checkStatus) {
3625
3749
  EntityFieldInfo.AssertEntityFieldActiveStatus(field.EntityFieldInfo, 'BaseEntity.SetMany');
3626
3750
  }
3627
- // Use SetLocal here so we set on OUR fields (mirrors for parent fields)
3628
- this.setLocal(key, object[key]);
3751
+ // Use setFieldValue directly so we don't repeat GetFieldByName(key)
3752
+ this.setFieldValue(field, object[key]);
3629
3753
  if (replaceOldValues) {
3630
3754
  field.ResetOldValue();
3631
3755
  }
@@ -3638,8 +3762,8 @@ export class BaseEntity {
3638
3762
  if (checkStatus) {
3639
3763
  EntityFieldInfo.AssertEntityFieldActiveStatus(field.EntityFieldInfo, 'BaseEntity.SetMany');
3640
3764
  }
3641
- // Use SetLocal here so we set on OUR fields (mirrors for parent fields)
3642
- this.setLocal(field.Name, object[key]);
3765
+ // Use setFieldValue directly so we don't repeat GetFieldByName(field.Name)
3766
+ this.setFieldValue(field, object[key]);
3643
3767
  if (replaceOldValues) {
3644
3768
  field.ResetOldValue();
3645
3769
  }
@@ -3707,21 +3831,51 @@ export class BaseEntity {
3707
3831
  */
3708
3832
  GetAll(oldValues = false, onlyDirtyFields = false) {
3709
3833
  let obj = {};
3710
- for (let field of this.Fields) {
3711
- // NotLoaded fields are OMITTED (D-3 decision): their in-memory state is a
3712
- // construction artifact, and serializing it would launder a default/null into
3713
- // something downstream code treats as data — the exact masquerade the flag exists
3714
- // to prevent. Key-absence also propagates the flag naturally: hydrating another
3715
- // entity from this output re-marks the same fields not-loaded.
3716
- if (field.NotLoaded)
3717
- continue;
3718
- if (!onlyDirtyFields || (onlyDirtyFields && field.Dirty)) {
3719
- // Reads field.Value directly — serialization is framework-internal, so it does not
3720
- // (and must not) assert active status. No suppression toggle needed: the assertion no
3721
- // longer lives on EntityField.Value (it moved to BaseEntity.Get/Set/SetMany).
3722
- obj[field.Name] = oldValues ? field.OldValue : field.Value;
3723
- if (field.EntityFieldInfo.TSType == EntityFieldTSType.Date && obj[field.Name] && !(obj[field.Name] instanceof Date)) {
3724
- obj[field.Name] = new Date(obj[field.Name]); // a timestamp, convert to JS Date Object
3834
+ if (!this._fieldsHydrated && this._raw !== null) {
3835
+ if (!onlyDirtyFields) {
3836
+ for (const ef of this.EntityInfo.Fields) {
3837
+ const name = ef.Name;
3838
+ const rawVal = this.rawValueFor(this._raw, ef);
3839
+ // Key absence in _raw means NotLoaded (D-3 decision): omit from serialized output
3840
+ if (rawVal === undefined)
3841
+ continue;
3842
+ let val;
3843
+ const memo = this._rawConverted?.get(name);
3844
+ if (memo !== undefined) {
3845
+ val = memo;
3846
+ }
3847
+ else if (ef.TSType === EntityFieldTSType.Date && (typeof rawVal === 'string' || typeof rawVal === 'number')) {
3848
+ val = new Date(rawVal);
3849
+ this.memoizeRawConversion(name, val);
3850
+ }
3851
+ else if (typeof rawVal === 'string' && ef.FixedWidthColumn) {
3852
+ val = trimTrailingSpaces(rawVal);
3853
+ this.memoizeRawConversion(name, val);
3854
+ }
3855
+ else {
3856
+ val = rawVal;
3857
+ }
3858
+ obj[name] = val;
3859
+ }
3860
+ }
3861
+ }
3862
+ else {
3863
+ for (let field of this.Fields) {
3864
+ // NotLoaded fields are OMITTED (D-3 decision): their in-memory state is a
3865
+ // construction artifact, and serializing it would launder a default/null into
3866
+ // something downstream code treats as data — the exact masquerade the flag exists
3867
+ // to prevent. Key-absence also propagates the flag naturally: hydrating another
3868
+ // entity from this output re-marks the same fields not-loaded.
3869
+ if (field.NotLoaded)
3870
+ continue;
3871
+ if (!onlyDirtyFields || (onlyDirtyFields && field.Dirty)) {
3872
+ // Reads field.Value directly — serialization is framework-internal, so it does not
3873
+ // (and must not) assert active status. No suppression toggle needed: the assertion no
3874
+ // longer lives on EntityField.Value (it moved to BaseEntity.Get/Set/SetMany).
3875
+ obj[field.Name] = oldValues ? field.OldValue : field.Value;
3876
+ if (field.EntityFieldInfo.TSType == EntityFieldTSType.Date && obj[field.Name] && !(obj[field.Name] instanceof Date)) {
3877
+ obj[field.Name] = new Date(obj[field.Name]); // a timestamp, convert to JS Date Object
3878
+ }
3725
3879
  }
3726
3880
  }
3727
3881
  }
@@ -3744,6 +3898,53 @@ export class BaseEntity {
3744
3898
  }
3745
3899
  return obj;
3746
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
+ }
3747
3948
  /**
3748
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.
3749
3950
  * It is also helpful for quickly finding the fields that are "dirty".
@@ -3921,26 +4122,33 @@ export class BaseEntity {
3921
4122
  this._fieldsHydrated = true;
3922
4123
  if (!this.EntityInfo)
3923
4124
  return;
3924
- // 1. Allocate one EntityField per column. Same construction the old `init()` used to do
3925
- // up-front — we just deferred it.
3926
- for (const rawField of this.EntityInfo.Fields) {
3927
- const key = this.EntityInfo.Name + '.' + rawField.Name;
3928
- const newField = MJGlobal.Instance.ClassFactory.CreateInstance(EntityField, key, rawField);
3929
- this._fields.push(newField);
3930
- }
3931
- // 2. If we have raw data from LoadFromData, populate Fields. The first set of each Field
3932
- // runs through the EntityField setter with `_NeverSet=true`, which simultaneously sets
3933
- // Value AND OldValue (so the field is not marked dirty). This matches the old
3934
- // SetMany(replaceOldValues=true) semantics on initial load.
4125
+ // 1. Allocate one EntityField per column. Check once whether any custom EntityField
4126
+ // subclasses are registered in ClassFactory; in standard deployments none are, so we
4127
+ // can instantiate EntityField directly without N string concatenations + factory lookups.
4128
+ const hasCustomEntityFieldRegistrations = MJGlobal.Instance.ClassFactory.GetAllRegistrations(EntityField).length > 0;
4129
+ const entityFields = this.EntityInfo.Fields;
4130
+ const skipDefaultValue = this._raw !== null;
4131
+ if (hasCustomEntityFieldRegistrations) {
4132
+ const prefix = this.EntityInfo.Name + '.';
4133
+ for (let i = 0; i < entityFields.length; i++) {
4134
+ const rawField = entityFields[i];
4135
+ const newField = MJGlobal.Instance.ClassFactory.CreateInstance(EntityField, prefix + rawField.Name, rawField, undefined, skipDefaultValue);
4136
+ this._fields.push(newField);
4137
+ }
4138
+ }
4139
+ else {
4140
+ for (let i = 0; i < entityFields.length; i++) {
4141
+ this._fields.push(new EntityField(entityFields[i], undefined, skipDefaultValue));
4142
+ }
4143
+ }
4144
+ // 2. If we have raw data from LoadFromData, populate Fields and mark omitted fields as
4145
+ // NotLoaded in a single pass.
3935
4146
  if (this._raw) {
3936
- for (const field of this._fields) {
3937
- const value = this._raw[field.Name];
4147
+ const raw = this._raw;
4148
+ for (let i = 0; i < this._fields.length; i++) {
4149
+ const field = this._fields[i];
4150
+ const value = this.rawValueFor(raw, field.EntityFieldInfo);
3938
4151
  if (value !== undefined) {
3939
- // Populating fields from the loaded row writes EntityField.Value directly, which does
3940
- // not assert active status — so a deprecated column still present in the table emits
3941
- // no warning on load (hydration is not user use of the field). No suppression toggle
3942
- // is needed now that the assertion lives at BaseEntity.Get/Set/SetMany.
3943
- // Date conversion mirrors what SetLocal / Get does for raw string/number dates.
3944
4152
  if (field.EntityFieldInfo.TSType === EntityFieldTSType.Date && (typeof value === 'string' || typeof value === 'number')) {
3945
4153
  field.Value = new Date(value);
3946
4154
  }
@@ -3948,10 +4156,10 @@ export class BaseEntity {
3948
4156
  field.Value = value;
3949
4157
  }
3950
4158
  }
4159
+ else {
4160
+ field.MarkNotLoaded();
4161
+ }
3951
4162
  }
3952
- // _raw IS a hydration source (LoadFromData fast path) — fields whose key it omitted
3953
- // are not-loaded, not defaulted. See EntityField.NotLoaded.
3954
- this.markFieldsOmittedBySourceAsNotLoaded(this._raw);
3955
4163
  // Raw data has been promoted into Fields — release the reference so we don't carry
3956
4164
  // duplicate state. Fields hold their own copies, so a frozen source no longer
3957
4165
  // constrains anything from here on.
@@ -3964,16 +4172,42 @@ export class BaseEntity {
3964
4172
  * Called only by the hydration entry points ({@link LoadFromData} both modes, {@link Hydrate},
3965
4173
  * {@link InnerLoad}) — never by plain {@link SetMany}, which is an incremental mutation API
3966
4174
  * where omitting a field means "leave it alone," not "this field was never loaded."
3967
- * 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.
3968
4179
  */
3969
4180
  markFieldsOmittedBySourceAsNotLoaded(source) {
3970
4181
  if (!source || typeof source !== 'object')
3971
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;
3972
4186
  for (const field of this.Fields) {
3973
- if (source[field.Name] === undefined && source[field.CodeName] === undefined) {
3974
- field.MarkNotLoaded();
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())) {
4192
+ // Cleared, not just flagged: otherwise a default from construction, or the previous
4193
+ // record's value on a reload, reads back through Get() as if it had been loaded.
4194
+ field.ClearAsNotLoaded();
4195
+ }
4196
+ }
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());
3975
4208
  }
3976
4209
  }
4210
+ return keys;
3977
4211
  }
3978
4212
  /**
3979
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.
@@ -3984,8 +4218,15 @@ export class BaseEntity {
3984
4218
  */
3985
4219
  CopyFrom(other, includePrimaryKeys = false, replaceOldValues = false) {
3986
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();
3987
4225
  // iterate through all of OUR fields and set them to the value of the other object, if they exist in the other object
3988
4226
  for (let field of this.Fields) {
4227
+ if (denied?.has(field.Name.trim().toLowerCase())) {
4228
+ continue;
4229
+ }
3989
4230
  if (!field.IsPrimaryKey || includePrimaryKeys) {
3990
4231
  const otherField = other.GetFieldByName(field.Name);
3991
4232
  // Skip fields the SOURCE never loaded (D-3): copying their construction
@@ -4009,9 +4250,27 @@ export class BaseEntity {
4009
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,
4010
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
4011
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.
4012
4263
  */
4013
4264
  set ContextCurrentUser(user) {
4265
+ if (user === this._contextCurrentUser) {
4266
+ return;
4267
+ }
4014
4268
  this._contextCurrentUser = user;
4269
+ this.narrowHeldValuesForActiveUser(false);
4270
+ if (this._parentEntity) {
4271
+ this._parentEntity.ContextCurrentUser = user;
4272
+ }
4273
+ this.notifyCompanionsContextUserChanged(user);
4015
4274
  }
4016
4275
  get ContextCurrentUser() {
4017
4276
  return this._contextCurrentUser;
@@ -4078,6 +4337,11 @@ export class BaseEntity {
4078
4337
  }
4079
4338
  }
4080
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);
4081
4345
  // Apply caller-supplied values LAST so an explicit PK (or any routed
4082
4346
  // parent field) wins over the generated/adopted value instead of being
4083
4347
  // clobbered by the parent's NewRecord().
@@ -4104,6 +4368,53 @@ export class BaseEntity {
4104
4368
  }
4105
4369
  }
4106
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
+ }
4107
4418
  /**
4108
4419
  * Returns the active restore context for the next save, if any.
4109
4420
  *
@@ -4479,7 +4790,7 @@ export class BaseEntity {
4479
4790
  const data = await this.ProviderToUse.Save(this, this.ActiveUser, _options);
4480
4791
  if (!this.TransactionGroup) {
4481
4792
  // no transaction group, so we have our results here
4482
- const result = this.finalizeSave(data, saveSubType);
4793
+ const result = this.finalizeSave(data, saveSubType, _options);
4483
4794
  // Settle the scope this entity opened, if any (only the initiator holds
4484
4795
  // one). The provider can fail by RETURNING falsy data rather than
4485
4796
  // throwing — a validate-type entity action rejecting the save does
@@ -4500,7 +4811,7 @@ export class BaseEntity {
4500
4811
  this.TransactionGroup.TransactionNotifications$.subscribe(({ success, results, error }) => {
4501
4812
  const transItem = success && results ? results.find(r => r.Transaction.BaseEntity === this) : undefined;
4502
4813
  if (transItem) {
4503
- this.finalizeSave(transItem.Result, saveSubType); // we get the resulting data from the transaction result, not data above as that will be blank when in a TG
4814
+ this.finalizeSave(transItem.Result, saveSubType, _options); // we get the resulting data from the transaction result, not data above as that will be blank when in a TG
4504
4815
  }
4505
4816
  else {
4506
4817
  // The transaction group failed / rolled back, OR (should never happen) reported success
@@ -4602,10 +4913,43 @@ export class BaseEntity {
4602
4913
  // If no remaining children, safe to delete parent
4603
4914
  return remainingChildren.length === 0;
4604
4915
  }
4605
- finalizeSave(data, saveSubType) {
4916
+ finalizeSave(data, saveSubType, options) {
4606
4917
  if (data) {
4918
+ // The 'save' event names the fields this save wrote; init() below clears every dirty flag.
4919
+ const changedFieldNames = this.dirtyFieldNames();
4920
+ const effectiveReturnMode = options?.SaveReturnMode ?? this.EntityInfo.SaveReturnMode;
4921
+ const hasGetAll = (val) => val instanceof BaseEntity || typeof val.GetAll === 'function';
4922
+ const rawFieldData = hasGetAll(data) ? data.GetAll() : data;
4923
+ // In 'datestamps_only' mode, the database only returned PK + __mj_CreatedAt / __mj_UpdatedAt.
4924
+ // Merge those returned values onto the current in-memory values BEFORE init() wipes Fields,
4925
+ // so caller-set fields remain loaded and intact without a post-save BaseView SELECT.
4926
+ // When DatabaseProviderBase.Save already merged its pre-save snapshot (`__mj_preMerged`),
4927
+ // reuse `rawFieldData` directly to avoid a redundant second `GetAll()` call.
4928
+ let fieldData;
4929
+ if (effectiveReturnMode === 'datestamps_only' && typeof rawFieldData === 'object' && !Array.isArray(rawFieldData)) {
4930
+ if (rawFieldData.__mj_preMerged) {
4931
+ fieldData = rawFieldData;
4932
+ }
4933
+ else {
4934
+ const currentValues = this.GetAll();
4935
+ if (saveSubType === 'create' || !this.IsSaved) {
4936
+ for (const f of this.EntityInfo.Fields) {
4937
+ if (f.IsVirtual && !f.IsPrimaryKey && !(f.Name in rawFieldData) && (currentValues[f.Name] === null || currentValues[f.Name] === undefined)) {
4938
+ delete currentValues[f.Name];
4939
+ }
4940
+ }
4941
+ }
4942
+ fieldData = { ...currentValues, ...rawFieldData };
4943
+ }
4944
+ }
4945
+ else {
4946
+ fieldData = rawFieldData;
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);
4607
4952
  this.init(); // wipe out the current data to flush out the DIRTY flags, load the ID as part of this too
4608
- const fieldData = (data instanceof BaseEntity || (data && typeof data.GetAll === 'function')) ? data.GetAll() : data;
4609
4953
  // IS-A GetAll() merges the parent, including parent virtuals this entity
4610
4954
  // does not own (e.g. OrderHeader on Event Order Line). Keep only columns
4611
4955
  // this entity defines, and ignore anything leftover.
@@ -4624,7 +4968,7 @@ export class BaseEntity {
4624
4968
  result.NewValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.Value }; }); // set the latest values here
4625
4969
  // Cache the record name for faster lookups (updates cache with potentially new name)
4626
4970
  this.cacheRecordName();
4627
- this.RaiseEvent('save', null, saveSubType);
4971
+ this.RaiseEvent('save', null, saveSubType, changedFieldNames);
4628
4972
  return true;
4629
4973
  }
4630
4974
  else {
@@ -4760,16 +5104,17 @@ export class BaseEntity {
4760
5104
  * entity was hydrated FROM THE DATABASE on every FLS entity. Two distinct resolver behaviours
4761
5105
  * carry that premise, and BOTH are load-bearing:
4762
5106
  *
4763
- * 1. `StripDeniedReadFieldsFromClientInput` removes client-sent values for fields the caller
4764
- * cannot READ, which `SetMany` would otherwise make genuinely dirty with fabricated data.
4765
- * 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
4766
5111
  * non-dirty baseline is the real stored row rather than the client's `OldValues___`.
4767
5112
  *
4768
5113
  * (2) is not redundant with (1). A value arriving through `LoadFromData` is recorded by the
4769
5114
  * EntityField setter as the field's INITIAL value, so it is not dirty — and this check would
4770
5115
  * never see it, while `GenerateSaveSQL` sends it anyway (it filters on `NotLoaded`, never on
4771
5116
  * `Dirty`). Without the forced truth-load, a caller with Read Allow + Update Deny — the
4772
- * 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
4773
5118
  * update-denied field just by pinning its value in `OldValues___` and never naming it in the
4774
5119
  * mutation. If you are considering relaxing that branch condition, this check is what breaks.
4775
5120
  *
@@ -4802,7 +5147,7 @@ export class BaseEntity {
4802
5147
  // deliberately skips the readability assertion (hydration / resolver-apply path),
4803
5148
  // so server-side code can dirty a read-denied field and land here.
4804
5149
  if (deniedRead === undefined) {
4805
- deniedRead = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedReadFields(u));
5150
+ deniedRead = this.deniedReadFieldsForActiveUser();
4806
5151
  }
4807
5152
  throw deniedRead?.has(key)
4808
5153
  ? new FieldSecurityError(field.Name, this.EntityInfo.Name)
@@ -4851,15 +5196,32 @@ export class BaseEntity {
4851
5196
  `omitted field(s) ${suppressed.join(', ')}; each column takes its default`);
4852
5197
  }
4853
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
+ }
4854
5216
  /**
4855
5217
  * The denied-field set for the acting user, or null when field security does not apply —
4856
- * 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.
4857
5219
  *
4858
5220
  * Returning null rather than an empty Set lets callers skip their loop entirely, and keeps
4859
5221
  * the three cheap short-circuits in one place instead of repeated at each gate.
4860
5222
  */
4861
5223
  deniedFieldsForActiveUser(select) {
4862
- if (!this.EntityInfo?.EnableFieldLevelSecurity) {
5224
+ if (!this.EntityInfo?.FieldLevelSecurityApplies) {
4863
5225
  return null; // one boolean for the overwhelming majority of entities
4864
5226
  }
4865
5227
  const u = this.resolveActiveUserOrNull();
@@ -4869,6 +5231,34 @@ export class BaseEntity {
4869
5231
  const denied = select(u);
4870
5232
  return denied.size > 0 ? denied : null;
4871
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
+ }
4872
5262
  /**
4873
5263
  * {@link ActiveUser}, but null instead of throwing when no provider is configured to resolve
4874
5264
  * one from.
@@ -4903,7 +5293,7 @@ export class BaseEntity {
4903
5293
  this.discardJSONFieldObjects(); // unconditional: an un-flushed edit through a stale reference must go too
4904
5294
  if (this.Dirty) {
4905
5295
  for (let field of this.Fields) {
4906
- field.Value = field.OldValue;
5296
+ field.RevertToOldValue();
4907
5297
  }
4908
5298
  // IS-A composition: revert parent entity chain as well
4909
5299
  if (this._parentEntity) {
@@ -4936,7 +5326,7 @@ export class BaseEntity {
4936
5326
  if (!this.IsSaved) {
4937
5327
  this.init(); // wipe out current data if we're loading on top of existing record
4938
5328
  }
4939
- const data = await this.readRowForLoad(CompositeKey, EntityRelationshipsToLoad);
5329
+ let data = await this.readRowForLoad(CompositeKey, EntityRelationshipsToLoad);
4940
5330
  this.discardJSONFieldObjects(); // a reload replaces the record's data
4941
5331
  if (!data) {
4942
5332
  // A subtype-hint probe asks whether this row exists, so "no" is an answer, not an error
@@ -4945,6 +5335,9 @@ export class BaseEntity {
4945
5335
  }
4946
5336
  return false; // no data loaded, return false
4947
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);
4948
5341
  // IS-A: hydrate parent chain from loaded data before populating self.
4949
5342
  // Hydrate resets each parent via init() (giving fresh _NeverSet=true on PK fields)
4950
5343
  // then populates from data. This must happen before self's SetMany so parents
@@ -5095,6 +5488,16 @@ export class BaseEntity {
5095
5488
  */
5096
5489
  async LoadFromData(data, _replaceOldValues = false) {
5097
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);
5098
5501
  // IS-A: hydrate parent chain from data before populating self.
5099
5502
  // Hydrate resets each parent via init() (giving fresh _NeverSet=true on PK fields)
5100
5503
  // then populates from data, ensuring correct PK and saved state on parents.
@@ -5108,12 +5511,26 @@ export class BaseEntity {
5108
5511
  // This is the dominant warm-load case (cache restore in TransformSimpleObjectToEntityObject
5109
5512
  // populating engine arrays of read-only data) and was previously the per-row bottleneck.
5110
5513
  const isPlainObject = data && typeof data === 'object' && !Array.isArray(data) && !(data instanceof Date);
5514
+ const isFirstOrUnhydratedLoad = this._raw === null &&
5515
+ !this._recordLoaded &&
5516
+ !this._everSaved &&
5517
+ ((!this._fieldsHydrated && this._fields.length === 0) || this._fields.every(f => f.NeverSet));
5518
+ const hasCompanionPayload = this.HasCompanions &&
5519
+ isPlainObject &&
5520
+ Array.isArray(data[COMPANION_PAYLOAD_KEY]);
5111
5521
  const canTakeFastPath = isPlainObject &&
5112
- !this._fieldsHydrated &&
5113
- this._fields.length === 0 &&
5522
+ isFirstOrUnhydratedLoad &&
5114
5523
  !this._parentEntity &&
5115
- !this.EntityInfo?.IsParentType;
5524
+ !this.EntityInfo?.IsParentType &&
5525
+ !hasCompanionPayload;
5116
5526
  if (canTakeFastPath) {
5527
+ if (this._fieldsHydrated || this._fields.length > 0) {
5528
+ this._fields = [];
5529
+ this._fieldsHydrated = false;
5530
+ this._fieldCache = null;
5531
+ this._codeNameCache = null;
5532
+ }
5533
+ this._compositeKey = null;
5117
5534
  this._raw = data;
5118
5535
  // Drop any conversions memoized from a previously-loaded row — they describe the old
5119
5536
  // `_raw`, not this one. No isFrozen probe is needed: `Get()` never writes to `_raw`,
@@ -5124,7 +5541,7 @@ export class BaseEntity {
5124
5541
  const pks = this.EntityInfo?.PrimaryKeys ?? [];
5125
5542
  let allPksPresent = pks.length > 0;
5126
5543
  for (const pkInfo of pks) {
5127
- const v = this._raw[pkInfo.Name];
5544
+ const v = this.rawValueFor(this._raw, pkInfo);
5128
5545
  if (v === null || v === undefined) {
5129
5546
  allPksPresent = false;
5130
5547
  break;
@@ -5887,7 +6304,7 @@ export class BaseEntity {
5887
6304
  //
5888
6305
  // Returning null is the same answer callers already handle for "this entity has no name
5889
6306
  // field", and every one of them degrades to the primary key.
5890
- if (!this.EntityInfo.IsFieldReadableByUser(f.Name, this.ActiveUser)) {
6307
+ if (!this.IsFieldReadableByActiveUser(f.Name)) {
5891
6308
  return null;
5892
6309
  }
5893
6310
  return this.Get(f.Name);
@@ -6091,13 +6508,16 @@ export class BaseEntity {
6091
6508
  * use this to track vectors associated with
6092
6509
  */
6093
6510
  get Vectors() {
6511
+ if (!this._vectors) {
6512
+ this._vectors = new Map();
6513
+ }
6094
6514
  return this._vectors;
6095
6515
  }
6096
6516
  /**
6097
6517
  * Resets the vector embeddings for this entity to an empty state.
6098
6518
  */
6099
6519
  ResetVectors() {
6100
- this._vectors.clear();
6520
+ this._vectors?.clear();
6101
6521
  }
6102
6522
  /**
6103
6523
  * Resolves the recursive foreign key field for this entity. If `parentFieldName` is provided,