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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts +27 -0
  2. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts.map +1 -0
  3. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.js +6 -0
  4. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.js.map +1 -0
  5. package/dist/generic/baseEngine.d.ts +52 -1
  6. package/dist/generic/baseEngine.d.ts.map +1 -1
  7. package/dist/generic/baseEngine.js +95 -7
  8. package/dist/generic/baseEngine.js.map +1 -1
  9. package/dist/generic/baseEntity.d.ts +292 -0
  10. package/dist/generic/baseEntity.d.ts.map +1 -1
  11. package/dist/generic/baseEntity.js +878 -98
  12. package/dist/generic/baseEntity.js.map +1 -1
  13. package/dist/generic/baseInfo.d.ts +6 -0
  14. package/dist/generic/baseInfo.d.ts.map +1 -1
  15. package/dist/generic/baseInfo.js +19 -1
  16. package/dist/generic/baseInfo.js.map +1 -1
  17. package/dist/generic/compositeKey.d.ts +108 -0
  18. package/dist/generic/compositeKey.d.ts.map +1 -1
  19. package/dist/generic/compositeKey.js +225 -10
  20. package/dist/generic/compositeKey.js.map +1 -1
  21. package/dist/generic/databaseProviderBase.d.ts +49 -0
  22. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  23. package/dist/generic/databaseProviderBase.js +90 -11
  24. package/dist/generic/databaseProviderBase.js.map +1 -1
  25. package/dist/generic/embeddedRecord.d.ts +2 -1
  26. package/dist/generic/embeddedRecord.d.ts.map +1 -1
  27. package/dist/generic/embeddedRecord.js +9 -2
  28. package/dist/generic/embeddedRecord.js.map +1 -1
  29. package/dist/generic/entityCompanion.d.ts +6 -1
  30. package/dist/generic/entityCompanion.d.ts.map +1 -1
  31. package/dist/generic/entityCompanion.js +7 -0
  32. package/dist/generic/entityCompanion.js.map +1 -1
  33. package/dist/generic/entityInfo.d.ts +550 -8
  34. package/dist/generic/entityInfo.d.ts.map +1 -1
  35. package/dist/generic/entityInfo.js +695 -20
  36. package/dist/generic/entityInfo.js.map +1 -1
  37. package/dist/generic/entitySubtypeResolver.d.ts +19 -0
  38. package/dist/generic/entitySubtypeResolver.d.ts.map +1 -0
  39. package/dist/generic/entitySubtypeResolver.js +24 -0
  40. package/dist/generic/entitySubtypeResolver.js.map +1 -0
  41. package/dist/generic/entityTransactionScope.d.ts +6 -3
  42. package/dist/generic/entityTransactionScope.d.ts.map +1 -1
  43. package/dist/generic/entityTransactionScope.js +6 -3
  44. package/dist/generic/entityTransactionScope.js.map +1 -1
  45. package/dist/generic/extendedTypeValue.d.ts +47 -0
  46. package/dist/generic/extendedTypeValue.d.ts.map +1 -0
  47. package/dist/generic/extendedTypeValue.js +152 -0
  48. package/dist/generic/extendedTypeValue.js.map +1 -0
  49. package/dist/generic/filters/compositeFilter.d.ts +77 -0
  50. package/dist/generic/filters/compositeFilter.d.ts.map +1 -0
  51. package/dist/generic/filters/compositeFilter.js +324 -0
  52. package/dist/generic/filters/compositeFilter.js.map +1 -0
  53. package/dist/generic/filters/filter.types.d.ts +29 -0
  54. package/dist/generic/filters/filter.types.d.ts.map +1 -0
  55. package/dist/generic/filters/filter.types.js +28 -0
  56. package/dist/generic/filters/filter.types.js.map +1 -0
  57. package/dist/generic/filters/index.d.ts +3 -0
  58. package/dist/generic/filters/index.d.ts.map +1 -0
  59. package/dist/generic/filters/index.js +3 -0
  60. package/dist/generic/filters/index.js.map +1 -0
  61. package/dist/generic/geoFields.d.ts +64 -0
  62. package/dist/generic/geoFields.d.ts.map +1 -0
  63. package/dist/generic/geoFields.js +108 -0
  64. package/dist/generic/geoFields.js.map +1 -0
  65. package/dist/generic/interfaces.d.ts +16 -2
  66. package/dist/generic/interfaces.d.ts.map +1 -1
  67. package/dist/generic/interfaces.js.map +1 -1
  68. package/dist/generic/localCacheManager.d.ts +34 -3
  69. package/dist/generic/localCacheManager.d.ts.map +1 -1
  70. package/dist/generic/localCacheManager.js +53 -4
  71. package/dist/generic/localCacheManager.js.map +1 -1
  72. package/dist/generic/providerBase.d.ts +282 -5
  73. package/dist/generic/providerBase.d.ts.map +1 -1
  74. package/dist/generic/providerBase.js +754 -93
  75. package/dist/generic/providerBase.js.map +1 -1
  76. package/dist/generic/recordChangeFieldSecurity.d.ts +164 -0
  77. package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -0
  78. package/dist/generic/recordChangeFieldSecurity.js +279 -0
  79. package/dist/generic/recordChangeFieldSecurity.js.map +1 -0
  80. package/dist/generic/relatedRecordBatchLoader.js +2 -2
  81. package/dist/generic/relatedRecordBatchLoader.js.map +1 -1
  82. package/dist/generic/relatedRecordCollection.d.ts +2 -1
  83. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  84. package/dist/generic/relatedRecordCollection.js +16 -4
  85. package/dist/generic/relatedRecordCollection.js.map +1 -1
  86. package/dist/generic/saveEntityGraphOperation.d.ts +10 -0
  87. package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
  88. package/dist/generic/saveEntityGraphOperation.js +2 -1
  89. package/dist/generic/saveEntityGraphOperation.js.map +1 -1
  90. package/dist/generic/wellKnownUserSource.d.ts +70 -0
  91. package/dist/generic/wellKnownUserSource.d.ts.map +1 -0
  92. package/dist/generic/wellKnownUserSource.js +82 -0
  93. package/dist/generic/wellKnownUserSource.js.map +1 -0
  94. package/dist/index.d.ts +7 -0
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +7 -0
  97. package/dist/index.js.map +1 -1
  98. package/package.json +3 -3
@@ -8,14 +8,17 @@ var __metadata = (this && this.__metadata) || function (k, v) {
8
8
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
9
  };
10
10
  var EntityField_1;
11
- import { IsMemberOverridden, MJEventType, MJGlobal, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, WarningManager } from '@memberjunction/global';
11
+ import { DeserializeValidationErrors, IsMemberOverridden, MJEventType, MJGlobal, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, WarningManager } from '@memberjunction/global';
12
12
  import { GetDataHooks } from './dataHooks.js';
13
- import { EntityFieldInfo, EntityInfo, EntityFieldTSType, EntityPermissionType, RecordChange, ValidationErrorInfo, ValidationResult } from './entityInfo.js';
13
+ import { EntityFieldInfo, EntityInfo, EntityFieldTSType, EntityPermissionType, FieldSecurityError, RecordChange, ValidationErrorInfo, ValidationResult } from './entityInfo.js';
14
+ import { EntitySubtypeResolver } from './entitySubtypeResolver.js';
15
+ import { BaseEngineRegistry } from './baseEngineRegistry.js';
16
+ import { IsPermittedImageFieldValue, IsValidCssColor, TryParseJsonText } from './extendedTypeValue.js';
14
17
  import { EntityDeleteOptions, EntitySaveOptions, ProviderType } from './interfaces.js';
15
18
  import { Metadata } from './metadata.js';
16
19
  import { RunView } from '../views/runView.js';
17
20
  import { LogDebug, LogError } from './logging.js';
18
- import { CompositeKey } from './compositeKey.js';
21
+ import { CompositeKey, KeyValuePair } from './compositeKey.js';
19
22
  import { RelatedRecordCollection } from './relatedRecordCollection.js';
20
23
  import { COMPANION_PAYLOAD_KEY } from './entityCompanion.js';
21
24
  import { EmbeddedRecord } from './embeddedRecord.js';
@@ -49,6 +52,57 @@ let EntityField = class EntityField {
49
52
  "real": { min: -3.402823466e+38, max: 3.402823466e+38 },
50
53
  "money": { min: -922337203685477.5808, max: 922337203685477.5807 },
51
54
  }; }
55
+ /**
56
+ * True when the source this entity was hydrated from OMITTED this field's key — most
57
+ * commonly because field-level security stripped it before the payload reached us, but
58
+ * equally for any partial hydration. The field's in-memory state (its metadata default,
59
+ * else null) is a construction artifact, not data: the save path skips not-loaded fields
60
+ * entirely (the generated procs' `ISNULL(@p, [Col])` merge then preserves the stored
61
+ * value), {@link Dirty} always reports false for them, and {@link Validate} exempts them
62
+ * from the required/null check.
63
+ *
64
+ * Deliberately DISTINCT from `_NeverSet`, which means "no set since construction," exists
65
+ * to permit the one-time write to ReadOnly fields on load, and is re-armed wholesale by
66
+ * `InnerLoad` — reusing it would conflate defaults with omissions. This flag is set only
67
+ * by the hydration paths (via {@link MarkNotLoaded}) when a source omits the key, is
68
+ * cleared by ANY explicit set (an intentional blind write to a read-denied field is a
69
+ * legitimate write-only update and must save), and is never present on new (unhydrated)
70
+ * entities — their fields legitimately hold metadata defaults for INSERT.
71
+ */
72
+ get NotLoaded() {
73
+ return this._NotLoaded;
74
+ }
75
+ /**
76
+ * Framework-internal: hydration paths call this for each field whose key the hydration
77
+ * source omitted. Application code should never need it — an explicit {@link Value} set
78
+ * clears the flag.
79
+ */
80
+ MarkNotLoaded() {
81
+ this._NotLoaded = true;
82
+ }
83
+ /**
84
+ * True when field-level security bars this user from supplying the field's value on INSERT,
85
+ * so the save path must omit it and let the column take its database default.
86
+ *
87
+ * Deliberately DISTINCT from {@link NotLoaded}, even though both end in "leave this
88
+ * parameter out of the SP call". `NotLoaded` means "the hydration source omitted this key"
89
+ * and carries consequences this must not: it suppresses the `_Clear` companion, exempts the
90
+ * field from required/null validation, and forces {@link Dirty} to false. A create-suppressed
91
+ * field, by contrast, holds a perfectly real value the user typed — it is simply not one they
92
+ * are permitted to supply. Conflating them would silently disarm validation on fields a user
93
+ * IS allowed to create.
94
+ *
95
+ * Set per-save by the create-path gate and cleared at the start of every save, because the
96
+ * answer depends on the acting user and the same entity object can be saved by different
97
+ * users over its lifetime.
98
+ */
99
+ get CreateSuppressed() {
100
+ return this._CreateSuppressed;
101
+ }
102
+ /** Framework-internal: set by the save path's field-security create gate. */
103
+ SetCreateSuppressed(suppressed) {
104
+ this._CreateSuppressed = suppressed;
105
+ }
52
106
  get Name() {
53
107
  return this._entityFieldInfo.Name;
54
108
  }
@@ -123,8 +177,16 @@ let EntityField = class EntityField {
123
177
  value = value.replace(/ +$/, '');
124
178
  }
125
179
  this._Value = value;
180
+ // Any explicit set means the field now holds REAL data — including a blind write to
181
+ // a read-denied field (the write-only case), which must flow to the save. Captured
182
+ // BEFORE clearing so the OldValue branch below can see it: a set onto a not-loaded
183
+ // field is an EDIT (must become dirty and reach the save), never the "initial value
184
+ // set" of record setup — the record's setup moment was the hydration that omitted
185
+ // this field.
186
+ const wasNotLoaded = this._NotLoaded;
187
+ this._NotLoaded = false;
126
188
  // 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
127
- if (this._NeverSet &&
189
+ if (this._NeverSet && !wasNotLoaded &&
128
190
  (value !== null || this._OldValue !== null)) {
129
191
  // initial value set
130
192
  this._OldValue = value;
@@ -142,9 +204,11 @@ let EntityField = class EntityField {
142
204
  }
143
205
  /**
144
206
  * 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.
207
+ * A {@link NotLoaded} field is never dirty either — its in-memory state is a construction
208
+ * artifact, not data, and nothing that was never loaded can have been changed.
145
209
  */
146
210
  get Dirty() {
147
- if (this.ReadOnly)
211
+ if (this.ReadOnly || this._NotLoaded)
148
212
  return false;
149
213
  else {
150
214
  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
@@ -278,7 +342,13 @@ let EntityField = class EntityField {
278
342
  // no longer false-warns or throws here.
279
343
  if (!ef.ReadOnly && !ef.SkipValidation) {
280
344
  // only do validation on updatable fields and skip the special case fields defined inside the SkipValidation property (like ID/CreatedAt/UpdatedAt)
281
- if (!ef.AllowsNull && (this.Value === null || this.Value === undefined)) {
345
+ // NotLoaded exemption: a field the hydration source omitted holds null/default by
346
+ // construction and is SKIPPED by the save SQL, so the stored value is untouched —
347
+ // failing the required check here would make every partially hydrated record
348
+ // unsaveable for unrelated edits (the exact NOT-NULL breakage mode the not-loaded
349
+ // design exists to remove). Length/type checks below still apply to whatever the
350
+ // constructor state is; only the required/null check is meaningless for it.
351
+ if (!ef.AllowsNull && !this._NotLoaded && (this.Value === null || this.Value === undefined)) {
282
352
  // make sure this isn't a field that has a default value and we are inside a new record
283
353
  if (ef.DefaultValue === null || ef.DefaultValue === undefined || ef.DefaultValue.trim().length === 0) {
284
354
  // we have no default value, so this is an error
@@ -327,11 +397,42 @@ let EntityField = class EntityField {
327
397
  const nullNote = ef.AllowsNull ? ' (or null)' : '';
328
398
  result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} must be one of: ${ef.ValueListValuesForDisplay}${nullNote}. Current value is '${this.Value}'`, this.Value));
329
399
  }
400
+ // ExtendedType semantic checks (Image / Color / JSON). Empty values are handled by
401
+ // the AllowsNull rung above — only non-empty strings are inspected here.
402
+ if (ef.TSType === EntityFieldTSType.String && this.Value != null && this.Value !== '') {
403
+ const text = String(this.Value);
404
+ switch (ef.ExtendedType) {
405
+ case 'JSON': {
406
+ const parsed = TryParseJsonText(text);
407
+ if (parsed.ok === false) {
408
+ result.Success = false;
409
+ result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} must be valid JSON. ${parsed.message}`, this.Value));
410
+ }
411
+ break;
412
+ }
413
+ case 'Color': {
414
+ if (!IsValidCssColor(text)) {
415
+ result.Success = false;
416
+ result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} must be a CSS color (hex, rgb, or hsl)`, this.Value));
417
+ }
418
+ break;
419
+ }
420
+ case 'Image': {
421
+ if (!IsPermittedImageFieldValue(text)) {
422
+ result.Success = false;
423
+ result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} must be an image URL or inline image (data URI / base64)`, this.Value));
424
+ }
425
+ break;
426
+ }
427
+ }
428
+ }
330
429
  }
331
430
  return result;
332
431
  }
333
432
  constructor(fieldInfo, Value) {
334
433
  this._NeverSet = true;
434
+ this._NotLoaded = false;
435
+ this._CreateSuppressed = false;
335
436
  // NOTE: constructing an EntityField for a deprecated/disabled field is always allowed — the
336
437
  // column is physically part of the entity and the instance must exist. Active-status is
337
438
  // asserted only when code accesses the field by name via BaseEntity.Get/Set/SetMany, never
@@ -489,6 +590,21 @@ export class BaseEntityAIActionParams {
489
590
  */
490
591
  export class BaseEntityResult {
491
592
  constructor(success, message, type) {
593
+ /**
594
+ * Set by a producer whose `Message` ALREADY renders every entry of `Errors`, so that
595
+ * {@link CompleteMessage} does not say the same thing twice.
596
+ *
597
+ * Two kinds of producer build `Message` out of `Errors`: the client-side providers, which copy
598
+ * the SERVER's `CompleteMessage` (= its errors, joined) into `Message` and then rehydrate the same
599
+ * entries into `Errors` so a form can paint the fields; and the IS-A parent-failure paths, which
600
+ * write "Failed to save parent entity … : <errors joined>". Without this flag every one of them
601
+ * read twice in `CompleteMessage`. A substring dedupe was tried and reverted (it is lossy in ways
602
+ * a reader cannot detect — see `CompleteMessage`); a producer stating the fact is exact.
603
+ *
604
+ * Only honoured when `Message` actually has text: a producer that set the flag and left `Message`
605
+ * empty is contradicting itself, and the errors are still rendered rather than lost.
606
+ */
607
+ this.MessageIncludesErrors = false;
492
608
  /**
493
609
  * A copy of the values of the entity object BEFORE the operation was performed
494
610
  */
@@ -505,6 +621,56 @@ export class BaseEntityResult {
505
621
  this.StartedAt = new Date();
506
622
  this.EndedAt = new Date();
507
623
  }
624
+ /**
625
+ * Renders ONE entry of the {@link Errors} array as human-readable text.
626
+ *
627
+ * `Errors` is typed `any[]`, and two shapes land in it from different places:
628
+ *
629
+ * - **`ValidationErrorInfo`** — carries **`Message`** (capital M), plus `Source`, `Value` and
630
+ * `Type`. This is what `_InnerSave` puts there when validation refuses a save: it throws the
631
+ * `ValidationResult`, and the catch block assigns `newResult.Errors = e.Errors`.
632
+ * - **`Error`** (and anything error-like) — carries lowercase **`message`**.
633
+ *
634
+ * This used to read `err.message` ONLY, so every `ValidationErrorInfo` fell through to
635
+ * `JSON.stringify(err)`. That is not a cosmetic difference: `CompleteMessage` is the string the
636
+ * server hands the client on a failed save — every write-refusal throw in `ResolverBase`
637
+ * (`CreateRecord`/`UpdateRecord`/`DeleteRecord`) puts it in the `GraphQLError`, and
638
+ * `SaveEntityGraphOperation` puts it in `ErrorMessage` — so the whole point of writing a careful,
639
+ * field-named refusal in a subclass's `ValidateAsync()` was defeated at the last step, and the
640
+ * user saw
641
+ * `{"Source":"ParentContractID","Message":"…","Value":null,"Type":"Failure"}` in a toast.
642
+ *
643
+ * Nothing catches this at compile time because `Errors` is `any[]`; nothing catches it at runtime
644
+ * because `JSON.stringify` always succeeds. It is only visible by reading the message a user got.
645
+ *
646
+ * The parameter is `unknown` rather than `any` — per `.claude/rules/typescript-style.md` — because
647
+ * not knowing the shape is the whole reason this helper exists, and `unknown` forces the narrowing
648
+ * that makes each shape's handling explicit. Callers pass `any` (the `Errors` array and the `Error`
649
+ * property are both legacy `any`), which is assignable, so no call site changes.
650
+ *
651
+ * `Message` is preferred over `message` because a `ValidationErrorInfo` has only the former,
652
+ * while an `Error` has only the latter — so the order matters solely for an object carrying both,
653
+ * where the MJ-native field is the better answer.
654
+ *
655
+ * @param err - One entry from the `Errors` array.
656
+ * @returns The entry's human-readable text, falling back to JSON for a shape with neither field.
657
+ */
658
+ static ErrorText(err) {
659
+ if (err === null || err === undefined) {
660
+ return '';
661
+ }
662
+ if (typeof err === 'string') {
663
+ return err;
664
+ }
665
+ if (typeof err === 'object') {
666
+ const shaped = err;
667
+ const text = shaped.Message ?? shaped.message;
668
+ if (typeof text === 'string' && text.trim().length > 0) {
669
+ return text;
670
+ }
671
+ }
672
+ return JSON.stringify(err);
673
+ }
508
674
  /**
509
675
  * Returns a complete message that includes the Message property (if present), the Error property (if present), and any Errors array items (if present).
510
676
  */
@@ -514,23 +680,30 @@ export class BaseEntityResult {
514
680
  if (this.Message && this.Message.trim().length > 0) {
515
681
  msg = this.Message;
516
682
  }
517
- // now check the simple Error property
683
+ // now check the simple Error property. Same shape problem as the Errors array below, so the
684
+ // same helper answers it: a string, an Error (lowercase `message`), or an MJ
685
+ // ValidationErrorInfo (capital `Message`) all render as their text rather than as JSON.
518
686
  if (this.Error) {
519
- msg = (msg ? msg + '\n' : '');
520
- if (typeof this.Error === 'string') {
521
- msg += this.Error;
522
- }
523
- else if (this.Error.message) {
524
- msg += this.Error.message;
525
- }
526
- else {
527
- msg += JSON.stringify(this.Error);
528
- }
687
+ msg = (msg ? msg + '\n' : '') + BaseEntityResult.ErrorText(this.Error);
529
688
  }
530
- // now check the Errors array
531
- if (this.Errors && this.Errors.length > 0) {
689
+ // now check the Errors array.
690
+ //
691
+ // NOT de-duplicated, deliberately. Some producers set BOTH `Message` and `Errors` and build
692
+ // the former out of the latter — `_InnerSave`/`_InnerDelete` do on an IS-A parent failure —
693
+ // so their text does appear twice here. Suppressing a repeat was tried and reverted: any
694
+ // containment test is lossy in ways a reader cannot detect. Three fields failing with the
695
+ // same sentence collapse to one line; an entry whose text is a substring of another is kept
696
+ // or dropped depending on ARRAY ORDER; and a distinct error vanishes when its text happens to
697
+ // appear inside the summary. Saying something twice is ugly. Silently reporting one problem
698
+ // when there were three is the failure this whole class of bug is about, so the duplication
699
+ // stays UNLESS the producer states, via `MessageIncludesErrors`, that `Message` already
700
+ // renders every entry — an exact fact, not a guess, and the only producer-side fix that
701
+ // removes the repeat at the source.
702
+ const messageHasText = !!this.Message && this.Message.trim().length > 0;
703
+ const errorsAlreadyInMessage = this.MessageIncludesErrors && messageHasText;
704
+ if (this.Errors && this.Errors.length > 0 && !errorsAlreadyInMessage) {
532
705
  // append
533
- msg = (msg ? msg + '\n' : '') + this.Errors.map(err => err.message || JSON.stringify(err)).join('\n');
706
+ msg = (msg ? msg + '\n' : '') + this.Errors.map(err => BaseEntityResult.ErrorText(err)).join('\n');
534
707
  }
535
708
  return msg;
536
709
  }
@@ -816,6 +989,16 @@ export class BaseEntity {
816
989
  * pay nothing for the feature, not even an empty Map per instance.
817
990
  */
818
991
  this._companions = null;
992
+ /**
993
+ * Returns true if the object is Dirty, meaning something has changed since it was last saved to the database, and false otherwise. For new records, this will always return true.
994
+ *
995
+ * @remarks
996
+ * Dirtiness rolls up **companions** as well as fields. Without that rollup, a clean parent with
997
+ * three brand-new children reports `Dirty === false`, `_InnerSave` takes its not-dirty early
998
+ * return, and the children are silently never persisted — the save reports success and writes
999
+ * nothing. See {@link EntityCompanion.Dirty}.
1000
+ */
1001
+ this._isCheckingDirty = false;
819
1002
  this._compositeKey = null;
820
1003
  // ────────────────────────────────────────────────────────────────────
821
1004
  // Restore context — populated by callers immediately before Save() to
@@ -833,6 +1016,13 @@ export class BaseEntity {
833
1016
  this._restoreContext = null;
834
1017
  // Holds the current pending save observable (if any)
835
1018
  this._pendingSave$ = null;
1019
+ /**
1020
+ * This method is used automatically within Save() and is used to determine if the state of the object is valid relative to the validation rules that are defined in metadata. In addition, sub-classes can
1021
+ * override or wrap this base class method to add other logic for validation.
1022
+ *
1023
+ * @returns ValidationResult The validation result
1024
+ */
1025
+ this._isValidating = false;
836
1026
  /**
837
1027
  * private storage for vectors that might be used for this entity, typically in association with individual fields
838
1028
  * however it is possible for the string in the map to be any unique key relative to the object so you could have vectors
@@ -852,6 +1042,42 @@ export class BaseEntity {
852
1042
  this._provider = Provider;
853
1043
  this.init();
854
1044
  }
1045
+ /**
1046
+ * The provider actually stored on this instance, or `null` if none was bound.
1047
+ * Unlike {@link ProviderToUse}, this does **not** fall back to the process-wide
1048
+ * {@link BaseEntity.Provider}. Use it to detect a dropped constructor argument:
1049
+ * `GetEntityObject(graphProvider)` must yield `BoundProvider === graphProvider`.
1050
+ */
1051
+ get BoundProvider() {
1052
+ return this._provider;
1053
+ }
1054
+ /**
1055
+ * Bind this instance to a provider after construction.
1056
+ *
1057
+ * **Rule (ORM, not just metadata-sync):** every DB read and write on this
1058
+ * instance — Save, Load, Delete, RunView, GetEntityObject of children/embeds,
1059
+ * lookups, RecordGeoCode — MUST use this provider. Mixing another provider
1060
+ * (especially the process-wide host) into the same record graph is a deadlock:
1061
+ * a child FK waits on an uncommitted parent on another connection.
1062
+ *
1063
+ * {@link ProviderBase.GetEntityObject} always calls this so a subclass that
1064
+ * declares `constructor(Entity: EntityInfo)` and drops the second ClassFactory
1065
+ * argument cannot silently run on the global host.
1066
+ */
1067
+ BindProvider(provider) {
1068
+ this._provider = provider;
1069
+ if (this._parentEntity && this._parentEntity.BoundProvider !== provider) {
1070
+ this._parentEntity.BindProvider(provider);
1071
+ }
1072
+ if (this._childEntity && this._childEntity.BoundProvider !== provider) {
1073
+ this._childEntity.BindProvider(provider);
1074
+ }
1075
+ if (this._companions) {
1076
+ for (const companion of this._companions.values()) {
1077
+ companion.BindProvider(provider);
1078
+ }
1079
+ }
1080
+ }
855
1081
  /**
856
1082
  * Initializes the IS-A parent entity composition chain. For child type entities,
857
1083
  * this creates the parent entity instance (and recursively its parent, etc.) and
@@ -1062,6 +1288,249 @@ export class BaseEntity {
1062
1288
  // InitializeChildEntity is idempotent via _childEntityDiscoveryDone flag
1063
1289
  await childEntity.InitializeChildEntity();
1064
1290
  }
1291
+ static { this._subtypeLookupCache = new Map(); }
1292
+ /**
1293
+ * Clears the static memoization cache used by SubtypeSelector path evaluation.
1294
+ */
1295
+ static ClearSubtypeLookupCache() {
1296
+ BaseEntity._subtypeLookupCache.clear();
1297
+ }
1298
+ /**
1299
+ * Prospective counterpart to FindISAChildEntity.
1300
+ * Evaluates which IsA child subtype entity this record should have based on:
1301
+ * 1. Registered EntitySubtypeResolver (ClassFactory key = entity name)
1302
+ * 2. Entity.SubtypeSelector declarative FK traversal path
1303
+ * 3. Unconditional single-child IsA fallback (ChildEntities.length === 1)
1304
+ * 4. Otherwise null (no subtype)
1305
+ *
1306
+ * @see plans/sync-composition-axes.md
1307
+ */
1308
+ async ResolveSubtypeEntityName() {
1309
+ if (!this.EntityInfo.IsParentType || !this.EntityInfo.ChildEntities || this.EntityInfo.ChildEntities.length === 0) {
1310
+ return null;
1311
+ }
1312
+ // 1. Registered resolver override
1313
+ const reg = MJGlobal.Instance.ClassFactory.GetRegistration(EntitySubtypeResolver, this.EntityInfo.Name);
1314
+ if (reg) {
1315
+ const resolution = MJGlobal.Instance.ClassFactory.TryCreateInstance(EntitySubtypeResolver, this.EntityInfo.Name);
1316
+ if (resolution.Resolved && resolution.Instance) {
1317
+ const raw = resolution.Instance.Resolve(this);
1318
+ const candidate = raw instanceof Promise ? await raw : raw;
1319
+ if (candidate != null && candidate.trim() !== '') {
1320
+ const trimmed = candidate.trim();
1321
+ const match = this.EntityInfo.ChildEntities.find(c => c.Name.trim().toLowerCase() === trimmed.toLowerCase());
1322
+ if (!match) {
1323
+ throw new Error(`EntitySubtypeResolver for '${this.EntityInfo.Name}' returned '${candidate}', which is not a declared IsA child entity of '${this.EntityInfo.Name}'.`);
1324
+ }
1325
+ return match.Name;
1326
+ }
1327
+ return null;
1328
+ }
1329
+ }
1330
+ // 2. Entity.SubtypeSelector declarative path
1331
+ const selectorConfig = this.EntityInfo.SubtypeSelectorConfig;
1332
+ if (selectorConfig && selectorConfig.Path && selectorConfig.Path.trim() !== '') {
1333
+ const pathResult = await this.evaluateSubtypeSelectorPath(selectorConfig.Path.trim());
1334
+ if (pathResult != null && pathResult.trim() !== '') {
1335
+ const trimmed = pathResult.trim();
1336
+ const match = this.EntityInfo.ChildEntities.find(c => c.Name.trim().toLowerCase() === trimmed.toLowerCase());
1337
+ if (!match) {
1338
+ throw new Error(`SubtypeSelector path '${selectorConfig.Path}' on '${this.EntityInfo.Name}' resolved to '${pathResult}', which is not a declared IsA child entity of '${this.EntityInfo.Name}'.`);
1339
+ }
1340
+ return match.Name;
1341
+ }
1342
+ return null;
1343
+ }
1344
+ // 3. Exactly one child, unconditional IsA
1345
+ if (!this.EntityInfo.AllowMultipleSubtypes && this.EntityInfo.ChildEntities.length === 1) {
1346
+ return this.EntityInfo.ChildEntities[0].Name;
1347
+ }
1348
+ // 4. Otherwise null
1349
+ return null;
1350
+ }
1351
+ /**
1352
+ * Create-safe prospective counterpart to InitializeChildEntity.
1353
+ * Unlike createAndLinkChildEntity, does NOT unlink when InnerLoad finds no row —
1354
+ * that is the create case. Idempotent. Defaults to ResolveSubtypeEntityName()
1355
+ * when no name is passed.
1356
+ *
1357
+ * @param entityName Optional explicit child entity name. If omitted, resolved via ResolveSubtypeEntityName().
1358
+ * @returns The linked child BaseEntity, or null if no subtype applies.
1359
+ */
1360
+ async EnsureISAChild(entityName) {
1361
+ if (!entityName) {
1362
+ entityName = await this.ResolveSubtypeEntityName();
1363
+ }
1364
+ if (!entityName) {
1365
+ return null;
1366
+ }
1367
+ const matchedChild = this.EntityInfo.ChildEntities?.find(c => c.Name.trim().toLowerCase() === entityName.trim().toLowerCase());
1368
+ if (!matchedChild) {
1369
+ throw new Error(`'${entityName}' is not a declared IsA child entity of '${this.EntityInfo.Name}'.`);
1370
+ }
1371
+ const resolvedName = matchedChild.Name;
1372
+ if (!this.EntityInfo.AllowMultipleSubtypes) {
1373
+ // Disjoint hierarchy
1374
+ if (this._childEntity) {
1375
+ if (this._childEntity.EntityInfo.Name.trim().toLowerCase() === resolvedName.trim().toLowerCase()) {
1376
+ return this._childEntity; // Idempotent
1377
+ }
1378
+ throw new Error(`Entity '${this.EntityInfo.Name}' already has an attached child entity of type '${this._childEntity.EntityInfo.Name}', cannot attach '${resolvedName}' (AllowMultipleSubtypes is false).`);
1379
+ }
1380
+ const childProvider = this.ProviderToUse;
1381
+ const childEntity = await childProvider.GetEntityObject(resolvedName, this._contextCurrentUser);
1382
+ // Wire up shared instance chain
1383
+ this.replaceChildParentChain(childEntity);
1384
+ this._childEntity = childEntity;
1385
+ const dirtySnapshots = this.captureChainDirtyState();
1386
+ if (this.PrimaryKey && this.PrimaryKey.HasValue) {
1387
+ const loaded = await childEntity.InnerLoad(this.PrimaryKey);
1388
+ if (!loaded) {
1389
+ this.mirrorSharedKeysToChild(childEntity);
1390
+ }
1391
+ }
1392
+ else {
1393
+ this.mirrorSharedKeysToChild(childEntity);
1394
+ }
1395
+ this.restoreChainDirtyState(dirtySnapshots);
1396
+ // Recursively discover grandchildren if the child is also a parent type
1397
+ if (childEntity.EntityInfo.IsParentType) {
1398
+ await childEntity.EnsureISAChild();
1399
+ }
1400
+ return childEntity;
1401
+ }
1402
+ else {
1403
+ // Overlapping hierarchy (AllowMultipleSubtypes = true)
1404
+ if (!this._childEntities) {
1405
+ this._childEntities = [];
1406
+ }
1407
+ if (!this._childEntities.some(c => c.entityName.trim().toLowerCase() === resolvedName.trim().toLowerCase())) {
1408
+ this._childEntities.push({ entityName: resolvedName });
1409
+ }
1410
+ const childProvider = this.ProviderToUse;
1411
+ const childEntity = await childProvider.GetEntityObject(resolvedName, this._contextCurrentUser);
1412
+ this.replaceChildParentChain(childEntity);
1413
+ const dirtySnapshots = this.captureChainDirtyState();
1414
+ if (this.PrimaryKey && this.PrimaryKey.HasValue) {
1415
+ const loaded = await childEntity.InnerLoad(this.PrimaryKey);
1416
+ if (!loaded) {
1417
+ this.mirrorSharedKeysToChild(childEntity);
1418
+ }
1419
+ }
1420
+ else {
1421
+ this.mirrorSharedKeysToChild(childEntity);
1422
+ }
1423
+ this.restoreChainDirtyState(dirtySnapshots);
1424
+ if (childEntity.EntityInfo.IsParentType) {
1425
+ await childEntity.EnsureISAChild();
1426
+ }
1427
+ return childEntity;
1428
+ }
1429
+ }
1430
+ mirrorSharedKeysToChild(childEntity) {
1431
+ const parentPks = this.EntityInfo.PrimaryKeys;
1432
+ if (!parentPks || parentPks.length === 0)
1433
+ return;
1434
+ for (const pk of parentPks) {
1435
+ const val = this.Get(pk.Name);
1436
+ if (val != null) {
1437
+ childEntity.mirrorSharedKey(pk.Name, val);
1438
+ }
1439
+ }
1440
+ }
1441
+ async evaluateSubtypeSelectorPath(path) {
1442
+ const segments = path.split('.').map(s => s.trim()).filter(Boolean);
1443
+ if (segments.length === 0)
1444
+ return null;
1445
+ let currentEntity = this;
1446
+ let currentEntityInfo = this.EntityInfo;
1447
+ for (let i = 0; i < segments.length - 1; i++) {
1448
+ const fieldName = segments[i];
1449
+ const fieldInfo = currentEntityInfo.Fields.find(f => f.Name.trim().toLowerCase() === fieldName.toLowerCase());
1450
+ if (!fieldInfo) {
1451
+ throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': field '${fieldName}' was not found on entity '${currentEntityInfo.Name}'.`);
1452
+ }
1453
+ const fkValue = currentEntity.Get(fieldInfo.Name);
1454
+ if (fkValue == null || fkValue === '') {
1455
+ return null;
1456
+ }
1457
+ const relatedEntityName = fieldInfo.RelatedEntity;
1458
+ if (!relatedEntityName) {
1459
+ throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': field '${fieldName}' on entity '${currentEntityInfo.Name}' is not a foreign key relationship.`);
1460
+ }
1461
+ const targetEntity = await this.getSubtypePathTargetEntity(relatedEntityName, fkValue);
1462
+ if (!targetEntity) {
1463
+ return null;
1464
+ }
1465
+ currentEntity = targetEntity;
1466
+ currentEntityInfo = targetEntity.EntityInfo;
1467
+ }
1468
+ const terminalSegment = segments[segments.length - 1];
1469
+ const terminalField = currentEntityInfo.Fields.find(f => f.Name.trim().toLowerCase() === terminalSegment.toLowerCase());
1470
+ if (!terminalField) {
1471
+ throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': terminal field '${terminalSegment}' was not found on entity '${currentEntityInfo.Name}'.`);
1472
+ }
1473
+ const terminalValue = currentEntity.Get(terminalField.Name);
1474
+ if (terminalValue == null || typeof terminalValue !== 'string' || terminalValue.trim() === '') {
1475
+ return null;
1476
+ }
1477
+ return terminalValue.trim();
1478
+ }
1479
+ async getSubtypePathTargetEntity(entityName, pkValue) {
1480
+ const cacheKey = `${entityName.trim().toLowerCase()}|${String(pkValue).trim().toLowerCase()}`;
1481
+ if (BaseEntity._subtypeLookupCache.has(cacheKey)) {
1482
+ return BaseEntity._subtypeLookupCache.get(cacheKey) ?? null;
1483
+ }
1484
+ // 1. Check BaseEngineRegistry for loaded cached entities
1485
+ const cachedMatches = BaseEngineRegistry.Instance.FindCachedEntity(entityName);
1486
+ if (cachedMatches && cachedMatches.length > 0) {
1487
+ for (const match of cachedMatches) {
1488
+ const found = match.records.find(r => {
1489
+ const firstPK = r.FirstPrimaryKey; // first-pk-ok: FK target — pkValue is one SubtypeSelector FK column's value
1490
+ if (firstPK) {
1491
+ return String(firstPK.Value).trim().toLowerCase() === String(pkValue).trim().toLowerCase();
1492
+ }
1493
+ return false;
1494
+ });
1495
+ if (found) {
1496
+ BaseEntity._subtypeLookupCache.set(cacheKey, found);
1497
+ return found;
1498
+ }
1499
+ }
1500
+ }
1501
+ // 2. Fall back to loading via provider
1502
+ const provider = this.ProviderToUse;
1503
+ if (!provider?.GetEntityObject) {
1504
+ BaseEntity._subtypeLookupCache.set(cacheKey, null);
1505
+ return null;
1506
+ }
1507
+ try {
1508
+ const targetObj = await provider.GetEntityObject(entityName, this._contextCurrentUser);
1509
+ if (!targetObj) {
1510
+ BaseEntity._subtypeLookupCache.set(cacheKey, null);
1511
+ return null;
1512
+ }
1513
+ let key;
1514
+ if (pkValue instanceof CompositeKey) {
1515
+ key = pkValue;
1516
+ }
1517
+ else {
1518
+ key = new CompositeKey();
1519
+ const pkName = targetObj.FirstPrimaryKey.Name; // first-pk-ok: FK target — pkValue is one SubtypeSelector FK column's value
1520
+ key.KeyValuePairs.push(new KeyValuePair(pkName, pkValue));
1521
+ }
1522
+ const loaded = await targetObj.InnerLoad(key);
1523
+ if (loaded) {
1524
+ BaseEntity._subtypeLookupCache.set(cacheKey, targetObj);
1525
+ return targetObj;
1526
+ }
1527
+ }
1528
+ catch {
1529
+ // On load failure, return null
1530
+ }
1531
+ BaseEntity._subtypeLookupCache.set(cacheKey, null);
1532
+ return null;
1533
+ }
1065
1534
  captureChainDirtyState() {
1066
1535
  const snapshots = [];
1067
1536
  let curr = this;
@@ -1102,6 +1571,10 @@ export class BaseEntity {
1102
1571
  // Replace it with `this`, then walk upward replacing each parent.
1103
1572
  let childParent = childEntity._parentEntity;
1104
1573
  let ourInstance = this;
1574
+ if (!childParent) {
1575
+ childEntity._parentEntity = this;
1576
+ return;
1577
+ }
1105
1578
  while (childParent && ourInstance) {
1106
1579
  // Replace the child's parent reference with our shared instance
1107
1580
  if (childParent === childEntity._parentEntity) {
@@ -1193,6 +1666,8 @@ export class BaseEntity {
1193
1666
  // ignoreNonExistentFields=true remains as a safety net; ownedFieldsFrom already
1194
1667
  // dropped columns that belong to another level of the IS-A chain.
1195
1668
  this.SetMany(this.ownedFieldsFrom(data), true, true, true);
1669
+ // Hydrate is a hydration entry point: keys the source omitted are not-loaded.
1670
+ this.markFieldsOmittedBySourceAsNotLoaded(data);
1196
1671
  }
1197
1672
  /**
1198
1673
  * Columns on `data` that this entity actually defines (by field name or CodeName).
@@ -1346,6 +1821,8 @@ export class BaseEntity {
1346
1821
  throw new Error(`BaseEntity.ConstructUninitializedEntity: ClassFactory could not construct '${entityName}'. ` +
1347
1822
  `Ensure the entity class is registered.`);
1348
1823
  }
1824
+ // Same rebind as GetEntityObject — 1-arg subclasses drop the ClassFactory provider.
1825
+ instance.BindProvider(provider);
1349
1826
  await instance.Config(this.ContextCurrentUser);
1350
1827
  await instance.InitializeParentEntity();
1351
1828
  // Recurse so a *new* peer's own embeds are constructed (required nested
@@ -1434,7 +1911,10 @@ export class BaseEntity {
1434
1911
  }, { provider: this.ProviderToUse, user: this.ActiveUser });
1435
1912
  if (!result.Success || !result.Output?.Success) {
1436
1913
  const detail = result.ErrorMessage ?? result.Output?.ErrorMessage ?? 'unknown error';
1437
- this.registerGraphFailure(detail);
1914
+ // The structured refusal rides alongside the prose so a form can paint the fields a
1915
+ // server-side ValidateAsync named — the same thing a plain save gets from the
1916
+ // GraphQL error's `extensions.validationErrors`.
1917
+ this.registerGraphFailure(detail, 'save', result.Output?.ValidationErrors);
1438
1918
  this.RaiseEvent('graph_save', { Success: false, NodeCount: plan.NodeCount, Error: detail });
1439
1919
  return false;
1440
1920
  }
@@ -1541,7 +2021,7 @@ export class BaseEntity {
1541
2021
  // One `RunViews` for all remaining collections — N declared collections cost one round trip,
1542
2022
  // not N. Params are built per collection so each keeps its own filter and ordering. The key
1543
2023
  // is escaped exactly as RelatedRecordCollection.Load() and the batch loader escape it.
1544
- const parentKeyLiteral = String(this.FirstPrimaryKey?.Value).replace(/'/g, "''");
2024
+ const parentKeyLiteral = String(this.FirstPrimaryKey?.Value).replace(/'/g, "''"); // first-pk-ok: RelatedEntityJoinField is one FK column, so the parent key it holds is single-column by design
1545
2025
  const rv = new RunView(this.ProviderToUse);
1546
2026
  const results = await rv.RunViews(needsDatabase.map(c => ({
1547
2027
  EntityName: c.RelatedEntityName,
@@ -1570,7 +2050,7 @@ export class BaseEntity {
1570
2050
  seedEmbedLoadVisited() {
1571
2051
  const seeded = new Set();
1572
2052
  const name = this.EntityInfo?.Name;
1573
- const pk = this.FirstPrimaryKey?.Value;
2053
+ const pk = this.FirstPrimaryKey?.Value; // first-pk-ok: must equal EmbeddedRecord.LoadEager's `entity:fk` cycle token, and an FK holds one column
1574
2054
  if (name && pk !== null && pk !== undefined && pk !== '') {
1575
2055
  seeded.add(`${name}:${String(pk)}`);
1576
2056
  }
@@ -1853,11 +2333,16 @@ export class BaseEntity {
1853
2333
  *
1854
2334
  * @param message - The failure detail.
1855
2335
  */
1856
- registerGraphFailure(message, operation = 'save') {
2336
+ registerGraphFailure(message, operation = 'save', validationErrors) {
1857
2337
  const result = new BaseEntityResult();
1858
2338
  result.Success = false;
1859
2339
  result.Type = operation === 'delete' ? 'delete' : this.IsSaved ? 'update' : 'create';
1860
2340
  result.Message = message ?? 'Entity graph operation failed';
2341
+ // Rehydrated into real ValidationErrorInfo instances so `LatestResult.Errors` reads exactly as
2342
+ // it does after a local `Validate()` refusal; `[]` when the server sent none.
2343
+ result.Errors = DeserializeValidationErrors(validationErrors);
2344
+ // `message` is the server's CompleteMessage — the same errors, already joined — so say so.
2345
+ result.MessageIncludesErrors = result.Errors.length > 0 && !!message;
1861
2346
  result.StartedAt = new Date();
1862
2347
  result.EndedAt = new Date();
1863
2348
  result.OriginalValues = this.Fields.map(f => ({ FieldName: f.CodeName, Value: f.OldValue }));
@@ -2164,6 +2649,46 @@ export class BaseEntity {
2164
2649
  }
2165
2650
  return this._fieldCache.get(lcase) || null;
2166
2651
  }
2652
+ /**
2653
+ * True when any of the named fields exists on this entity and its current value
2654
+ * differs from the last loaded or saved value.
2655
+ *
2656
+ * This is the boolean form of `GetFieldByName(name)?.Dirty === true`. Prefer it at
2657
+ * call sites that only care whether a column has been edited — pricing, validation,
2658
+ * and "did the user type this" gates — so they do not repeat the optional-chain and
2659
+ * do not treat a missing field as a distinct third state.
2660
+ *
2661
+ * Semantics:
2662
+ * - **Unknown or blank names return `false`.** They are not dirty; they are absent.
2663
+ * Callers that must distinguish "no such field" from "field is clean" should use
2664
+ * {@link GetFieldByName} and inspect the result.
2665
+ * - **Names are case-insensitive and trimmed**, matching {@link GetFieldByName}.
2666
+ * - **Read-only fields are never dirty**, even if their value was overwritten internally.
2667
+ * - **Multiple names are OR'd.** `FieldIsDirty('UnitPrice', 'ProductPriceID')` is true
2668
+ * if either field has been edited. An empty rest list is a single-field check.
2669
+ *
2670
+ * @param fieldName First field to test. A missing/blank name contributes `false`.
2671
+ * @param more Additional field names, each OR'd with the first.
2672
+ * @returns `true` if at least one named field exists and is dirty; otherwise `false`.
2673
+ *
2674
+ * @example
2675
+ * ```ts
2676
+ * // Single field
2677
+ * if (line.FieldIsDirty('UnitPrice')) { ... }
2678
+ *
2679
+ * // Either money column was edited
2680
+ * if (line.FieldIsDirty('UnitPrice', 'ProductPriceID')) { ... }
2681
+ * ```
2682
+ */
2683
+ FieldIsDirty(fieldName, ...more) {
2684
+ const names = more.length === 0 ? [fieldName] : [fieldName, ...more];
2685
+ for (const name of names) {
2686
+ if (this.GetFieldByName(name)?.Dirty === true) {
2687
+ return true;
2688
+ }
2689
+ }
2690
+ return false;
2691
+ }
2167
2692
  /**
2168
2693
  * Convenience method to access a field by code name. This method is case-insensitive and will return null if the field is not found.
2169
2694
  * @param codeName
@@ -2188,26 +2713,25 @@ export class BaseEntity {
2188
2713
  }
2189
2714
  return this._codeNameCache.get(lcase) || null;
2190
2715
  }
2191
- /**
2192
- * Returns true if the object is Dirty, meaning something has changed since it was last saved to the database, and false otherwise. For new records, this will always return true.
2193
- *
2194
- * @remarks
2195
- * Dirtiness rolls up **companions** as well as fields. Without that rollup, a clean parent with
2196
- * three brand-new children reports `Dirty === false`, `_InnerSave` takes its not-dirty early
2197
- * return, and the children are silently never persisted — the save reports success and writes
2198
- * nothing. See {@link EntityCompanion.Dirty}.
2199
- */
2200
2716
  get Dirty() {
2201
- if (!this.IsSaved)
2202
- return true;
2203
- if (this.companionsDirty)
2204
- return true;
2205
- // Raw mode means LoadFromData populated us but no mutation has happened — nothing can be
2206
- // dirty. Avoid hydrating just to check.
2207
- if (!this._fieldsHydrated)
2208
- return this._parentEntity?.Dirty ?? false;
2209
- return this._Fields.some(f => f.Dirty) ||
2210
- (this._parentEntity?.Dirty ?? false);
2717
+ if (this._isCheckingDirty)
2718
+ return false;
2719
+ this._isCheckingDirty = true;
2720
+ try {
2721
+ if (!this.IsSaved)
2722
+ return true;
2723
+ if (this.companionsDirty)
2724
+ return true;
2725
+ // Raw mode means LoadFromData populated us but no mutation has happened — nothing can be
2726
+ // dirty. Avoid hydrating just to check.
2727
+ if (!this._fieldsHydrated)
2728
+ return this._parentEntity?.Dirty ?? false;
2729
+ return this._Fields.some(f => f.Dirty) ||
2730
+ (this._parentEntity?.Dirty ?? false);
2731
+ }
2732
+ finally {
2733
+ this._isCheckingDirty = false;
2734
+ }
2211
2735
  }
2212
2736
  /**
2213
2737
  * Whether any registered companion holds unsaved work.
@@ -2242,7 +2766,7 @@ export class BaseEntity {
2242
2766
  * Helper method to return just the first Primary Key
2243
2767
  */
2244
2768
  get FirstPrimaryKey() {
2245
- return this.PrimaryKeys[0];
2769
+ return this.PrimaryKeys[0]; // first-pk-ok: the accessor itself
2246
2770
  }
2247
2771
  /**
2248
2772
  * Returns true if the record has been loaded from the database, false otherwise. This is useful to check to see if the record is in a "New Record" state or not.
@@ -2276,6 +2800,7 @@ export class BaseEntity {
2276
2800
  if (this.EntityInfo?.HasInactiveFields) {
2277
2801
  this.AssertFieldActiveStatus(FieldName, 'BaseEntity.Set');
2278
2802
  }
2803
+ this.AssertFieldReadable(FieldName);
2279
2804
  this.SetLocal(FieldName, Value);
2280
2805
  }
2281
2806
  }
@@ -2295,6 +2820,33 @@ export class BaseEntity {
2295
2820
  EntityFieldInfo.AssertEntityFieldActiveStatus(fi, caller);
2296
2821
  }
2297
2822
  }
2823
+ /**
2824
+ * Field-level security choke point for the strongly-typed accessor path. Throws when the
2825
+ * acting user may not READ the field.
2826
+ *
2827
+ * Called by `Get()` and `Set()` — every generated typed accessor
2828
+ * (`get Salary() { return this.Get('Salary'); }`) routes through them, so these two sites
2829
+ * cover the whole typed surface. Gated on both READ, because a field a user cannot see is
2830
+ * one they cannot meaningfully address by name at all; update and create denials are
2831
+ * enforced on the write path, where a rejection can name a save rather than a keystroke.
2832
+ *
2833
+ * **Deliberately NOT called by `SetMany`.** That is the hydration and resolver-apply path —
2834
+ * throwing there would break loading a record that merely CONTAINS a restricted column.
2835
+ *
2836
+ * **Fails open when no user resolves.** `ActiveUser` is legitimately null in plenty of
2837
+ * server paths, and a gate that threw there would break unrelated code in ways that look
2838
+ * nothing like field security.
2839
+ *
2840
+ * Framework-internal value machinery (`Dirty`, `Validate`, `GetAll`, hydration, save-SQL
2841
+ * build) reads `EntityField.Value` directly and never routes through here — that exemption
2842
+ * is load-bearing, not an oversight. Do not "fix" it.
2843
+ */
2844
+ AssertFieldReadable(fieldName) {
2845
+ const denied = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedReadFields(u));
2846
+ if (denied?.has(fieldName?.trim().toLowerCase())) {
2847
+ throw new FieldSecurityError(fieldName, this.EntityInfo.Name);
2848
+ }
2849
+ }
2298
2850
  /**
2299
2851
  * Internal helper that sets a field value directly on THIS entity's own Fields array
2300
2852
  * without IS-A routing. Used by Set() for own-fields and for mirroring parent field values.
@@ -2362,6 +2914,10 @@ export class BaseEntity {
2362
2914
  if (this.EntityInfo?.HasInactiveFields) {
2363
2915
  this.AssertFieldActiveStatus(FieldName, 'BaseEntity.Get');
2364
2916
  }
2917
+ // Field security sits BEFORE the raw-mode fast path below, not after: an entity whose
2918
+ // fields are not yet hydrated would otherwise return denied values straight out of _raw
2919
+ // without the gate ever running.
2920
+ this.AssertFieldReadable(FieldName);
2365
2921
  // Raw mode fast path: read directly from the cached data without building EntityField
2366
2922
  // instances. This is the dominant cost in engine warm-loads — generated typed getters
2367
2923
  // (e.g. `get Name() { return this.Get('Name'); }`) flow through here, so a consumer that
@@ -2518,6 +3074,13 @@ export class BaseEntity {
2518
3074
  GetAll(oldValues = false, onlyDirtyFields = false) {
2519
3075
  let obj = {};
2520
3076
  for (let field of this.Fields) {
3077
+ // NotLoaded fields are OMITTED (D-3 decision): their in-memory state is a
3078
+ // construction artifact, and serializing it would launder a default/null into
3079
+ // something downstream code treats as data — the exact masquerade the flag exists
3080
+ // to prevent. Key-absence also propagates the flag naturally: hydrating another
3081
+ // entity from this output re-marks the same fields not-loaded.
3082
+ if (field.NotLoaded)
3083
+ continue;
2521
3084
  if (!onlyDirtyFields || (onlyDirtyFields && field.Dirty)) {
2522
3085
  // Reads field.Value directly — serialization is framework-internal, so it does not
2523
3086
  // (and must not) assert active status. No suppression toggle needed: the assertion no
@@ -2635,7 +3198,7 @@ export class BaseEntity {
2635
3198
  async GetRelatedEntityDataExt(re, filter = null, maxRecords = null) {
2636
3199
  // we need to query the database to get related entity info
2637
3200
  const params = EntityInfo.BuildRelationshipViewParams(this, re, filter, maxRecords);
2638
- const rv = new RunView();
3201
+ const rv = new RunView(this.RunViewProviderToUse);
2639
3202
  const result = await rv.RunView(params, this._contextCurrentUser);
2640
3203
  if (result && result.Success) {
2641
3204
  return {
@@ -2709,6 +3272,9 @@ export class BaseEntity {
2709
3272
  }
2710
3273
  }
2711
3274
  }
3275
+ // _raw IS a hydration source (LoadFromData fast path) — fields whose key it omitted
3276
+ // are not-loaded, not defaulted. See EntityField.NotLoaded.
3277
+ this.markFieldsOmittedBySourceAsNotLoaded(this._raw);
2712
3278
  // Raw data has been promoted into Fields — release the reference so we don't carry
2713
3279
  // duplicate state. Fields hold their own copies, so a frozen source no longer
2714
3280
  // constrains anything from here on.
@@ -2716,6 +3282,22 @@ export class BaseEntity {
2716
3282
  this._rawConverted = null;
2717
3283
  }
2718
3284
  }
3285
+ /**
3286
+ * Marks every field whose key the given HYDRATION SOURCE omitted as {@link EntityField.NotLoaded}.
3287
+ * Called only by the hydration entry points ({@link LoadFromData} both modes, {@link Hydrate},
3288
+ * {@link InnerLoad}) — never by plain {@link SetMany}, which is an incremental mutation API
3289
+ * where omitting a field means "leave it alone," not "this field was never loaded."
3290
+ * Both the field Name and CodeName are checked, matching SetMany's key acceptance.
3291
+ */
3292
+ markFieldsOmittedBySourceAsNotLoaded(source) {
3293
+ if (!source || typeof source !== 'object')
3294
+ return;
3295
+ for (const field of this.Fields) {
3296
+ if (source[field.Name] === undefined && source[field.CodeName] === undefined) {
3297
+ field.MarkNotLoaded();
3298
+ }
3299
+ }
3300
+ }
2719
3301
  /**
2720
3302
  * This method will copy the values from the other entity object into the current one. This is useful for things like cloning a record.
2721
3303
  * 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.
@@ -2729,7 +3311,9 @@ export class BaseEntity {
2729
3311
  for (let field of this.Fields) {
2730
3312
  if (!field.IsPrimaryKey || includePrimaryKeys) {
2731
3313
  const otherField = other.GetFieldByName(field.Name);
2732
- if (otherField) {
3314
+ // Skip fields the SOURCE never loaded (D-3): copying their construction
3315
+ // state would masquerade a default/null as real data on this entity.
3316
+ if (otherField && !otherField.NotLoaded) {
2733
3317
  this.Set(field.Name, otherField.Value);
2734
3318
  if (replaceOldValues) {
2735
3319
  field.ResetOldValue();
@@ -2800,7 +3384,7 @@ export class BaseEntity {
2800
3384
  // Root of an IS-A chain, or a standalone (non-IS-A) entity: generate
2801
3385
  // a single GUID/UUID PK here (SQL Server `uniqueidentifier` /
2802
3386
  // PostgreSQL `uuid`).
2803
- const pk = this.EntityInfo.PrimaryKeys[0];
3387
+ const pk = this.EntityInfo.FirstPrimaryKey; // first-pk-ok: guarded by PrimaryKeys.length === 1 above
2804
3388
  if (!pk.AutoIncrement &&
2805
3389
  pk.IsUniqueIdentifier &&
2806
3390
  !this.Get(pk.Name)) {
@@ -3050,6 +3634,9 @@ export class BaseEntity {
3050
3634
  `Failed to save parent entity '${this._parentEntity.EntityInfo?.Name}': ${detail}`;
3051
3635
  // Surface the parent's field-level errors so the caller can act on them.
3052
3636
  newResult.Errors = parentErrors;
3637
+ // When `detail` was built from `parentErrors` (no parent Message), `Message` already renders
3638
+ // them — say so, or CompleteMessage repeats every one.
3639
+ newResult.MessageIncludesErrors = !parentLatest?.Message && parentErrors.length > 0;
3053
3640
  newResult.OriginalValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.OldValue }; });
3054
3641
  newResult.EndedAt = new Date();
3055
3642
  this.RegisterResultHistoryEntry(newResult);
@@ -3060,6 +3647,8 @@ export class BaseEntity {
3060
3647
  const type = this.IsSaved ? EntityPermissionType.Update : EntityPermissionType.Create;
3061
3648
  const saveSubType = this.IsSaved ? 'update' : 'create';
3062
3649
  this.CheckPermissions(type, true); // this will throw an error and exit out if we don't have permission
3650
+ this.CheckFieldLevelUpdatePermissions(); // field-level security — throws if a dirty field is not updatable by this user
3651
+ this.ApplyFieldLevelCreateSuppression(); // field-level security on INSERT — omits fields, never rejects
3063
3652
  // IS-A disjoint subtype enforcement: on CREATE, ensure parent record
3064
3653
  // isn't already claimed by another child type (e.g., can't create Meeting
3065
3654
  // if a Publication already exists with the same Product ID).
@@ -3279,6 +3868,14 @@ export class BaseEntity {
3279
3868
  // does not own (e.g. OrderHeader on Event Order Line). Keep only columns
3280
3869
  // this entity defines, and ignore anything leftover.
3281
3870
  this.SetMany(this.ownedFieldsFrom(fieldData), true, true, true);
3871
+ // finalizeSave re-hydrates from the save RESPONSE — a hydration source. Keys it
3872
+ // omitted (e.g. fields the server stripped for field security) must be marked
3873
+ // not-loaded, or the defaults init() just produced would masquerade as confirmed
3874
+ // values and be resent on the NEXT save (the create/update-response corner: a
3875
+ // trigger/system adjustment or concurrent change would be silently stomped).
3876
+ if (typeof fieldData === 'object' && !Array.isArray(fieldData)) {
3877
+ this.markFieldsOmittedBySourceAsNotLoaded(fieldData);
3878
+ }
3282
3879
  this._everSaved = true; // Mark as saved after successful save
3283
3880
  const result = this.LatestResult;
3284
3881
  if (result)
@@ -3398,6 +3995,158 @@ export class BaseEntity {
3398
3995
  else
3399
3996
  return bAllowed;
3400
3997
  }
3998
+ /**
3999
+ * Field-level security on the write path: rejects a save that modifies a field this user
4000
+ * has no update permission on.
4001
+ *
4002
+ * ENFORCEMENT LAYER — read this before treating it as the security boundary. `BaseEntity`
4003
+ * also runs in the browser, where this guard is trivially bypassable. The AUTHORITATIVE
4004
+ * check is the server-side execution of this same code: the MJServer mutation resolver
4005
+ * re-instantiates the entity and re-runs Save on the server, where the client cannot reach
4006
+ * it. The client-side occurrence is UX and defense-in-depth — fail fast with a clear
4007
+ * message before a network round-trip — and must never be relied on alone.
4008
+ *
4009
+ * UPDATE rejects; CREATE does not — see {@link ApplyFieldLevelCreateSuppression}.
4010
+ *
4011
+ * Note this checks DIRTY fields only. CLIENT-side that is safe on its own: nothing ever
4012
+ * nulls a restricted value in memory, so a field the user cannot see was never loaded as
4013
+ * null, is not dirty, and an unrelated edit saves cleanly with the restricted column
4014
+ * keeping its stored value.
4015
+ *
4016
+ * SERVER-side, dirty-only is safe only because `ResolverBase.UpdateRecord` guarantees the
4017
+ * entity was hydrated FROM THE DATABASE on every FLS entity. Two distinct resolver behaviours
4018
+ * carry that premise, and BOTH are load-bearing:
4019
+ *
4020
+ * 1. `StripDeniedReadFieldsFromClientInput` removes client-sent values for fields the caller
4021
+ * cannot READ, which `SetMany` would otherwise make genuinely dirty with fabricated data.
4022
+ * 2. `entityInfo.EnableFieldLevelSecurity` forces the truth-load branch, so the entity's
4023
+ * non-dirty baseline is the real stored row rather than the client's `OldValues___`.
4024
+ *
4025
+ * (2) is not redundant with (1). A value arriving through `LoadFromData` is recorded by the
4026
+ * EntityField setter as the field's INITIAL value, so it is not dirty — and this check would
4027
+ * never see it, while `GenerateSaveSQL` sends it anyway (it filters on `NotLoaded`, never on
4028
+ * `Dirty`). Without the forced truth-load, a caller with Read Allow + Update Deny — the
4029
+ * canonical FLS configuration, and one that leaves (1) with nothing to strip — could write an
4030
+ * update-denied field just by pinning its value in `OldValues___` and never naming it in the
4031
+ * mutation. If you are considering relaxing that branch condition, this check is what breaks.
4032
+ *
4033
+ * The refusal names the missing permission when the caller can READ the field, and falls back
4034
+ * to the ambiguous "does not exist or you do not have access" wording when they cannot. See
4035
+ * {@link FieldSecurityWriteDenialMessage} for why that split discloses nothing.
4036
+ */
4037
+ CheckFieldLevelUpdatePermissions() {
4038
+ if (!this.IsSaved) {
4039
+ return; // INSERT — handled by ApplyFieldLevelCreateSuppression, which never rejects
4040
+ }
4041
+ const denied = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedUpdateFields(u));
4042
+ if (!denied) {
4043
+ return;
4044
+ }
4045
+ // Resolved once, and only if we are actually going to reject: the wording depends on
4046
+ // whether the caller can READ the field they were refused a write on.
4047
+ let deniedRead;
4048
+ for (const field of this.Fields) {
4049
+ const key = field.Name.trim().toLowerCase();
4050
+ if (field.Dirty && denied.has(key)) {
4051
+ LogDebug(`[FieldSecurity] Rejected save on '${this.EntityInfo.Name}': ` +
4052
+ `field '${field.Name}' is not updatable by this user`);
4053
+ // A field the caller can READ gets the real reason. Both facts the ambiguous
4054
+ // wording protects — that the column exists, and that it is restricted for them —
4055
+ // are already theirs, so withholding the reason only tells someone a field whose
4056
+ // values they are looking at might not exist.
4057
+ //
4058
+ // A field they CANNOT read keeps the ambiguous wording. Not hypothetical: SetMany
4059
+ // deliberately skips the readability assertion (hydration / resolver-apply path),
4060
+ // so server-side code can dirty a read-denied field and land here.
4061
+ if (deniedRead === undefined) {
4062
+ deniedRead = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedReadFields(u));
4063
+ }
4064
+ throw deniedRead?.has(key)
4065
+ ? new FieldSecurityError(field.Name, this.EntityInfo.Name)
4066
+ : FieldSecurityError.WriteDenial(field.Name, this.EntityInfo.Name);
4067
+ }
4068
+ }
4069
+ }
4070
+ /**
4071
+ * Field-level security on the INSERT path: marks the fields this user may not supply so the
4072
+ * save omits them and each column takes its database default.
4073
+ *
4074
+ * **This never rejects, and that is deliberate.** Rejecting would be inconsistent with the
4075
+ * read path (a denied field is simply absent, not an error) and would leak information — an
4076
+ * error naming `Salary` confirms the field exists and is restricted, which the ambiguous
4077
+ * denial wording exists to prevent. Silently defaulting is also what an unrestricted user
4078
+ * gets by leaving the field blank, so a restricted user creating a record ends up with the
4079
+ * same record SHAPE rather than a failure.
4080
+ *
4081
+ * The cost is that a user who supplies a value for a create-denied field gets no feedback
4082
+ * that it was dropped, which is why the drop is logged and why the admin UI should not
4083
+ * render the field at all.
4084
+ *
4085
+ * Runs on every save (clearing prior marks first) because the answer depends on the acting
4086
+ * user, and one entity object can be saved by different users over its lifetime.
4087
+ */
4088
+ ApplyFieldLevelCreateSuppression() {
4089
+ for (const field of this.Fields) {
4090
+ field.SetCreateSuppressed(false);
4091
+ }
4092
+ if (this.IsSaved) {
4093
+ return; // UPDATE — CheckFieldLevelUpdatePermissions owns that path
4094
+ }
4095
+ const denied = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedCreateFields(u));
4096
+ if (!denied) {
4097
+ return;
4098
+ }
4099
+ const suppressed = [];
4100
+ for (const field of this.Fields) {
4101
+ if (denied.has(field.Name.trim().toLowerCase())) {
4102
+ field.SetCreateSuppressed(true);
4103
+ suppressed.push(field.Name);
4104
+ }
4105
+ }
4106
+ if (suppressed.length > 0) {
4107
+ LogDebug(`[FieldSecurity] Create on '${this.EntityInfo.Name}': ` +
4108
+ `omitted field(s) ${suppressed.join(', ')}; each column takes its default`);
4109
+ }
4110
+ }
4111
+ /**
4112
+ * The denied-field set for the acting user, or null when field security does not apply —
4113
+ * the entity has it switched off, no user resolves, or the user is denied nothing.
4114
+ *
4115
+ * Returning null rather than an empty Set lets callers skip their loop entirely, and keeps
4116
+ * the three cheap short-circuits in one place instead of repeated at each gate.
4117
+ */
4118
+ deniedFieldsForActiveUser(select) {
4119
+ if (!this.EntityInfo?.EnableFieldLevelSecurity) {
4120
+ return null; // one boolean for the overwhelming majority of entities
4121
+ }
4122
+ const u = this.resolveActiveUserOrNull();
4123
+ if (!u) {
4124
+ return null; // no user resolves — fail open, see AssertFieldReadable
4125
+ }
4126
+ const denied = select(u);
4127
+ return denied.size > 0 ? denied : null;
4128
+ }
4129
+ /**
4130
+ * {@link ActiveUser}, but null instead of throwing when no provider is configured to resolve
4131
+ * one from.
4132
+ *
4133
+ * `ActiveUser` ends in `Metadata.Provider.CurrentUser`, which throws a TypeError when there
4134
+ * is no global provider — during early boot, in tests, or in any context that never
4135
+ * configured one. That was harmless while only the save path consulted it, but `Get()` and
4136
+ * `Set()` now do on every access to an FLS-enabled entity, so an unresolvable provider would
4137
+ * turn an ordinary read into a crash.
4138
+ *
4139
+ * "No provider to ask" is the same answer as "no user" for this purpose, and field security
4140
+ * fails open on both.
4141
+ */
4142
+ resolveActiveUserOrNull() {
4143
+ try {
4144
+ return this.ActiveUser ?? null;
4145
+ }
4146
+ catch {
4147
+ return null;
4148
+ }
4149
+ }
3401
4150
  ThrowPermissionError(u, type, additionalInfoMessage) {
3402
4151
  throw new Error(`User: ${u.Name} (ID: ${u.ID}, Email: ${u.Email})
3403
4152
  Does NOT have permission to ${EntityPermissionType[type]} ${this.EntityInfo.Name} records.
@@ -3465,6 +4214,10 @@ export class BaseEntity {
3465
4214
  f.ResetNeverSetFlag();
3466
4215
  }
3467
4216
  this.SetMany(data, false, true, true); // don't ignore non-existent fields, but DO replace old values
4217
+ // InnerLoad is a hydration entry point: any field the provider's row omitted (e.g.
4218
+ // a client-side load whose server response stripped read-denied fields) is
4219
+ // not-loaded, so its constructor state never masquerades as data on the next save.
4220
+ this.markFieldsOmittedBySourceAsNotLoaded(data);
3468
4221
  if (EntityRelationshipsToLoad) {
3469
4222
  for (let relationship of EntityRelationshipsToLoad) {
3470
4223
  if (data[relationship]) {
@@ -3626,6 +4379,10 @@ export class BaseEntity {
3626
4379
  // Hits when: subsequent LoadFromData call on an already-loaded instance, IS-A entity
3627
4380
  // (parent or child), or non-plain-object input. Preserves original semantics exactly.
3628
4381
  this.SetMany(data, true, _replaceOldValues, true); // ignore non-existent fields, but DO replace old values based on the provided param
4382
+ if (isPlainObject) {
4383
+ // LoadFromData is a hydration entry point: keys the source omitted are not-loaded.
4384
+ this.markFieldsOmittedBySourceAsNotLoaded(data);
4385
+ }
3629
4386
  // now, check to see if we have the primary key set, if so, we should consider ourselves
3630
4387
  // loaded from the database and set the _recordLoaded flag to true along with the _everSaved flag
3631
4388
  if (this.PrimaryKeys && this.PrimaryKeys.length > 0) {
@@ -3671,40 +4428,45 @@ export class BaseEntity {
3671
4428
  await this.DeserializeCompanions(payloads);
3672
4429
  }
3673
4430
  }
3674
- /**
3675
- * This method is used automatically within Save() and is used to determine if the state of the object is valid relative to the validation rules that are defined in metadata. In addition, sub-classes can
3676
- * override or wrap this base class method to add other logic for validation.
3677
- *
3678
- * @returns ValidationResult The validation result
3679
- */
3680
4431
  Validate() {
3681
- const result = new ValidationResult();
3682
- result.Success = true; // start off with assumption of success, if any field fails, we'll set this to false
3683
- // IS-A composition: validate parent entity first to collect all chain errors
3684
- if (this._parentEntity) {
3685
- const parentResult = this._parentEntity.Validate();
3686
- if (!parentResult.Success) {
3687
- result.Success = false;
3688
- parentResult.Errors.forEach(err => result.Errors.push(err));
4432
+ if (this._isValidating) {
4433
+ const emptyResult = new ValidationResult();
4434
+ emptyResult.Success = true;
4435
+ return emptyResult;
4436
+ }
4437
+ this._isValidating = true;
4438
+ try {
4439
+ const result = new ValidationResult();
4440
+ result.Success = true; // start off with assumption of success, if any field fails, we'll set this to false
4441
+ // IS-A composition: validate parent entity first to collect all chain errors
4442
+ if (this._parentEntity) {
4443
+ const parentResult = this._parentEntity.Validate();
4444
+ if (!parentResult.Success) {
4445
+ result.Success = false;
4446
+ parentResult.Errors.forEach(err => result.Errors.push(err));
4447
+ }
4448
+ }
4449
+ // Validate own fields — for IS-A entities, skip parent field mirrors since
4450
+ // those are validated via _parentEntity above
4451
+ for (let field of this.Fields) {
4452
+ if (this._parentEntityFieldNames?.has(field.Name))
4453
+ continue; // skip parent field mirrors — authoritative validation is on _parentEntity
4454
+ const err = field.Validate();
4455
+ err.Errors.forEach(element => {
4456
+ result.Errors.push(element);
4457
+ });
4458
+ result.Success = result.Success && err.Success; // if any field fails, we fail, but keep going to get all of the validation messages
3689
4459
  }
4460
+ // Companions validate LAST but still BEFORE any write, over their complete state including
4461
+ // pending removals. That ordering is what lets a cross-child invariant — "debits must equal
4462
+ // credits", "a confirmed order must have lines" — be enforced against the whole graph rather
4463
+ // than discovered halfway through persisting it.
4464
+ this.validateCompanions(result);
4465
+ return result;
3690
4466
  }
3691
- // Validate own fields — for IS-A entities, skip parent field mirrors since
3692
- // those are validated via _parentEntity above
3693
- for (let field of this.Fields) {
3694
- if (this._parentEntityFieldNames?.has(field.Name))
3695
- continue; // skip parent field mirrors — authoritative validation is on _parentEntity
3696
- const err = field.Validate();
3697
- err.Errors.forEach(element => {
3698
- result.Errors.push(element);
3699
- });
3700
- result.Success = result.Success && err.Success; // if any field fails, we fail, but keep going to get all of the validation messages
4467
+ finally {
4468
+ this._isValidating = false;
3701
4469
  }
3702
- // Companions validate LAST but still BEFORE any write, over their complete state including
3703
- // pending removals. That ordering is what lets a cross-child invariant — "debits must equal
3704
- // credits", "a confirmed order must have lines" — be enforced against the whole graph rather
3705
- // than discovered halfway through persisting it.
3706
- this.validateCompanions(result);
3707
- return result;
3708
4470
  }
3709
4471
  /**
3710
4472
  * Fans synchronous validation out to every registered companion.
@@ -3930,6 +4692,9 @@ export class BaseEntity {
3930
4692
  `Failed to delete parent entity '${this._parentEntity.EntityInfo?.Name}': ${detail}`;
3931
4693
  // Surface the parent's field-level errors so the caller can act on them.
3932
4694
  newResult.Errors = parentErrors;
4695
+ // When `detail` was built from `parentErrors` (no parent Message), `Message` already renders
4696
+ // them — say so, or CompleteMessage repeats every one.
4697
+ newResult.MessageIncludesErrors = !parentLatest?.Message && parentErrors.length > 0;
3933
4698
  newResult.OriginalValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.OldValue }; });
3934
4699
  newResult.EndedAt = new Date();
3935
4700
  this.RegisterResultHistoryEntry(newResult);
@@ -4020,11 +4785,12 @@ export class BaseEntity {
4020
4785
  if (childEntities.length === 0) {
4021
4786
  return { HasChildren: false, ChildEntityName: '' };
4022
4787
  }
4023
- // Use RunView to check each child entity for records with our PK
4024
- const rv = new RunView();
4788
+ // Use RunView on this instance's provider a host RunView cannot see
4789
+ // uncommitted child rows on a graph-scoped connection.
4790
+ const rv = new RunView(this.RunViewProviderToUse);
4025
4791
  const pkValue = this.PrimaryKey.Values();
4026
4792
  for (const childEntity of childEntities) {
4027
- const pkField = childEntity.PrimaryKeys[0];
4793
+ const pkField = childEntity.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
4028
4794
  if (!pkField)
4029
4795
  continue;
4030
4796
  const result = await rv.RunView({
@@ -4083,21 +4849,21 @@ export class BaseEntity {
4083
4849
  if (!entityInfo) {
4084
4850
  return { LeafEntityName: entityName, IsLeaf: true };
4085
4851
  }
4086
- return BaseEntity.ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser);
4852
+ return BaseEntity.ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser, md);
4087
4853
  }
4088
4854
  /**
4089
4855
  * Internal recursive helper for leaf entity resolution.
4090
4856
  * Walks down the child hierarchy until no more children are found.
4091
4857
  */
4092
- static async ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser) {
4858
+ static async ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser, provider) {
4093
4859
  const childEntities = entityInfo.ChildEntities;
4094
4860
  if (childEntities.length === 0) {
4095
4861
  return { LeafEntityName: entityInfo.Name, IsLeaf: true };
4096
4862
  }
4097
- const rv = new RunView();
4863
+ const rv = new RunView((provider ?? BaseEntity.Provider));
4098
4864
  const pkValue = primaryKey.Values();
4099
4865
  for (const child of childEntities) {
4100
- const childPK = child.PrimaryKeys[0];
4866
+ const childPK = child.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
4101
4867
  if (!childPK)
4102
4868
  continue;
4103
4869
  const result = await rv.RunView({
@@ -4109,7 +4875,7 @@ export class BaseEntity {
4109
4875
  }, contextUser);
4110
4876
  if (result?.Success && result.Results?.length > 0) {
4111
4877
  // Found a child — recurse to see if there's an even more specific leaf
4112
- return BaseEntity.ResolveLeafEntityRecursive(child, primaryKey, contextUser);
4878
+ return BaseEntity.ResolveLeafEntityRecursive(child, primaryKey, contextUser, provider);
4113
4879
  }
4114
4880
  }
4115
4881
  // No child found — this entity IS the leaf
@@ -4142,16 +4908,19 @@ export class BaseEntity {
4142
4908
  const pkValue = this.PrimaryKey.Values();
4143
4909
  if (!pkValue)
4144
4910
  return;
4145
- // Build all sibling queries and execute them in a single batch
4146
- const rv = new RunView();
4147
- const validSiblings = siblingChildEntities.filter(s => s.PrimaryKeys[0]);
4911
+ // Build all sibling queries and execute them in a single batch on
4912
+ // this instance's provider so an uncommitted sibling on the same
4913
+ // graph connection is visible (host RunView would miss it).
4914
+ const rv = new RunView(this.RunViewProviderToUse);
4915
+ // first-pk-ok: IS-A siblings share the parent's single key
4916
+ const validSiblings = siblingChildEntities.filter(s => s.FirstPrimaryKey); // first-pk-ok: IS-A siblings share the parent's single key
4148
4917
  if (validSiblings.length === 0)
4149
4918
  return;
4150
4919
  const viewParams = validSiblings.map(sibling => ({
4151
4920
  EntityName: sibling.Name,
4152
- ExtraFilter: `${sibling.PrimaryKeys[0].Name} = '${pkValue}'`,
4921
+ ExtraFilter: `${sibling.FirstPrimaryKey.Name} = '${pkValue}'`, // first-pk-ok: IS-A shared key
4153
4922
  ResultType: 'simple',
4154
- Fields: [sibling.PrimaryKeys[0].Name],
4923
+ Fields: [sibling.FirstPrimaryKey.Name], // first-pk-ok: IS-A shared key
4155
4924
  MaxRows: 1
4156
4925
  }));
4157
4926
  const results = await rv.RunViews(viewParams, this._contextCurrentUser);
@@ -4218,9 +4987,20 @@ export class BaseEntity {
4218
4987
  if (!f) {
4219
4988
  return null;
4220
4989
  }
4221
- else {
4222
- return this.Get(f.Name);
4990
+ // Field security: the name field is an ordinary field and can be denied like any other.
4991
+ // `Get()` THROWS for a denied field, and this method runs AUTOMATICALLY after every
4992
+ // Load / LoadFromData / Save via CacheRecordName — so an unguarded read here does not
4993
+ // hide a name, it makes the record fail to load at all, with a message about the name
4994
+ // field that reads like the record itself is broken.
4995
+ //
4996
+ // Returning null is the same answer callers already handle for "this entity has no name
4997
+ // field", and every one of them degrades to the primary key. It also keeps a denied name
4998
+ // OUT of the provider's record-name cache, which is keyed by entity + primary key and NOT
4999
+ // by user — caching it would leak it to the next caller.
5000
+ if (!this.EntityInfo.IsFieldReadableByUser(f.Name, this.ActiveUser)) {
5001
+ return null;
4223
5002
  }
5003
+ return this.Get(f.Name);
4224
5004
  }
4225
5005
  /**
4226
5006
  * Static Utility method to get RecordChanges for a given entityName/KeyValuePair combination
@@ -4444,7 +5224,7 @@ export class BaseEntity {
4444
5224
  LogError(`BaseEntity.GetDescendants(): No recursive foreign key field found on entity ${this.EntityInfo?.Name}`);
4445
5225
  return [];
4446
5226
  }
4447
- const pkName = this.FirstPrimaryKey?.Name ?? 'ID';
5227
+ const pkName = this.FirstPrimaryKey.Name; // first-pk-ok: getRecursiveForeignKeyField already gates hierarchy traversal to single-column keys
4448
5228
  const rootId = this.Get(pkName);
4449
5229
  if (!rootId)
4450
5230
  return [];
@@ -4453,7 +5233,7 @@ export class BaseEntity {
4453
5233
  const filter = maxDepth != null
4454
5234
  ? `${rootFieldName} = '${rootId}' AND ${depthFieldName} <= ${maxDepth}`
4455
5235
  : `${rootFieldName} = '${rootId}'`;
4456
- const rv = new RunView();
5236
+ const rv = new RunView(this.RunViewProviderToUse);
4457
5237
  const result = await rv.RunView({
4458
5238
  EntityName: this.EntityInfo.Name,
4459
5239
  ExtraFilter: filter,
@@ -4472,7 +5252,7 @@ export class BaseEntity {
4472
5252
  LogError(`BaseEntity.GetAncestors(): No recursive foreign key field found on entity ${this.EntityInfo?.Name}`);
4473
5253
  return [];
4474
5254
  }
4475
- const pkName = this.FirstPrimaryKey?.Name ?? 'ID';
5255
+ const pkName = this.FirstPrimaryKey.Name; // first-pk-ok: getRecursiveForeignKeyField already gates hierarchy traversal to single-column keys
4476
5256
  const currentId = this.Get(pkName);
4477
5257
  const pathFieldName = `${fkField.Name}Path`;
4478
5258
  const depthFieldName = `${fkField.Name}Depth`;
@@ -4482,7 +5262,7 @@ export class BaseEntity {
4482
5262
  const rawIds = path.split('/').filter(id => id.length > 0 && id !== currentId);
4483
5263
  if (rawIds.length === 0)
4484
5264
  return [];
4485
- const rv = new RunView();
5265
+ const rv = new RunView(this.RunViewProviderToUse);
4486
5266
  const idList = rawIds.map(id => `'${id}'`).join(',');
4487
5267
  const result = await rv.RunView({
4488
5268
  EntityName: this.EntityInfo.Name,
@@ -4502,11 +5282,11 @@ export class BaseEntity {
4502
5282
  LogError(`BaseEntity.GetChildren(): No recursive foreign key field found on entity ${this.EntityInfo?.Name}`);
4503
5283
  return [];
4504
5284
  }
4505
- const pkName = this.FirstPrimaryKey?.Name ?? 'ID';
5285
+ const pkName = this.FirstPrimaryKey.Name; // first-pk-ok: getRecursiveForeignKeyField already gates hierarchy traversal to single-column keys
4506
5286
  const currentId = this.Get(pkName);
4507
5287
  if (!currentId)
4508
5288
  return [];
4509
- const rv = new RunView();
5289
+ const rv = new RunView(this.RunViewProviderToUse);
4510
5290
  const result = await rv.RunView({
4511
5291
  EntityName: this.EntityInfo.Name,
4512
5292
  ExtraFilter: `${fkField.Name} = '${currentId}'`,