@memberjunction/core 6.2.0-edge.0 → 6.2.0-edge.2

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 (124) hide show
  1. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +180 -0
  2. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
  3. package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts +28 -0
  4. package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts.map +1 -1
  5. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts +76 -0
  6. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts.map +1 -1
  7. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts +14 -0
  8. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts.map +1 -1
  9. package/dist/generic/QueryCache.d.ts +17 -0
  10. package/dist/generic/QueryCache.d.ts.map +1 -1
  11. package/dist/generic/QueryCache.js +25 -5
  12. package/dist/generic/QueryCache.js.map +1 -1
  13. package/dist/generic/RegisterForStartup.d.ts +5 -5
  14. package/dist/generic/RegisterForStartup.js +12 -12
  15. package/dist/generic/applicationInfo.d.ts +4 -2
  16. package/dist/generic/applicationInfo.d.ts.map +1 -1
  17. package/dist/generic/applicationInfo.js +13 -9
  18. package/dist/generic/applicationInfo.js.map +1 -1
  19. package/dist/generic/baseEngine.d.ts +9 -1
  20. package/dist/generic/baseEngine.d.ts.map +1 -1
  21. package/dist/generic/baseEngine.js +13 -3
  22. package/dist/generic/baseEngine.js.map +1 -1
  23. package/dist/generic/baseEngineRegistry.d.ts +22 -9
  24. package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
  25. package/dist/generic/baseEngineRegistry.js +43 -25
  26. package/dist/generic/baseEngineRegistry.js.map +1 -1
  27. package/dist/generic/baseEntity.d.ts +422 -28
  28. package/dist/generic/baseEntity.d.ts.map +1 -1
  29. package/dist/generic/baseEntity.js +930 -202
  30. package/dist/generic/baseEntity.js.map +1 -1
  31. package/dist/generic/baseInfo.d.ts +12 -0
  32. package/dist/generic/baseInfo.d.ts.map +1 -1
  33. package/dist/generic/baseInfo.js +22 -1
  34. package/dist/generic/baseInfo.js.map +1 -1
  35. package/dist/generic/column-descriptors.d.ts +25 -5
  36. package/dist/generic/column-descriptors.d.ts.map +1 -1
  37. package/dist/generic/column-descriptors.js +45 -5
  38. package/dist/generic/column-descriptors.js.map +1 -1
  39. package/dist/generic/compositeKey.d.ts +1 -1
  40. package/dist/generic/compositeKey.js +2 -2
  41. package/dist/generic/data-table.js +2 -2
  42. package/dist/generic/data-table.js.map +1 -1
  43. package/dist/generic/databaseProviderBase.d.ts +13 -7
  44. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  45. package/dist/generic/databaseProviderBase.js +20 -11
  46. package/dist/generic/databaseProviderBase.js.map +1 -1
  47. package/dist/generic/entityConfiguration.d.ts +1 -0
  48. package/dist/generic/entityConfiguration.d.ts.map +1 -1
  49. package/dist/generic/entityConfiguration.js.map +1 -1
  50. package/dist/generic/entityInfo.d.ts +199 -39
  51. package/dist/generic/entityInfo.d.ts.map +1 -1
  52. package/dist/generic/entityInfo.js +351 -78
  53. package/dist/generic/entityInfo.js.map +1 -1
  54. package/dist/generic/entitySubtypeResolver.d.ts +51 -5
  55. package/dist/generic/entitySubtypeResolver.d.ts.map +1 -1
  56. package/dist/generic/entitySubtypeResolver.js +50 -3
  57. package/dist/generic/entitySubtypeResolver.js.map +1 -1
  58. package/dist/generic/filters/compositeFilter.d.ts +19 -19
  59. package/dist/generic/filters/compositeFilter.js +60 -60
  60. package/dist/generic/graphqlTypeNames.d.ts +6 -0
  61. package/dist/generic/graphqlTypeNames.d.ts.map +1 -1
  62. package/dist/generic/graphqlTypeNames.js +18 -6
  63. package/dist/generic/graphqlTypeNames.js.map +1 -1
  64. package/dist/generic/interfaces.d.ts +13 -4
  65. package/dist/generic/interfaces.d.ts.map +1 -1
  66. package/dist/generic/interfaces.js +3 -3
  67. package/dist/generic/interfaces.js.map +1 -1
  68. package/dist/generic/logging.d.ts +2 -0
  69. package/dist/generic/logging.d.ts.map +1 -1
  70. package/dist/generic/logging.js +7 -3
  71. package/dist/generic/logging.js.map +1 -1
  72. package/dist/generic/metadata.d.ts +1 -1
  73. package/dist/generic/metadata.js +3 -3
  74. package/dist/generic/providerBase.d.ts +89 -8
  75. package/dist/generic/providerBase.d.ts.map +1 -1
  76. package/dist/generic/providerBase.js +180 -45
  77. package/dist/generic/providerBase.js.map +1 -1
  78. package/dist/generic/queryResultEnricher.d.ts +2 -0
  79. package/dist/generic/queryResultEnricher.d.ts.map +1 -1
  80. package/dist/generic/queryResultEnricher.js +5 -1
  81. package/dist/generic/queryResultEnricher.js.map +1 -1
  82. package/dist/generic/querySQLFilters.d.ts +4 -0
  83. package/dist/generic/querySQLFilters.d.ts.map +1 -1
  84. package/dist/generic/querySQLFilters.js +10 -2
  85. package/dist/generic/querySQLFilters.js.map +1 -1
  86. package/dist/generic/relatedRecordCollection.d.ts +2 -0
  87. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  88. package/dist/generic/relatedRecordCollection.js +5 -1
  89. package/dist/generic/relatedRecordCollection.js.map +1 -1
  90. package/dist/generic/remoteEventRow.d.ts +55 -0
  91. package/dist/generic/remoteEventRow.d.ts.map +1 -0
  92. package/dist/generic/remoteEventRow.js +110 -0
  93. package/dist/generic/remoteEventRow.js.map +1 -0
  94. package/dist/generic/remoteOperationDispatch.d.ts +2 -0
  95. package/dist/generic/remoteOperationDispatch.d.ts.map +1 -1
  96. package/dist/generic/remoteOperationDispatch.js +5 -1
  97. package/dist/generic/remoteOperationDispatch.js.map +1 -1
  98. package/dist/generic/runQuerySQLFilterImplementations.d.ts +8 -0
  99. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  100. package/dist/generic/runQuerySQLFilterImplementations.js +20 -4
  101. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  102. package/dist/generic/schemaHydration.d.ts +8 -0
  103. package/dist/generic/schemaHydration.d.ts.map +1 -1
  104. package/dist/generic/schemaHydration.js +21 -5
  105. package/dist/generic/schemaHydration.js.map +1 -1
  106. package/dist/generic/securityInfo.d.ts +11 -9
  107. package/dist/generic/securityInfo.d.ts.map +1 -1
  108. package/dist/generic/securityInfo.js +31 -27
  109. package/dist/generic/securityInfo.js.map +1 -1
  110. package/dist/generic/telemetryManager.d.ts +16 -0
  111. package/dist/generic/telemetryManager.d.ts.map +1 -1
  112. package/dist/generic/telemetryManager.js +52 -20
  113. package/dist/generic/telemetryManager.js.map +1 -1
  114. package/dist/generic/transactionGroup.d.ts +1 -1
  115. package/dist/generic/transactionGroup.js +4 -4
  116. package/dist/generic/transactionGroup.js.map +1 -1
  117. package/dist/index.d.ts +1 -0
  118. package/dist/index.d.ts.map +1 -1
  119. package/dist/index.js +1 -0
  120. package/dist/index.js.map +1 -1
  121. package/dist/views/viewInfo.d.ts +2 -2
  122. package/dist/views/viewInfo.js +13 -13
  123. package/dist/views/viewInfo.js.map +1 -1
  124. package/package.json +3 -3
@@ -8,7 +8,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
8
8
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
9
  };
10
10
  var EntityField_1;
11
- import { DeserializeValidationErrors, IsMemberOverridden, MJEventType, MJGlobal, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, WarningManager, ComputeContentHashAsync } from '@memberjunction/global';
11
+ import { DeserializeValidationErrors, IsMemberOverridden, MJEventType, MJGlobal, NormalizeUUID, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, 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';
@@ -17,7 +17,7 @@ import { IsPermittedImageFieldValue, IsValidCssColor, TryParseJsonText } from '.
17
17
  import { EntityDeleteOptions, EntitySaveOptions, ProviderType } from './interfaces.js';
18
18
  import { Metadata } from './metadata.js';
19
19
  import { RunView } from '../views/runView.js';
20
- import { LogDebug, LogError } from './logging.js';
20
+ import { LogDebug, LogError, LogStatusEx } from './logging.js';
21
21
  import { CompositeKey, KeyValuePair } from './compositeKey.js';
22
22
  import { RelatedRecordCollection } from './relatedRecordCollection.js';
23
23
  import { COMPANION_PAYLOAD_KEY } from './entityCompanion.js';
@@ -26,6 +26,18 @@ import { EntitySavePlan, ExecuteEntitySavePlan } from './entitySavePlan.js';
26
26
  import { BaseRemotableOperation } from './baseRemotableOperation.js';
27
27
  import { SAVE_ENTITY_GRAPH_OPERATION_KEY, } from './saveEntityGraphOperation.js';
28
28
  import { finalize, firstValueFrom, from, of, shareReplay, Subject, switchMap } from 'rxjs';
29
+ /**
30
+ * Strips the trailing spaces SQL Server and PostgreSQL pad fixed-width string columns with. Only
31
+ * spaces: tabs and other whitespace are data. A loop rather than `/ +$/`, which backtracks
32
+ * quadratically on a long run of spaces that doesn't end the string.
33
+ */
34
+ function trimTrailingSpaces(value) {
35
+ let end = value.length;
36
+ while (end > 0 && value.charCodeAt(end - 1) === 32) {
37
+ end--;
38
+ }
39
+ return end === value.length ? value : value.slice(0, end);
40
+ }
29
41
  /**
30
42
  * Represents a field in an instance of the BaseEntity class. This class is used to store the value of the field, dirty state, as well as other run-time information about the field. The class encapsulates the underlying field metadata and exposes some of the more commonly
31
43
  * used properties from the entity field metadata.
@@ -70,7 +82,7 @@ let EntityField = class EntityField {
70
82
  * entities — their fields legitimately hold metadata defaults for INSERT.
71
83
  */
72
84
  get NotLoaded() {
73
- return this._NotLoaded;
85
+ return this._notLoaded;
74
86
  }
75
87
  /**
76
88
  * Framework-internal: hydration paths call this for each field whose key the hydration
@@ -78,7 +90,7 @@ let EntityField = class EntityField {
78
90
  * clears the flag.
79
91
  */
80
92
  MarkNotLoaded() {
81
- this._NotLoaded = true;
93
+ this._notLoaded = true;
82
94
  }
83
95
  /**
84
96
  * True when field-level security bars this user from supplying the field's value on INSERT,
@@ -97,11 +109,11 @@ let EntityField = class EntityField {
97
109
  * users over its lifetime.
98
110
  */
99
111
  get CreateSuppressed() {
100
- return this._CreateSuppressed;
112
+ return this._createSuppressed;
101
113
  }
102
114
  /** Framework-internal: set by the save path's field-security create gate. */
103
115
  SetCreateSuppressed(suppressed) {
104
- this._CreateSuppressed = suppressed;
116
+ this._createSuppressed = suppressed;
105
117
  }
106
118
  get Name() {
107
119
  return this._entityFieldInfo.Name;
@@ -137,7 +149,7 @@ let EntityField = class EntityField {
137
149
  * serialization, hydration, SQL build) reads this directly and must stay assertion-free.
138
150
  */
139
151
  get Value() {
140
- return this._Value;
152
+ return this._value;
141
153
  }
142
154
  get ReadOnly() {
143
155
  return this._entityFieldInfo.ReadOnly;
@@ -164,7 +176,7 @@ let EntityField = class EntityField {
164
176
  // No active-status assertion here — see the Value getter note. Deprecation/disabled checks
165
177
  // are enforced at BaseEntity.Get/Set/SetMany, not at this low-level accessor.
166
178
  if (!this.ReadOnly ||
167
- this._NeverSet /* Allow one time set of any field because BaseEntity Object passes in ReadOnly fields when we load,
179
+ this._neverSet /* Allow one time set of any field because BaseEntity Object passes in ReadOnly fields when we load,
168
180
  after that load for a given INSTANCE of an EntityField object we never set a ReadOnly Field*/) {
169
181
  // Strip trailing space-padding on fixed-width string columns
170
182
  // (`nchar`/`char`/`bpchar`). SQL Server / PG right-pad these
@@ -174,24 +186,24 @@ let EntityField = class EntityField {
174
186
  // value is the logical (un-padded) form. See
175
187
  // `EntityFieldInfo.FixedWidthColumn` for the source of truth.
176
188
  if (typeof value === 'string' && this._entityFieldInfo.FixedWidthColumn) {
177
- value = value.replace(/ +$/, '');
189
+ value = trimTrailingSpaces(value);
178
190
  }
179
- this._Value = value;
191
+ this._value = value;
180
192
  // Any explicit set means the field now holds REAL data — including a blind write to
181
193
  // a read-denied field (the write-only case), which must flow to the save. Captured
182
194
  // BEFORE clearing so the OldValue branch below can see it: a set onto a not-loaded
183
195
  // field is an EDIT (must become dirty and reach the save), never the "initial value
184
196
  // set" of record setup — the record's setup moment was the hydration that omitted
185
197
  // this field.
186
- const wasNotLoaded = this._NotLoaded;
187
- this._NotLoaded = false;
198
+ const wasNotLoaded = this._notLoaded;
199
+ this._notLoaded = false;
188
200
  // 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
189
- if (this._NeverSet && !wasNotLoaded &&
190
- (value !== null || this._OldValue !== null)) {
201
+ if (this._neverSet && !wasNotLoaded &&
202
+ (value !== null || this._oldValue !== null)) {
191
203
  // initial value set
192
- this._OldValue = value;
204
+ this._oldValue = value;
193
205
  }
194
- this._NeverSet = false;
206
+ this._neverSet = false;
195
207
  }
196
208
  }
197
209
  /**
@@ -200,7 +212,7 @@ let EntityField = class EntityField {
200
212
  * after the object is created, but only once. This is typically used in the BaseEntity class when loading an entity from an array of values or the DB and reusing an existing object.
201
213
  */
202
214
  ResetNeverSetFlag() {
203
- this._NeverSet = true;
215
+ this._neverSet = true;
204
216
  }
205
217
  /**
206
218
  * 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.
@@ -208,19 +220,19 @@ let EntityField = class EntityField {
208
220
  * artifact, not data, and nothing that was never loaded can have been changed.
209
221
  */
210
222
  get Dirty() {
211
- if (this.ReadOnly || this._NotLoaded)
223
+ if (this.ReadOnly || this._notLoaded)
212
224
  return false;
213
225
  else {
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
226
+ 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
215
227
  const curNull = this.Value === null || this.Value === undefined || Number.isNaN(this.OldValue);
216
228
  if (oldNull && curNull)
217
229
  return false; // BOTH are null or undefined, not dirty
218
230
  else {
219
- let oldCompare = this._OldValue;
231
+ let oldCompare = this._oldValue;
220
232
  let newCompare = this.Value;
221
- if (this._OldValue instanceof Date) {
233
+ if (this._oldValue instanceof Date) {
222
234
  // sometimes we have old values that are date objects, convert to UTC timestamp for comparison
223
- oldCompare = this._OldValue.getTime();
235
+ oldCompare = this._oldValue.getTime();
224
236
  }
225
237
  if (this.Value instanceof Date) {
226
238
  // and sometimes the new values are date objects, convert to UTC timestamp for comparison
@@ -348,7 +360,7 @@ let EntityField = class EntityField {
348
360
  // unsaveable for unrelated edits (the exact NOT-NULL breakage mode the not-loaded
349
361
  // design exists to remove). Length/type checks below still apply to whatever the
350
362
  // constructor state is; only the required/null check is meaningless for it.
351
- if (!ef.AllowsNull && !this._NotLoaded && (this.Value === null || this.Value === undefined)) {
363
+ if (!ef.AllowsNull && !this._notLoaded && (this.Value === null || this.Value === undefined)) {
352
364
  // make sure this isn't a field that has a default value and we are inside a new record
353
365
  if (ef.DefaultValue === null || ef.DefaultValue === undefined || ef.DefaultValue.trim().length === 0) {
354
366
  // we have no default value, so this is an error
@@ -357,7 +369,7 @@ let EntityField = class EntityField {
357
369
  }
358
370
  else {
359
371
  // 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
360
- if (this._OldValue !== null && this._OldValue !== undefined) {
372
+ if (this._oldValue !== null && this._oldValue !== undefined) {
361
373
  result.Success = false;
362
374
  result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} cannot be null`, null));
363
375
  }
@@ -430,9 +442,9 @@ let EntityField = class EntityField {
430
442
  return result;
431
443
  }
432
444
  constructor(fieldInfo, Value) {
433
- this._NeverSet = true;
434
- this._NotLoaded = false;
435
- this._CreateSuppressed = false;
445
+ this._neverSet = true;
446
+ this._notLoaded = false;
447
+ this._createSuppressed = false;
436
448
  // NOTE: constructing an EntityField for a deprecated/disabled field is always allowed — the
437
449
  // column is physically part of the entity and the instance must exist. Active-status is
438
450
  // asserted only when code accesses the field by name via BaseEntity.Get/Set/SetMany, never
@@ -525,25 +537,41 @@ let EntityField = class EntityField {
525
537
  this.Value = fieldInfo.DefaultValue;
526
538
  }
527
539
  }
528
- 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
540
+ 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
529
541
  }
530
542
  else {
531
543
  this.Value = null; // we need to set the value to null instead of being undefined as the value is defined, it is NULL
532
- this._NeverSet = true; // set this back to true because we are setting the value to null;
544
+ this._neverSet = true; // set this back to true because we are setting the value to null;
533
545
  }
534
546
  }
535
547
  /**
536
548
  * 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.
537
549
  */
538
550
  ResetOldValue() {
539
- this._OldValue = this.Value;
551
+ this._oldValue = this.Value;
540
552
  }
541
553
  /**
542
554
  * Restores the dirty-tracking baseline to a previously captured value.
543
- * Used by graph rollback so a retried save still sees the pre-attempt dirty set.
544
555
  */
545
556
  RestoreOldValue(value) {
546
- this._OldValue = value;
557
+ this._oldValue = value;
558
+ }
559
+ /**
560
+ * Framework-internal: the field's value and dirty-tracking state, so a unit of work that rolls
561
+ * back can put them back with {@link RestoreState}.
562
+ */
563
+ GetState() {
564
+ return { Value: this._value, OldValue: this._oldValue, NotLoaded: this._notLoaded, NeverSet: this._neverSet };
565
+ }
566
+ /**
567
+ * Framework-internal: puts back a state {@link GetState} captured. It writes the state as it was,
568
+ * so the read-only and first-set rules of the {@link Value} setter don't apply.
569
+ */
570
+ RestoreState(state) {
571
+ this._value = state.Value;
572
+ this._oldValue = state.OldValue;
573
+ this._notLoaded = state.NotLoaded;
574
+ this._neverSet = state.NeverSet;
547
575
  }
548
576
  /**
549
577
  * @deprecated No-op as of the active-status relocation. Active-status (deprecated/disabled)
@@ -563,7 +591,7 @@ let EntityField = class EntityField {
563
591
  * Returns the old value of the field. This is the value that was set when the field was last loaded from the database.
564
592
  */
565
593
  get OldValue() {
566
- return this._OldValue;
594
+ return this._oldValue;
567
595
  }
568
596
  };
569
597
  EntityField = EntityField_1 = __decorate([
@@ -571,6 +599,13 @@ EntityField = EntityField_1 = __decorate([
571
599
  __metadata("design:paramtypes", [EntityFieldInfo, Object])
572
600
  ], EntityField);
573
601
  export { EntityField };
602
+ /**
603
+ * The `ChangeContext` JSON a clone writes on its Record Change rows (`IRecordChangeContext`,
604
+ * Kind 'Clone'). The one serializer every provider path uses, so the shape can't drift.
605
+ */
606
+ export function SerializeCloneChangeContext(context) {
607
+ return JSON.stringify({ Version: 1, Kind: 'Clone', Clone: context });
608
+ }
574
609
  export class DataObjectRelatedEntityParam {
575
610
  }
576
611
  export class DataObjectParams {
@@ -714,6 +749,13 @@ export class BaseEntityResult {
714
749
  */
715
750
  export class BaseEntityEvent {
716
751
  }
752
+ /** Entity names compare the way metadata lookups do: trimmed and case-insensitive. */
753
+ function sameEntityName(a, b) {
754
+ return a.trim().toLowerCase() === b.trim().toLowerCase();
755
+ }
756
+ /**
757
+ * Base class used for all entity objects. This class is abstract and is sub-classes for each particular entity using the CodeGen tool. This class provides the basic functionality for loading, saving, and validating entity objects.
758
+ */
717
759
  export class BaseEntity {
718
760
  /**
719
761
  * Maximum number of `BaseEntityResult` entries retained in `_resultHistory` per entity
@@ -791,7 +833,7 @@ export class BaseEntity {
791
833
  * `BaseEntity` are reserved for paths that have already triggered hydration or paths that
792
834
  * deliberately want to inspect hydration state (e.g., `Dirty` getter).
793
835
  */
794
- this._Fields = [];
836
+ this._fields = [];
795
837
  /**
796
838
  * Tracks whether `_Fields` has been populated. `false` while in "raw mode" (data accessible
797
839
  * via `_raw` but no EntityField instances built yet); flips to `true` after the first call to
@@ -956,6 +998,17 @@ export class BaseEntity {
956
998
  * loaded record. Reset to false on NewRecord() and init().
957
999
  */
958
1000
  this._childEntityDiscoveryDone = false;
1001
+ /**
1002
+ * Set only while this record's parent loads it because the parent's subtype rule named it
1003
+ * (see {@link tryLinkHintedChildEntity}); read by {@link InnerLoad}.
1004
+ */
1005
+ this._subtypeHintProbe = null;
1006
+ /**
1007
+ * Set only while {@link AttachToParent} loads this record as the parent it promotes. Child
1008
+ * discovery then doesn't ask the subtype rule: the child row being added doesn't exist yet, so
1009
+ * a hint would name it and miss.
1010
+ */
1011
+ this._skipSubtypeLoadHint = false;
959
1012
  /**
960
1013
  * For **overlapping** subtype parents (AllowMultipleSubtypes = true), stores
961
1014
  * the list of child entity type names that have records for this PK.
@@ -982,6 +1035,12 @@ export class BaseEntity {
982
1035
  * routing through the provider: participants stay ignorant of one another.
983
1036
  */
984
1037
  this._entityTransactionScope = null;
1038
+ /**
1039
+ * The unit of work this record is saved or deleted in, while it runs as part of a larger one: an
1040
+ * IS-A chain's initiator sets it on each parent for the parent's write, and a graph sets it on
1041
+ * each of its records. See {@link UnitOfWork}.
1042
+ */
1043
+ this._unitOfWork = null;
985
1044
  /**
986
1045
  * Companions registered on this entity, keyed by {@link EntityCompanion.Name}.
987
1046
  *
@@ -1014,6 +1073,7 @@ export class BaseEntity {
1014
1073
  // provider may capture the context now but use it later.
1015
1074
  // ────────────────────────────────────────────────────────────────────
1016
1075
  this._restoreContext = null;
1076
+ this._cloneContext = null;
1017
1077
  // Holds the current pending save observable (if any)
1018
1078
  this._pendingSave$ = null;
1019
1079
  /**
@@ -1037,7 +1097,7 @@ export class BaseEntity {
1037
1097
  */
1038
1098
  this._skipEmbeddings = false;
1039
1099
  this._eventSubject = new Subject();
1040
- this._EntityInfo = Entity;
1100
+ this._entityInfo = Entity;
1041
1101
  EntityInfo.AssertEntityActiveStatus(Entity, 'BaseEntity::constructor');
1042
1102
  this._provider = Provider;
1043
1103
  this.init();
@@ -1096,10 +1156,27 @@ export class BaseEntity {
1096
1156
  // route the parent through the same connection — not the global default.
1097
1157
  const parentProvider = this.ProviderToUse;
1098
1158
  this._parentEntity = await parentProvider.GetEntityObject(parentEntityInfo.Name, this._contextCurrentUser);
1159
+ // A chain loaded through the parent is linked the other way too, so the parent's
1160
+ // LeafEntity (and its save hooks) see this child (MJ#4870).
1161
+ this.linkParentBackToThisChild();
1099
1162
  // Recursive: the parent's InitializeParentEntity() was called by GetEntityObject()
1100
1163
  // Cache the parent field names for O(1) routing lookups
1101
1164
  this._parentEntityFieldNames = this.EntityInfo.ParentEntityFieldNames;
1102
1165
  }
1166
+ /**
1167
+ * Points this child's disjoint parent back at this instance (`parent._childEntity = this`).
1168
+ *
1169
+ * Same gate as {@link InitializeParentEntity}: a parent that allows several subtypes keeps no
1170
+ * single child. `NewRecord()` sets `_childEntity` to null, so this has to run AFTER the
1171
+ * parent's `NewRecord()` — calling it before is wiped out.
1172
+ */
1173
+ linkParentBackToThisChild() {
1174
+ const parentEntityInfo = this.EntityInfo?.ParentEntityInfo;
1175
+ if (!this._parentEntity || !parentEntityInfo || parentEntityInfo.AllowMultipleSubtypes) {
1176
+ return;
1177
+ }
1178
+ this._parentEntity._childEntity = this;
1179
+ }
1103
1180
  /**
1104
1181
  * IS-A PROMOTION (#3825): binds this NEW child record to an EXISTING parent row, so saving it
1105
1182
  * ADDS a subtype to a person/org/product that already exists instead of trying to create a
@@ -1165,12 +1242,22 @@ export class BaseEntity {
1165
1242
  name: pk.Name,
1166
1243
  value: this._parentEntity.Get(pk.Name),
1167
1244
  }));
1168
- const loaded = await this._parentEntity.InnerLoad(parentKey);
1245
+ // The parent's subtype rule isn't asked for a load hint here: it would name the child this
1246
+ // promotion is adding, whose row doesn't exist yet, and the miss would cost a round trip.
1247
+ this._parentEntity._skipSubtypeLoadHint = true;
1248
+ let loaded;
1249
+ try {
1250
+ loaded = await this._parentEntity.InnerLoad(parentKey);
1251
+ }
1252
+ finally {
1253
+ this._parentEntity._skipSubtypeLoadHint = false;
1254
+ }
1169
1255
  if (!loaded) {
1170
1256
  // Restore the fresh chain the failed load destroyed: re-seed the parent chain, then put
1171
1257
  // the ORIGINAL minted key back (Set routes to the root), so the record the caller holds
1172
- // is bit-for-bit the fresh record they built.
1258
+ // is bit-for-bit the fresh record they built. Re-seed nulls the parent's back-link.
1173
1259
  this._parentEntity.NewRecord();
1260
+ this.linkParentBackToThisChild();
1174
1261
  for (const pk of freshPkValues) {
1175
1262
  if (pk.value != null) {
1176
1263
  this._parentEntity.Set(pk.name, pk.value);
@@ -1197,7 +1284,7 @@ export class BaseEntity {
1197
1284
  if (!this.GetFieldByName(name)) {
1198
1285
  return;
1199
1286
  }
1200
- this.SetLocal(name, value);
1287
+ this.setLocal(name, value);
1201
1288
  this.GetFieldByName(name)?.ResetNeverSetFlag();
1202
1289
  }
1203
1290
  /**
@@ -1211,6 +1298,10 @@ export class BaseEntity {
1211
1298
  * This ensures that Save/Delete operations always delegate to the leaf entity,
1212
1299
  * running the full validation and event chain at every level.
1213
1300
  *
1301
+ * For a disjoint parent, the entity's subtype rule is asked first (see
1302
+ * {@link resolveSubtypeLoadHint}); when it names a child, that child's load, which
1303
+ * happens anyway, checks the answer, and the discovery query runs only on a miss.
1304
+ *
1214
1305
  * Must be called AFTER a record is loaded (PK must be available).
1215
1306
  * Skipped for entities that are not parent types or have already been discovered.
1216
1307
  */
@@ -1225,12 +1316,48 @@ export class BaseEntity {
1225
1316
  await this.discoverOverlappingChildren();
1226
1317
  }
1227
1318
  else {
1228
- // Disjoint: discover single child, auto-chain (current behavior)
1229
- const childEntityName = await this.discoverChildEntityName();
1230
- if (!childEntityName)
1319
+ // Disjoint: discover the single child and auto-chain it
1320
+ await this.initializeDisjointChildEntity();
1321
+ }
1322
+ }
1323
+ /**
1324
+ * Finds and links the one child of a loaded disjoint parent.
1325
+ *
1326
+ * Without a hint this is one discovery query plus the child's load. With a hint from the
1327
+ * entity's subtype rule, the child's load checks the hint, and the discovery query runs
1328
+ * only when that load comes back empty or wasn't tried.
1329
+ *
1330
+ * For well-formed data the outcome is the one the discovery query alone would give: a hint
1331
+ * changes the number of round trips, not which child is linked or whether the load succeeds.
1332
+ * Three benign exceptions:
1333
+ * - a failure reading the hinted child's row falls back to the discovery query, so a transient
1334
+ * failure now recovers (a lasting one reads the row twice, then fails as before);
1335
+ * - when the data breaks the disjoint rule with two child rows, the discovery query's
1336
+ * unordered `UNION ALL` links either one, where a hint links the one the rule names;
1337
+ * - a provider without `FindISAChildEntity` links nothing without a hint, and the hinted child
1338
+ * with one.
1339
+ */
1340
+ async initializeDisjointChildEntity() {
1341
+ const hintedName = await this.resolveSubtypeLoadHint();
1342
+ const hintOutcome = hintedName
1343
+ ? await this.tryLinkHintedChildEntity(hintedName)
1344
+ : 'NotTried';
1345
+ if (hintOutcome === 'Linked')
1346
+ return;
1347
+ const childEntityName = await this.discoverChildEntityName();
1348
+ if (hintedName && hintOutcome === 'NotFound') {
1349
+ if (childEntityName && sameEntityName(childEntityName, hintedName)) {
1350
+ // The row exists but its load came back empty (for example, a row filter hides it).
1351
+ // Loading it again would too, and the discovery-only path unlinks it the same way,
1352
+ // after logging the empty load that the hinted attempt kept quiet.
1353
+ BaseEntity.logLoadFoundNoRow(hintedName, this.PrimaryKey);
1231
1354
  return;
1232
- await this.createAndLinkChildEntity(childEntityName);
1355
+ }
1356
+ this.reportSubtypeLoadHintMiss(hintedName, childEntityName);
1233
1357
  }
1358
+ if (!childEntityName)
1359
+ return;
1360
+ await this.createAndLinkChildEntity(childEntityName);
1234
1361
  }
1235
1362
  /**
1236
1363
  * Uses the provider's FindISAChildEntity method (single UNION ALL query) to
@@ -1263,12 +1390,29 @@ export class BaseEntity {
1263
1390
  * Creates the child entity instance, wires up the shared instance chain
1264
1391
  * (child._parentEntity = this, this._childEntity = child), loads the
1265
1392
  * child's data, and recursively discovers further children.
1393
+ *
1394
+ * @returns true when the child's row loaded and the child is linked; false when the load
1395
+ * came back empty, in which case the link is undone.
1266
1396
  */
1267
1397
  async createAndLinkChildEntity(childEntityName) {
1268
- // Create child via this entity's provider so the child shares the same connection
1269
- // (correct in multi-provider scenarios — never the global default).
1398
+ const childEntity = await this.createChildEntityObject(childEntityName);
1399
+ return this.linkAndLoadChildEntity(childEntity);
1400
+ }
1401
+ /**
1402
+ * Creates an unlinked child entity instance through this entity's provider, so the child
1403
+ * shares the same connection (correct in multi-provider scenarios — never the global default).
1404
+ */
1405
+ async createChildEntityObject(childEntityName) {
1270
1406
  const childProvider = this.ProviderToUse;
1271
- const childEntity = await childProvider.GetEntityObject(childEntityName, this._contextCurrentUser);
1407
+ return childProvider.GetEntityObject(childEntityName, this._contextCurrentUser);
1408
+ }
1409
+ /**
1410
+ * Links `childEntity` into this entity's chain and loads its row under the shared key.
1411
+ *
1412
+ * @returns true when the row loaded and the child is linked; false when the load came back
1413
+ * empty, in which case the link is undone.
1414
+ */
1415
+ async linkAndLoadChildEntity(childEntity) {
1272
1416
  // Wire up the shared instance chain: child's parent IS this entity (same object)
1273
1417
  // We need to replace the child's auto-initialized parent with our existing instance
1274
1418
  this.replaceChildParentChain(childEntity);
@@ -1280,20 +1424,245 @@ export class BaseEntity {
1280
1424
  if (!loaded) {
1281
1425
  // Load failed — clean up the link
1282
1426
  this._childEntity = null;
1283
- return;
1427
+ return false;
1284
1428
  }
1285
1429
  // Re-apply any pending modifications so child hydration does not overwrite unsaved in-memory edits
1286
1430
  this.restoreChainDirtyState(dirtySnapshots);
1287
1431
  // Recursively discover grandchildren (child may also be a parent type)
1288
1432
  // InitializeChildEntity is idempotent via _childEntityDiscoveryDone flag
1289
1433
  await childEntity.InitializeChildEntity();
1434
+ return true;
1435
+ }
1436
+ /**
1437
+ * Tries to link the child that the subtype rule named, by loading its row directly. That
1438
+ * load is the check on the hint.
1439
+ *
1440
+ * The hint isn't tried when the child object can't be created or the user can't read that
1441
+ * entity: the discovery-only path decides those cases, so a hint can neither expose a child
1442
+ * nor fail a load that works without it. A failure READING the hinted row is logged and
1443
+ * treated the same way: the discovery path runs, and reads the row again only if the discovery
1444
+ * query finds it. A failure after the row was read (the child's hydration, its eager
1445
+ * companions, its own child discovery) propagates, as it does on the discovery-only path.
1446
+ *
1447
+ * @returns `'Linked'` when the hinted child loaded and is linked, `'NotFound'` when its load
1448
+ * came back empty, and `'NotTried'` when it wasn't loaded or reading its row failed.
1449
+ */
1450
+ async tryLinkHintedChildEntity(hintedName) {
1451
+ const childEntity = await this.createReadableHintedChild(hintedName);
1452
+ if (!childEntity) {
1453
+ return 'NotTried';
1454
+ }
1455
+ const probe = { RowReadFailed: false };
1456
+ childEntity._subtypeHintProbe = probe;
1457
+ try {
1458
+ return (await this.linkAndLoadChildEntity(childEntity)) ? 'Linked' : 'NotFound';
1459
+ }
1460
+ catch (e) {
1461
+ if (!probe.RowReadFailed) {
1462
+ throw e;
1463
+ }
1464
+ // Nothing after the row read ran, so the parent chain and its unsaved edits are untouched.
1465
+ this._childEntity = null;
1466
+ LogError(`IS-A load hint: reading the '${hintedName}' row for '${this.EntityInfo.Name}' record ${this.PrimaryKey.ToString()} failed (${BaseEntity.errorText(e)}); finding the subtype with the discovery query instead.`);
1467
+ return 'NotTried';
1468
+ }
1469
+ finally {
1470
+ childEntity._subtypeHintProbe = null;
1471
+ }
1472
+ }
1473
+ /**
1474
+ * Creates the child object the subtype rule named, or returns null when the user can't read
1475
+ * that entity. A failure to create it is logged and returns null too, so the discovery-only
1476
+ * path decides.
1477
+ */
1478
+ async createReadableHintedChild(hintedName) {
1479
+ try {
1480
+ const childEntity = await this.createChildEntityObject(hintedName);
1481
+ return childEntity.CheckPermissions(EntityPermissionType.Read, false) ? childEntity : null;
1482
+ }
1483
+ catch (e) {
1484
+ LogError(`IS-A load hint: creating a '${hintedName}' object for '${this.EntityInfo.Name}' record ${this.PrimaryKey.ToString()} failed (${BaseEntity.errorText(e)}); finding the subtype with the discovery query instead.`);
1485
+ return null;
1486
+ }
1487
+ }
1488
+ /**
1489
+ * Asks the entity's subtype rule which child a LOADED record has, without running a query.
1490
+ *
1491
+ * - A registered {@link EntitySubtypeResolver} owns the rule. It gives a hint only when its
1492
+ * class overrides {@link EntitySubtypeResolver.ResolveLoadHint}. A resolver that doesn't
1493
+ * gives none, and the `SubtypeSelector` isn't consulted in its place: at create time the
1494
+ * resolver overrides the selector, so the selector may not describe the same rule.
1495
+ * - Otherwise a `SubtypeSelector` that sets `UseForLoadedRecords` is walked through rows that
1496
+ * loaded `BaseEngine` caches already hold. A hop that isn't cached gives no hint, not a query.
1497
+ * - An entity with no rule gets no hint. The single-child fallback of
1498
+ * {@link ResolveSubtypeEntityName} is a default for new records, not a rule about existing ones.
1499
+ * - While {@link AttachToParent} loads this record, no rule is asked: the child row being added
1500
+ * doesn't exist yet.
1501
+ *
1502
+ * An answer of "no subtype" also gives no hint: only the discovery query can tell whether an
1503
+ * older record still has a child row.
1504
+ *
1505
+ * @returns A declared IsA child's entity name, or null when there is no usable hint. Never throws.
1506
+ */
1507
+ async resolveSubtypeLoadHint() {
1508
+ if (this._skipSubtypeLoadHint) {
1509
+ return null;
1510
+ }
1511
+ const registration = MJGlobal.Instance.ClassFactory.GetRegistration(EntitySubtypeResolver, this.EntityInfo.Name);
1512
+ if (registration) {
1513
+ const resolver = BaseEntity.loadHintResolverFor(this.EntityInfo.Name, registration);
1514
+ return resolver ? this.askResolverForLoadHint(resolver) : null;
1515
+ }
1516
+ const selector = this.EntityInfo.SubtypeSelectorConfig;
1517
+ const selectorPath = selector?.UseForLoadedRecords ? selector.Path?.trim() : null;
1518
+ return selectorPath ? this.evaluateSelectorForLoadHint(selectorPath) : null;
1519
+ }
1520
+ /**
1521
+ * The resolver to ask for load hints for an entity, decided once per registration and cached:
1522
+ * null when the registered class doesn't override {@link EntitySubtypeResolver.ResolveLoadHint},
1523
+ * which is read from the class without constructing it, or when constructing it failed.
1524
+ */
1525
+ static loadHintResolverFor(entityName, registration) {
1526
+ const key = entityName.trim().toLowerCase();
1527
+ const cached = BaseEntity._subtypeLoadHintResolvers.get(key);
1528
+ if (cached?.Registration === registration) {
1529
+ return cached.Resolver;
1530
+ }
1531
+ const resolver = BaseEntity.overridesResolveLoadHint(registration)
1532
+ ? BaseEntity.constructLoadHintResolver(entityName)
1533
+ : null;
1534
+ BaseEntity._subtypeLoadHintResolvers.set(key, { Registration: registration, Resolver: resolver });
1535
+ return resolver;
1536
+ }
1537
+ /** Whether a registered resolver class overrides `ResolveLoadHint`, read from its prototype. */
1538
+ static overridesResolveLoadHint(registration) {
1539
+ const prototype = registration.SubClass?.prototype;
1540
+ const resolveLoadHint = prototype?.ResolveLoadHint;
1541
+ return typeof resolveLoadHint === 'function' && resolveLoadHint !== EntitySubtypeResolver.prototype.ResolveLoadHint;
1542
+ }
1543
+ /**
1544
+ * Constructs the registered resolver once, to ask it for load hints. A failure is logged and
1545
+ * gives null; since the answer is cached, that is once per entity.
1546
+ */
1547
+ static constructLoadHintResolver(entityName) {
1548
+ try {
1549
+ const resolution = MJGlobal.Instance.ClassFactory.TryCreateInstance(EntitySubtypeResolver, entityName);
1550
+ if (resolution.Resolved && resolution.Instance) {
1551
+ return resolution.Instance;
1552
+ }
1553
+ LogError(`IS-A load hint: the EntitySubtypeResolver registered for '${entityName}' did not resolve (${resolution.Reason ?? 'no reason given'}); loading its records without a hint.`);
1554
+ }
1555
+ catch (e) {
1556
+ LogError(`IS-A load hint: constructing the EntitySubtypeResolver registered for '${entityName}' failed (${BaseEntity.errorText(e)}); loading its records without a hint.`);
1557
+ }
1558
+ return null;
1559
+ }
1560
+ /**
1561
+ * Asks the resolver for a load hint. A resolver that throws or rejects gives no hint for that
1562
+ * record, and is logged once per entity rather than per record.
1563
+ */
1564
+ async askResolverForLoadHint(resolver) {
1565
+ try {
1566
+ // A synchronous answer is used as-is, with no extra microtask per record.
1567
+ const raw = resolver.ResolveLoadHint(this);
1568
+ const candidate = raw instanceof Promise ? await raw : raw;
1569
+ return this.declaredChildForLoadHint(candidate, `EntitySubtypeResolver.ResolveLoadHint for '${this.EntityInfo.Name}'`);
1570
+ }
1571
+ catch (e) {
1572
+ BaseEntity.logSubtypeLoadHintOnce(`${this.EntityInfo.Name}|resolver-failed`, () => LogError(`IS-A load hint: EntitySubtypeResolver.ResolveLoadHint for '${this.EntityInfo.Name}' failed on record ${this.PrimaryKey.ToString()} (${BaseEntity.errorText(e)}). A record it fails on loads without a hint; this is reported once per entity.`));
1573
+ return null;
1574
+ }
1575
+ }
1576
+ /**
1577
+ * Walks the `SubtypeSelector` path through engine caches only. A path that isn't valid for
1578
+ * this entity's metadata is logged once and gives no hint; the create path throws on it instead.
1579
+ */
1580
+ async evaluateSelectorForLoadHint(path) {
1581
+ try {
1582
+ const pathResult = await this.evaluateSubtypeSelectorPath(path, (entityName, pkValue) => BaseEntity.findSubtypePathTargetInEngineCache(entityName, pkValue));
1583
+ return this.declaredChildForLoadHint(pathResult, `SubtypeSelector path '${path}' on '${this.EntityInfo.Name}'`);
1584
+ }
1585
+ catch (e) {
1586
+ BaseEntity.logSubtypeLoadHintOnce(`${this.EntityInfo.Name}|path|${path}`, () => LogError(`IS-A load hint: ${BaseEntity.errorText(e)} Loading '${this.EntityInfo.Name}' records without a hint.`));
1587
+ return null;
1588
+ }
1589
+ }
1590
+ /**
1591
+ * Turns a rule's answer into a declared child's canonical entity name. Empty means no subtype,
1592
+ * which gives no hint. A name that isn't a declared IsA child is logged once and gives no hint.
1593
+ */
1594
+ declaredChildForLoadHint(candidate, source) {
1595
+ const trimmed = candidate?.trim();
1596
+ if (!trimmed) {
1597
+ return null;
1598
+ }
1599
+ const match = this.findDeclaredChild(trimmed);
1600
+ if (!match) {
1601
+ BaseEntity.logSubtypeLoadHintOnce(`${this.EntityInfo.Name}|undeclared|${trimmed}`, () => LogError(`IS-A load hint: ${source} named '${trimmed}', which is not a declared IsA child entity of '${this.EntityInfo.Name}'. Loading the record without a hint.`));
1602
+ return null;
1603
+ }
1604
+ return match.Name;
1605
+ }
1606
+ /**
1607
+ * Reports a hint the data didn't bear out: the hinted child's row wasn't there.
1608
+ *
1609
+ * When the discovery query finds no child either, that is a normal state, not an error: core
1610
+ * never creates a subtype row on save, so a typed record can exist without one (saved through
1611
+ * a generic form or an import, or older than its type's subtype). It logs one verbose line per
1612
+ * entity and hinted child. When the discovery query finds a DIFFERENT child, the rule and the
1613
+ * data disagree, and each such load costs an extra round trip until one of them is fixed; that
1614
+ * is logged as an error, once per entity, hinted child and child found.
1615
+ */
1616
+ reportSubtypeLoadHintMiss(hintedName, discoveredName) {
1617
+ const entityName = this.EntityInfo.Name;
1618
+ if (!discoveredName) {
1619
+ BaseEntity.logSubtypeLoadHintOnce(`${entityName}|no-row|${hintedName}`, () => LogStatusEx({
1620
+ message: `IS-A load hint: a '${entityName}' record whose subtype rule names '${hintedName}' has no subtype row, so its load also ran the discovery query. Normal for a record saved before its subtype row; reported once.`,
1621
+ verboseOnly: true,
1622
+ }));
1623
+ return;
1624
+ }
1625
+ BaseEntity.logSubtypeLoadHintOnce(`${entityName}|disagree|${hintedName}|${discoveredName}`, () => LogError(`IS-A load hint: the subtype rule for '${entityName}' record ${this.PrimaryKey.ToString()} names '${hintedName}', but its subtype row is in '${discoveredName}'. The rule and the data disagree, and each such load costs an extra round trip until one of them is fixed. Reported once for this pair of subtypes.`));
1626
+ }
1627
+ /** Keys of load-hint messages already logged, so each is reported once rather than per record. */
1628
+ static { this._loggedSubtypeLoadHintMessages = new Set(); }
1629
+ /** Runs `log` the first time `key` is seen (case-insensitively), and not again until the caches are cleared. */
1630
+ static logSubtypeLoadHintOnce(key, log) {
1631
+ const normalizedKey = key.toLowerCase();
1632
+ if (BaseEntity._loggedSubtypeLoadHintMessages.has(normalizedKey)) {
1633
+ return;
1634
+ }
1635
+ BaseEntity._loggedSubtypeLoadHintMessages.add(normalizedKey);
1636
+ log();
1637
+ }
1638
+ static errorText(e) {
1639
+ return e instanceof Error ? e.message : String(e);
1290
1640
  }
1291
1641
  static { this._subtypeLookupCache = new Map(); }
1642
+ /** Per entity (lowercased name): the resolver asked for load hints, decided once per registration. */
1643
+ static { this._subtypeLoadHintResolvers = new Map(); }
1644
+ /** Indexes over engine-cached arrays, keyed by the live array itself; see {@link findEngineCachedRow}. */
1645
+ static { this._engineCachedRowIndexes = new WeakMap(); }
1292
1646
  /**
1293
- * Clears the static memoization cache used by SubtypeSelector path evaluation.
1647
+ * Clears the static caches IsA subtype resolution keeps: the create path's SubtypeSelector
1648
+ * lookup memo, the per-entity decision about which resolver gives load hints, the indexes over
1649
+ * engine-cached rows, and the record of load-hint messages already logged, so each can be
1650
+ * reported again.
1294
1651
  */
1295
1652
  static ClearSubtypeLookupCache() {
1296
1653
  BaseEntity._subtypeLookupCache.clear();
1654
+ BaseEntity._subtypeLoadHintResolvers.clear();
1655
+ BaseEntity._engineCachedRowIndexes = new WeakMap();
1656
+ BaseEntity._loggedSubtypeLoadHintMessages.clear();
1657
+ }
1658
+ /**
1659
+ * The declared IsA child entity named `name`, matched the way metadata lookups match names
1660
+ * (trimmed, case-insensitive), or undefined when this entity declares no such child. The one
1661
+ * lookup behind {@link ResolveSubtypeEntityName}, {@link EnsureISAChild} and load hints: the
1662
+ * create path throws on a miss, the load path logs it.
1663
+ */
1664
+ findDeclaredChild(name) {
1665
+ return this.EntityInfo.ChildEntities?.find(c => sameEntityName(c.Name, name));
1297
1666
  }
1298
1667
  /**
1299
1668
  * Prospective counterpart to FindISAChildEntity.
@@ -1303,6 +1672,12 @@ export class BaseEntity {
1303
1672
  * 3. Unconditional single-child IsA fallback (ChildEntities.length === 1)
1304
1673
  * 4. Otherwise null (no subtype)
1305
1674
  *
1675
+ * This is the create-time ladder, and steps 1 and 2 may query. When a record is loaded,
1676
+ * BaseEntity asks a narrower, query-free version of it for a hint instead: the resolver's
1677
+ * `ResolveLoadHint` (not `Resolve`) when its class overrides it, the selector only when it sets
1678
+ * `UseForLoadedRecords` and only through rows that loaded `BaseEngine` caches hold, and never
1679
+ * step 3.
1680
+ *
1306
1681
  * @see plans/sync-composition-axes.md
1307
1682
  */
1308
1683
  async ResolveSubtypeEntityName() {
@@ -1317,8 +1692,7 @@ export class BaseEntity {
1317
1692
  const raw = resolution.Instance.Resolve(this);
1318
1693
  const candidate = raw instanceof Promise ? await raw : raw;
1319
1694
  if (candidate != null && candidate.trim() !== '') {
1320
- const trimmed = candidate.trim();
1321
- const match = this.EntityInfo.ChildEntities.find(c => c.Name.trim().toLowerCase() === trimmed.toLowerCase());
1695
+ const match = this.findDeclaredChild(candidate);
1322
1696
  if (!match) {
1323
1697
  throw new Error(`EntitySubtypeResolver for '${this.EntityInfo.Name}' returned '${candidate}', which is not a declared IsA child entity of '${this.EntityInfo.Name}'.`);
1324
1698
  }
@@ -1330,10 +1704,9 @@ export class BaseEntity {
1330
1704
  // 2. Entity.SubtypeSelector declarative path
1331
1705
  const selectorConfig = this.EntityInfo.SubtypeSelectorConfig;
1332
1706
  if (selectorConfig && selectorConfig.Path && selectorConfig.Path.trim() !== '') {
1333
- const pathResult = await this.evaluateSubtypeSelectorPath(selectorConfig.Path.trim());
1707
+ const pathResult = await this.evaluateSubtypeSelectorPath(selectorConfig.Path.trim(), (entityName, pkValue) => this.getSubtypePathTargetEntity(entityName, pkValue));
1334
1708
  if (pathResult != null && pathResult.trim() !== '') {
1335
- const trimmed = pathResult.trim();
1336
- const match = this.EntityInfo.ChildEntities.find(c => c.Name.trim().toLowerCase() === trimmed.toLowerCase());
1709
+ const match = this.findDeclaredChild(pathResult);
1337
1710
  if (!match) {
1338
1711
  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
1712
  }
@@ -1364,7 +1737,7 @@ export class BaseEntity {
1364
1737
  if (!entityName) {
1365
1738
  return null;
1366
1739
  }
1367
- const matchedChild = this.EntityInfo.ChildEntities?.find(c => c.Name.trim().toLowerCase() === entityName.trim().toLowerCase());
1740
+ const matchedChild = this.findDeclaredChild(entityName);
1368
1741
  if (!matchedChild) {
1369
1742
  throw new Error(`'${entityName}' is not a declared IsA child entity of '${this.EntityInfo.Name}'.`);
1370
1743
  }
@@ -1383,15 +1756,7 @@ export class BaseEntity {
1383
1756
  this.replaceChildParentChain(childEntity);
1384
1757
  this._childEntity = childEntity;
1385
1758
  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
- }
1759
+ await this.loadOrMirrorChildRow(childEntity);
1395
1760
  this.restoreChainDirtyState(dirtySnapshots);
1396
1761
  // Recursively discover grandchildren if the child is also a parent type
1397
1762
  if (childEntity.EntityInfo.IsParentType) {
@@ -1411,15 +1776,7 @@ export class BaseEntity {
1411
1776
  const childEntity = await childProvider.GetEntityObject(resolvedName, this._contextCurrentUser);
1412
1777
  this.replaceChildParentChain(childEntity);
1413
1778
  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
- }
1779
+ await this.loadOrMirrorChildRow(childEntity);
1423
1780
  this.restoreChainDirtyState(dirtySnapshots);
1424
1781
  if (childEntity.EntityInfo.IsParentType) {
1425
1782
  await childEntity.EnsureISAChild();
@@ -1427,6 +1784,32 @@ export class BaseEntity {
1427
1784
  return childEntity;
1428
1785
  }
1429
1786
  }
1787
+ /**
1788
+ * A new parent's child is new too: there is no row to read, so copy the parent's keys.
1789
+ * A saved parent still reads (promotion's usual answer is "no row"), and that miss is not
1790
+ * an error — the same way a subtype-hint probe treats an empty read (MJ#4859).
1791
+ */
1792
+ async loadOrMirrorChildRow(childEntity) {
1793
+ if (this.IsSaved && this.PrimaryKey?.HasValue) {
1794
+ const loaded = await this.loadChildRowQuietly(childEntity);
1795
+ if (!loaded) {
1796
+ this.mirrorSharedKeysToChild(childEntity);
1797
+ }
1798
+ return;
1799
+ }
1800
+ this.mirrorSharedKeysToChild(childEntity);
1801
+ }
1802
+ /** Loads the child by the shared key. An empty result is an answer, not a logged error. */
1803
+ async loadChildRowQuietly(childEntity) {
1804
+ const probe = { RowReadFailed: false };
1805
+ childEntity._subtypeHintProbe = probe;
1806
+ try {
1807
+ return await childEntity.InnerLoad(this.PrimaryKey);
1808
+ }
1809
+ finally {
1810
+ childEntity._subtypeHintProbe = null;
1811
+ }
1812
+ }
1430
1813
  mirrorSharedKeysToChild(childEntity) {
1431
1814
  const parentPks = this.EntityInfo.PrimaryKeys;
1432
1815
  if (!parentPks || parentPks.length === 0)
@@ -1438,7 +1821,19 @@ export class BaseEntity {
1438
1821
  }
1439
1822
  }
1440
1823
  }
1441
- async evaluateSubtypeSelectorPath(path) {
1824
+ /**
1825
+ * Walks a `SubtypeSelector` path from this record to the column holding the subtype's entity
1826
+ * name, and returns that column's value, or null when a hop's foreign key is empty, a target
1827
+ * isn't found, or the terminal value is empty.
1828
+ *
1829
+ * @param path The dotted path, e.g. `ProductTypeID.ProductExtensionEntity`.
1830
+ * @param findTarget Finds the record a foreign key points at. Create time passes a finder that
1831
+ * may query; load time passes one that reads engine caches only, so a hop missing from them
1832
+ * ends the walk with null instead of a query.
1833
+ * @throws When the path doesn't fit this entity's metadata (a field isn't found, or a hop isn't
1834
+ * a foreign key).
1835
+ */
1836
+ async evaluateSubtypeSelectorPath(path, findTarget) {
1442
1837
  const segments = path.split('.').map(s => s.trim()).filter(Boolean);
1443
1838
  if (segments.length === 0)
1444
1839
  return null;
@@ -1458,7 +1853,7 @@ export class BaseEntity {
1458
1853
  if (!relatedEntityName) {
1459
1854
  throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': field '${fieldName}' on entity '${currentEntityInfo.Name}' is not a foreign key relationship.`);
1460
1855
  }
1461
- const targetEntity = await this.getSubtypePathTargetEntity(relatedEntityName, fkValue);
1856
+ const targetEntity = await findTarget(relatedEntityName, fkValue);
1462
1857
  if (!targetEntity) {
1463
1858
  return null;
1464
1859
  }
@@ -1482,21 +1877,10 @@ export class BaseEntity {
1482
1877
  return BaseEntity._subtypeLookupCache.get(cacheKey) ?? null;
1483
1878
  }
1484
1879
  // 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
- }
1880
+ const cached = BaseEntity.findSubtypePathTargetInEngineCache(entityName, pkValue);
1881
+ if (cached) {
1882
+ BaseEntity._subtypeLookupCache.set(cacheKey, cached);
1883
+ return cached;
1500
1884
  }
1501
1885
  // 2. Fall back to loading via provider
1502
1886
  const provider = this.ProviderToUse;
@@ -1531,6 +1915,84 @@ export class BaseEntity {
1531
1915
  BaseEntity._subtypeLookupCache.set(cacheKey, null);
1532
1916
  return null;
1533
1917
  }
1918
+ /**
1919
+ * Finds the record a `SubtypeSelector` hop points at among the entity objects that loaded
1920
+ * `BaseEngine` caches already hold. Never queries, and never reads the create path's memo, whose
1921
+ * entries can outlive a change to the row: engine caches are kept current by entity events.
1922
+ *
1923
+ * `pkValue` is one foreign-key column's value, matched against the first primary key of the
1924
+ * cached rows' entity the way UUIDs compare (trimmed, case-insensitive).
1925
+ *
1926
+ * @returns The cached record, or null when no loaded engine holds it as an entity object.
1927
+ */
1928
+ static findSubtypePathTargetInEngineCache(entityName, pkValue) {
1929
+ const key = NormalizeUUID(String(pkValue));
1930
+ for (const match of BaseEngineRegistry.Instance.FindCachedEntity(entityName)) {
1931
+ const found = BaseEntity.findEngineCachedRow(match.records, key);
1932
+ if (found) {
1933
+ return found;
1934
+ }
1935
+ }
1936
+ return null;
1937
+ }
1938
+ /**
1939
+ * Finds the row whose key is `key` in an engine's live cached array through an index over the
1940
+ * array, not a scan per lookup. Keys are read with `Get()`, which leaves the rows an engine holds
1941
+ * in raw mode unhydrated.
1942
+ *
1943
+ * The array is the engine's own and changes under the index: a refresh replaces it, and a save,
1944
+ * delete or remote invalidation pushes a row, splices one out, or swaps one for a copy with the
1945
+ * same key. So the index is keyed by the array itself, and rebuilt when the array's length or
1946
+ * last element differs from when it was built, which every added or removed row changes. A hit
1947
+ * is checked against the row now at that position, and a row that moved makes the index rebuild
1948
+ * before the answer is trusted. A key that isn't indexed is a miss, which gives no hint on load
1949
+ * and falls back to a query at create time, as for a row no engine caches.
1950
+ */
1951
+ static findEngineCachedRow(records, key) {
1952
+ let index = BaseEntity.currentEngineCachedRowIndex(records);
1953
+ let position = index.Positions.get(key);
1954
+ if (position !== undefined && BaseEntity.engineCachedRowKey(records[position], index.KeyField) !== key) {
1955
+ index = BaseEntity.buildEngineCachedRowIndex(records);
1956
+ position = index.Positions.get(key);
1957
+ }
1958
+ return position === undefined ? null : records[position];
1959
+ }
1960
+ /** The index over `records`, rebuilt when the array's length or last element changed since it was built. */
1961
+ static currentEngineCachedRowIndex(records) {
1962
+ const index = BaseEntity._engineCachedRowIndexes.get(records);
1963
+ if (index && index.Length === records.length && index.LastRow === records[records.length - 1]) {
1964
+ return index;
1965
+ }
1966
+ return BaseEntity.buildEngineCachedRowIndex(records);
1967
+ }
1968
+ /** Indexes `records` by key. Anything that isn't an entity object (a `'simple'` cache holds plain rows) is skipped. */
1969
+ static buildEngineCachedRowIndex(records) {
1970
+ const firstEntityRow = records.find(r => typeof r?.Get === 'function');
1971
+ const keyField = firstEntityRow?.EntityInfo?.FirstPrimaryKey?.Name ?? null; // first-pk-ok: FK target — a SubtypeSelector hop's foreign key holds one key value
1972
+ const positions = new Map();
1973
+ for (let i = 0; i < records.length; i++) {
1974
+ const rowKey = BaseEntity.engineCachedRowKey(records[i], keyField);
1975
+ if (rowKey !== null && !positions.has(rowKey)) {
1976
+ positions.set(rowKey, i);
1977
+ }
1978
+ }
1979
+ const index = {
1980
+ Length: records.length,
1981
+ LastRow: records[records.length - 1],
1982
+ KeyField: keyField,
1983
+ Positions: positions,
1984
+ };
1985
+ BaseEntity._engineCachedRowIndexes.set(records, index);
1986
+ return index;
1987
+ }
1988
+ /** A cached row's key, normalized with `NormalizeUUID`; null for a row that isn't an entity object or has no key value. */
1989
+ static engineCachedRowKey(row, keyField) {
1990
+ if (!keyField || typeof row?.Get !== 'function') {
1991
+ return null;
1992
+ }
1993
+ const value = row.Get(keyField);
1994
+ return value == null ? null : NormalizeUUID(String(value));
1995
+ }
1534
1996
  captureChainDirtyState() {
1535
1997
  const snapshots = [];
1536
1998
  let curr = this;
@@ -1762,6 +2224,20 @@ export class BaseEntity {
1762
2224
  DeclareRelatedRecords(options) {
1763
2225
  return this.RegisterCompanion(new RelatedRecordCollection(this, options));
1764
2226
  }
2227
+ /**
2228
+ * Dynamically registers a database-sourced child collection companion on this entity instance.
2229
+ * Used by generic composite persistence engines (Record Cloning, Metadata Sync) when a relationship
2230
+ * has no static collection companion declared on the generated entity class.
2231
+ *
2232
+ * Refuses duplicate names or re-registering an already existing companion.
2233
+ *
2234
+ * @typeParam TChild - The child entity type.
2235
+ * @param options - The collection declaration options.
2236
+ * @returns The registered collection companion.
2237
+ */
2238
+ DeclareRelatedRecordsDynamic(options) {
2239
+ return this.DeclareRelatedRecords(options);
2240
+ }
1765
2241
  /**
1766
2242
  * Declares a 1:1 embedded peer on this entity, joined by an owner-held foreign key,
1767
2243
  * and registers it as a companion.
@@ -2223,10 +2699,15 @@ export class BaseEntity {
2223
2699
  childSaveOptions.GraphVisited = visited;
2224
2700
  const childDeleteOptions = Object.assign(new EntityDeleteOptions(), deleteOptions ?? {});
2225
2701
  childDeleteOptions.GraphVisited = visited;
2226
- // Snapshot dirty/saved bookkeeping so a rolled-back graph can be retried.
2227
- // Node Save() finalizes each participant as saved+clean; DB rollback does
2228
- // not undo that, and the next Save() would skip the peer and fail the FK.
2229
- const participants = this.captureGraphParticipants(plan);
2702
+ // A graph that holds a scope is a unit of work: what its records write is put back if it
2703
+ // rolls back, and what they delete is reset only once it commits (see UnitOfWork). Nested in
2704
+ // another unit — the companions of an IS-A parent deleted in its child's chain, say — it
2705
+ // joins that one. Without a scope each write stands as it runs, so a failure leaves the
2706
+ // records that already wrote as they are, which is what the database holds.
2707
+ const enclosing = this._unitOfWork;
2708
+ const records = plan.Nodes.map(node => node.Entity);
2709
+ const previousUnits = records.map(record => record._unitOfWork);
2710
+ let part = null;
2230
2711
  // Acquired INSIDE the try: a begin failure (pool exhausted, dead connection) is a failed
2231
2712
  // save, and Save()/Delete() report failure by returning false — an escaping throw here
2232
2713
  // would break that contract for exactly one path.
@@ -2236,6 +2717,12 @@ export class BaseEntity {
2236
2717
  provider?.SupportsEntityTransactions === true && provider.BeginEntityTransaction
2237
2718
  ? await provider.BeginEntityTransaction()
2238
2719
  : null;
2720
+ if (scope) {
2721
+ part = BaseEntity.beginUnitOfWork(enclosing, BaseEntity.captureChains(records));
2722
+ for (const record of records) {
2723
+ record._unitOfWork = part.unit;
2724
+ }
2725
+ }
2239
2726
  const result = await ExecuteEntitySavePlan(plan, {
2240
2727
  SaveOptions: childSaveOptions,
2241
2728
  DeleteOptions: childDeleteOptions,
@@ -2245,12 +2732,13 @@ export class BaseEntity {
2245
2732
  });
2246
2733
  if (!result.Success) {
2247
2734
  await scope?.Rollback();
2248
- this.revertGraphParticipants(participants);
2735
+ BaseEntity.settleRollback(part);
2249
2736
  this.registerGraphFailure(result.ErrorMessage, operation);
2250
2737
  this.RaiseEvent('graph_save', { Success: false, NodeCount: plan.NodeCount, Error: result.ErrorMessage });
2251
2738
  return false;
2252
2739
  }
2253
2740
  await scope?.Commit();
2741
+ BaseEntity.settleCommit(part);
2254
2742
  this.acceptCompanionChanges();
2255
2743
  this.RaiseEvent('graph_save', { Success: true, NodeCount: plan.NodeCount });
2256
2744
  return true;
@@ -2266,13 +2754,18 @@ export class BaseEntity {
2266
2754
  LogError(`BaseEntity.executeGraphLocal: rollback failed after graph error for ` +
2267
2755
  `${this.EntityInfo?.Name}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
2268
2756
  }
2269
- this.revertGraphParticipants(participants);
2757
+ BaseEntity.settleRollback(part);
2270
2758
  const detail = e instanceof Error ? e.message : String(e);
2271
2759
  LogError(`BaseEntity.executeGraphLocal failed for ${this.EntityInfo?.Name}: ${detail}`);
2272
2760
  this.registerGraphFailure(detail, operation);
2273
2761
  this.RaiseEvent('graph_save', { Success: false, NodeCount: plan.NodeCount, Error: detail });
2274
2762
  return false;
2275
2763
  }
2764
+ finally {
2765
+ records.forEach((record, index) => {
2766
+ record._unitOfWork = previousUnits[index];
2767
+ });
2768
+ }
2276
2769
  }
2277
2770
  /**
2278
2771
  * Persist this record as one already-planned graph node. Private: the public
@@ -2280,47 +2773,80 @@ export class BaseEntity {
2280
2773
  * binds this via {@link ExecuteEntitySavePlan}'s `SaveSelfOnly` callback.
2281
2774
  */
2282
2775
  saveAsGraphNode(options) {
2283
- return this._InnerSave(options);
2776
+ return this._innerSave(options);
2284
2777
  }
2285
2778
  /**
2286
2779
  * Delete-path counterpart of {@link saveAsGraphNode}.
2287
2780
  */
2288
2781
  deleteAsGraphNode(options) {
2289
- return this._InnerDelete(options);
2782
+ return this._innerDelete(options);
2290
2783
  }
2291
2784
  /**
2292
- * Captures saved/dirty baselines for every plan participant so a rolled-back
2293
- * graph can be retried without re-INSERTing a "saved" peer or skipping it.
2785
+ * Captures each record's IS-A chain, from its leaf up to its root, so a unit of work that rolls
2786
+ * back can put the chain back with {@link restoreChains}. A record in more than one chain is
2787
+ * captured once.
2294
2788
  */
2295
- captureGraphParticipants(plan) {
2296
- return plan.Nodes.map(node => ({
2297
- entity: node.Entity,
2298
- wasSaved: node.Entity.IsSaved,
2299
- oldValues: node.Entity.Fields.map(f => ({ name: f.Name, old: f.OldValue })),
2300
- }));
2789
+ static captureChains(entities) {
2790
+ const captured = new Set();
2791
+ const snapshots = [];
2792
+ for (const entity of entities) {
2793
+ for (let level = entity.LeafEntity; level && !captured.has(level); level = level._parentEntity) {
2794
+ captured.add(level);
2795
+ snapshots.push({
2796
+ entity: level,
2797
+ wasSaved: level._everSaved,
2798
+ wasLoaded: level._recordLoaded,
2799
+ resultHistory: [...level._resultHistory],
2800
+ fields: level.Fields.map(field => ({ field, state: field.GetState() })),
2801
+ });
2802
+ }
2803
+ }
2804
+ return snapshots;
2301
2805
  }
2302
2806
  /**
2303
- * Restores in-memory saved/dirty state after the database rolled the graph back.
2807
+ * Puts back, after a rollback, what the unit of work changed in memory. See {@link restoreSnapshot}.
2304
2808
  */
2305
- revertGraphParticipants(snapshots) {
2306
- for (const snap of snapshots) {
2307
- snap.entity.revertUncommittedGraphSave(snap);
2809
+ static restoreChains(snapshots) {
2810
+ for (const snapshot of snapshots) {
2811
+ snapshot.entity.restoreSnapshot(snapshot);
2308
2812
  }
2309
2813
  }
2310
2814
  /**
2311
- * After a graph node Save() the fields look clean and `_everSaved` is true.
2312
- * The DB rollback does not undo that. Restore the pre-attempt baseline so
2313
- * a retry still writes the peer and the owner FK still matches.
2815
+ * Puts this record back as a snapshot captured it, after the unit of work that finalized it
2816
+ * rolled back.
2817
+ *
2818
+ * A save's `finalizeSave()` rebuilds the record's fields from what its write returned, marks it
2819
+ * saved and empties its result history. The database undid the write, so the record gets back
2820
+ * its saved and loaded flags, its result history, and each field's value and tracking state. A
2821
+ * field edited since then keeps the edit, and is compared with its captured baseline again, since
2822
+ * that is what the database holds after the rollback. (A delete leaves nothing to put back: a
2823
+ * record deleted inside a unit of work isn't reset until the unit commits.)
2824
+ *
2825
+ * The history matters to the caller: a save records its failure only when nothing else did,
2826
+ * judged by the history's length when it started, which an emptied history would make wrong.
2827
+ *
2828
+ * A record still holding the captured field objects was never finalized, so nothing about it
2829
+ * changed and it is left alone.
2314
2830
  */
2315
- revertUncommittedGraphSave(snap) {
2316
- if (!snap.wasSaved) {
2317
- this._everSaved = false;
2318
- this._recordLoaded = false;
2831
+ restoreSnapshot(snapshot) {
2832
+ const finalized = snapshot.fields.some(({ field }) => this.GetFieldByName(field.Name) !== field);
2833
+ if (!finalized) {
2834
+ return;
2319
2835
  }
2320
- for (const captured of snap.oldValues) {
2321
- const field = this.GetFieldByName(captured.name);
2322
- if (field) {
2323
- field.RestoreOldValue(captured.old);
2836
+ this._everSaved = snapshot.wasSaved;
2837
+ this._recordLoaded = snapshot.wasLoaded;
2838
+ this._resultHistory = snapshot.resultHistory;
2839
+ this._compositeKey = null; // cached from the finalized key; rebuilt on the next read
2840
+ for (const { field: capturedField, state } of snapshot.fields) {
2841
+ const field = this.GetFieldByName(capturedField.Name);
2842
+ if (!field) {
2843
+ continue;
2844
+ }
2845
+ if (field.Dirty) {
2846
+ field.RestoreOldValue(state.OldValue);
2847
+ }
2848
+ else {
2849
+ field.RestoreState(state);
2324
2850
  }
2325
2851
  }
2326
2852
  }
@@ -2379,6 +2905,89 @@ export class BaseEntity {
2379
2905
  LogError(`Error rolling back entity transaction scope for ${this.EntityInfo?.Name}: ${rollbackError}`);
2380
2906
  }
2381
2907
  }
2908
+ /**
2909
+ * The failure path of a save: rolls back the transaction scope this entity holds, if any, then
2910
+ * settles this save's part in its unit of work (see {@link settleRollback}). `part` is null when
2911
+ * this save took none (see `_innerSave`), and then only the scope is rolled back.
2912
+ */
2913
+ async rollbackChainSave(part) {
2914
+ await this.rollbackEntityTransactionScope();
2915
+ BaseEntity.settleRollback(part);
2916
+ }
2917
+ /**
2918
+ * Starts a unit of work's part in a {@link UnitOfWork}: joins `enclosing` when the unit runs
2919
+ * inside one, and owns a new one when it doesn't. `snapshots` are the unit's records as they are
2920
+ * before it writes them. They join the unit's, so that the outermost unit can put them back.
2921
+ */
2922
+ static beginUnitOfWork(enclosing, snapshots) {
2923
+ const unit = enclosing ?? { Snapshots: [], DeletedRecords: [] };
2924
+ unit.Snapshots.push(...snapshots);
2925
+ return { unit, owns: !enclosing, snapshots, deletedMark: unit.DeletedRecords.length };
2926
+ }
2927
+ /**
2928
+ * Runs `work` on `record` as part of `unit` (see {@link UnitOfWork}), and gives the record back
2929
+ * the unit it was in before, however `work` ends. Used for an IS-A parent's delete, which runs
2930
+ * inside its child's chain, along with the graph of its companions if it has any.
2931
+ */
2932
+ static async inUnitOfWork(record, unit, work) {
2933
+ const previous = record._unitOfWork;
2934
+ record._unitOfWork = unit;
2935
+ try {
2936
+ return await work(record);
2937
+ }
2938
+ finally {
2939
+ record._unitOfWork = previous;
2940
+ }
2941
+ }
2942
+ /**
2943
+ * After a unit of work rolls back: puts its records back as they were before it wrote them, and
2944
+ * drops the deletes the rollback undid, so that nothing resets their records. The owner does this
2945
+ * for everything the unit and the units nested in it wrote. A nested part does it for its own
2946
+ * records only; when the owner then rolls back too, restoring them again changes nothing.
2947
+ */
2948
+ static settleRollback(part) {
2949
+ if (!part) {
2950
+ return;
2951
+ }
2952
+ BaseEntity.restoreChains(part.owns ? part.unit.Snapshots : part.snapshots);
2953
+ part.unit.DeletedRecords.length = part.owns ? 0 : part.deletedMark;
2954
+ }
2955
+ /**
2956
+ * After a unit of work commits: its owner resets the records it deleted. A nested part leaves
2957
+ * them to the owner, since the enclosing unit can still roll back.
2958
+ */
2959
+ static settleCommit(part) {
2960
+ if (part?.owns) {
2961
+ BaseEntity.resetDeletedRecords(part.unit.DeletedRecords);
2962
+ }
2963
+ }
2964
+ /**
2965
+ * Resets the records a unit of work deleted, once the unit has committed, as each record's own
2966
+ * `Delete()` does when no unit of work is involved. See {@link UnitOfWork}.
2967
+ *
2968
+ * Last-deleted first, skipping a record that no longer reads as saved. `NewRecord()` on a record
2969
+ * also resets every IS-A level above it, and relinks them. A chain's initiator records its delete
2970
+ * after its parents do, so it's reset first, and its parents with it. Resetting a parent again
2971
+ * after that would unlink it from its child and give it a key the child doesn't share.
2972
+ *
2973
+ * The unit has committed, so a reset that throws is logged and the rest still run: the deletes
2974
+ * stand whatever happens here.
2975
+ */
2976
+ static resetDeletedRecords(records) {
2977
+ for (let index = records.length - 1; index >= 0; index--) {
2978
+ const record = records[index];
2979
+ if (!record.IsSaved) {
2980
+ continue;
2981
+ }
2982
+ try {
2983
+ record.NewRecord();
2984
+ }
2985
+ catch (e) {
2986
+ LogError(`BaseEntity.resetDeletedRecords: resetting a deleted ${record.EntityInfo?.Name} record failed: ` +
2987
+ `${e instanceof Error ? e.message : String(e)}`);
2988
+ }
2989
+ }
2990
+ }
2382
2991
  /**
2383
2992
  * Returns this provider to be used for a given instance of a BaseEntity derived subclass. If the provider is not set, the BaseEntity.Provider is returned.
2384
2993
  */
@@ -2620,12 +3229,12 @@ export class BaseEntity {
2620
3229
  * Access to the underlying metadata for the entity object.
2621
3230
  */
2622
3231
  get EntityInfo() {
2623
- return this._EntityInfo;
3232
+ return this._entityInfo;
2624
3233
  }
2625
3234
  get Fields() {
2626
3235
  // Caller wants to walk Fields — promote raw data into real EntityField instances now.
2627
3236
  this.hydrateFieldsIfNeeded();
2628
- return this._Fields;
3237
+ return this._fields;
2629
3238
  }
2630
3239
  /**
2631
3240
  * Convenience method to access a field by name. This method is case-insensitive and will return null if the field is not found. You can do the same thing with more fine tune controlled by accessing the Fields property directly.
@@ -2641,7 +3250,7 @@ export class BaseEntity {
2641
3250
  const lcase = fieldName.trim().toLowerCase(); // do this once as we will use it multiple times
2642
3251
  if (this._fieldCache === null) {
2643
3252
  this._fieldCache = new Map();
2644
- for (const f of this._Fields) {
3253
+ for (const f of this._fields) {
2645
3254
  if (!this._fieldCache.has(f.Name.trim().toLowerCase())) {
2646
3255
  this._fieldCache.set(f.Name.trim().toLowerCase(), f);
2647
3256
  }
@@ -2704,7 +3313,7 @@ export class BaseEntity {
2704
3313
  if (this._codeNameCache === null) {
2705
3314
  this._codeNameCache = new Map();
2706
3315
  // First-write-wins on duplicate code names — matches prior Array.find() behavior
2707
- for (const f of this._Fields) {
3316
+ for (const f of this._fields) {
2708
3317
  const codeKey = f.CodeName.trim().toLowerCase();
2709
3318
  if (!this._codeNameCache.has(codeKey)) {
2710
3319
  this._codeNameCache.set(codeKey, f);
@@ -2726,7 +3335,7 @@ export class BaseEntity {
2726
3335
  // dirty. Avoid hydrating just to check.
2727
3336
  if (!this._fieldsHydrated)
2728
3337
  return this._parentEntity?.Dirty ?? false;
2729
- return this._Fields.some(f => f.Dirty) ||
3338
+ return this._fields.some(f => f.Dirty) ||
2730
3339
  (this._parentEntity?.Dirty ?? false);
2731
3340
  }
2732
3341
  finally {
@@ -2791,17 +3400,17 @@ export class BaseEntity {
2791
3400
  if (this._parentEntity && this._parentEntityFieldNames?.has(FieldName)) {
2792
3401
  this._parentEntity.Set(FieldName, Value); // recursive for N-level chains (parent asserts its own field)
2793
3402
  // Also mirror the value on our own virtual EntityField for UI compatibility
2794
- this.SetLocal(FieldName, Value);
3403
+ this.setLocal(FieldName, Value);
2795
3404
  }
2796
3405
  else {
2797
3406
  // Active-status enforcement choke point for own-field writes. Fast-path gated on the
2798
3407
  // memoized EntityInfo.HasInactiveFields so all-Active entities (the overwhelming majority)
2799
3408
  // pay only a single cached boolean check.
2800
3409
  if (this.EntityInfo?.HasInactiveFields) {
2801
- this.AssertFieldActiveStatus(FieldName, 'BaseEntity.Set');
3410
+ this.assertFieldActiveStatus(FieldName, 'BaseEntity.Set');
2802
3411
  }
2803
- this.AssertFieldReadable(FieldName);
2804
- this.SetLocal(FieldName, Value);
3412
+ this.assertFieldReadable(FieldName);
3413
+ this.setLocal(FieldName, Value);
2805
3414
  }
2806
3415
  }
2807
3416
  /**
@@ -2814,7 +3423,7 @@ export class BaseEntity {
2814
3423
  * Callers should gate this behind `EntityInfo.HasInactiveFields` so the common all-Active entity
2815
3424
  * skips the field lookup entirely.
2816
3425
  */
2817
- AssertFieldActiveStatus(fieldName, caller) {
3426
+ assertFieldActiveStatus(fieldName, caller) {
2818
3427
  const fi = this.EntityInfo?.FieldByName(fieldName);
2819
3428
  if (fi) {
2820
3429
  EntityFieldInfo.AssertEntityFieldActiveStatus(fi, caller);
@@ -2841,7 +3450,7 @@ export class BaseEntity {
2841
3450
  * build) reads `EntityField.Value` directly and never routes through here — that exemption
2842
3451
  * is load-bearing, not an oversight. Do not "fix" it.
2843
3452
  */
2844
- AssertFieldReadable(fieldName) {
3453
+ assertFieldReadable(fieldName) {
2845
3454
  const denied = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedReadFields(u));
2846
3455
  if (denied?.has(fieldName?.trim().toLowerCase())) {
2847
3456
  throw new FieldSecurityError(fieldName, this.EntityInfo.Name);
@@ -2851,7 +3460,7 @@ export class BaseEntity {
2851
3460
  * Internal helper that sets a field value directly on THIS entity's own Fields array
2852
3461
  * without IS-A routing. Used by Set() for own-fields and for mirroring parent field values.
2853
3462
  */
2854
- SetLocal(FieldName, Value) {
3463
+ setLocal(FieldName, Value) {
2855
3464
  const field = this.GetFieldByName(FieldName);
2856
3465
  if (field != null) {
2857
3466
  if (field.EntityFieldInfo.TSType === EntityFieldTSType.Date && (typeof Value === 'string' || typeof Value === 'number')) {
@@ -2912,12 +3521,12 @@ export class BaseEntity {
2912
3521
  // EntityInfo.HasInactiveFields so all-Active entities pay only a single cached boolean check
2913
3522
  // before the hot read paths below.
2914
3523
  if (this.EntityInfo?.HasInactiveFields) {
2915
- this.AssertFieldActiveStatus(FieldName, 'BaseEntity.Get');
3524
+ this.assertFieldActiveStatus(FieldName, 'BaseEntity.Get');
2916
3525
  }
2917
3526
  // Field security sits BEFORE the raw-mode fast path below, not after: an entity whose
2918
3527
  // fields are not yet hydrated would otherwise return denied values straight out of _raw
2919
3528
  // without the gate ever running.
2920
- this.AssertFieldReadable(FieldName);
3529
+ this.assertFieldReadable(FieldName);
2921
3530
  // Raw mode fast path: read directly from the cached data without building EntityField
2922
3531
  // instances. This is the dominant cost in engine warm-loads — generated typed getters
2923
3532
  // (e.g. `get Name() { return this.Get('Name'); }`) flow through here, so a consumer that
@@ -2931,7 +3540,7 @@ export class BaseEntity {
2931
3540
  // back into `_raw` — the row may be shared, frozen cache state. Fields needing no
2932
3541
  // conversion (the vast majority) never touch the memo at all, so the fast path stays
2933
3542
  // a single property read.
2934
- const fi = this._EntityInfo?.FieldByName(FieldName);
3543
+ const fi = this._entityInfo?.FieldByName(FieldName);
2935
3544
  if (fi?.TSType === EntityFieldTSType.Date && (typeof value === 'string' || typeof value === 'number')) {
2936
3545
  const memo = this._rawConverted?.get(FieldName);
2937
3546
  if (memo !== undefined)
@@ -2945,7 +3554,7 @@ export class BaseEntity {
2945
3554
  const memo = this._rawConverted?.get(FieldName);
2946
3555
  if (memo !== undefined)
2947
3556
  return memo;
2948
- value = value.replace(/ +$/, '');
3557
+ value = trimTrailingSpaces(value);
2949
3558
  this.memoizeRawConversion(FieldName, value);
2950
3559
  }
2951
3560
  return value;
@@ -2991,7 +3600,7 @@ export class BaseEntity {
2991
3600
  EntityFieldInfo.AssertEntityFieldActiveStatus(field.EntityFieldInfo, 'BaseEntity.SetMany');
2992
3601
  }
2993
3602
  // Use SetLocal here so we set on OUR fields (mirrors for parent fields)
2994
- this.SetLocal(key, object[key]);
3603
+ this.setLocal(key, object[key]);
2995
3604
  if (replaceOldValues) {
2996
3605
  field.ResetOldValue();
2997
3606
  }
@@ -3005,7 +3614,7 @@ export class BaseEntity {
3005
3614
  EntityFieldInfo.AssertEntityFieldActiveStatus(field.EntityFieldInfo, 'BaseEntity.SetMany');
3006
3615
  }
3007
3616
  // Use SetLocal here so we set on OUR fields (mirrors for parent fields)
3008
- this.SetLocal(field.Name, object[key]);
3617
+ this.setLocal(field.Name, object[key]);
3009
3618
  if (replaceOldValues) {
3010
3619
  field.ResetOldValue();
3011
3620
  }
@@ -3045,7 +3654,7 @@ export class BaseEntity {
3045
3654
  // load — without this, the parent would think it's a new record and attempt CREATE
3046
3655
  // instead of UPDATE on save.
3047
3656
  if (replaceOldValues) {
3048
- this.UpdateSavedStateFromPrimaryKeys();
3657
+ this.updateSavedStateFromPrimaryKeys();
3049
3658
  }
3050
3659
  }
3051
3660
  /**
@@ -3053,7 +3662,7 @@ export class BaseEntity {
3053
3662
  * a saved/loaded record. This enables entities populated via SetMany (e.g., IS-A parent
3054
3663
  * entities) to correctly recognize themselves as existing records.
3055
3664
  */
3056
- UpdateSavedStateFromPrimaryKeys() {
3665
+ updateSavedStateFromPrimaryKeys() {
3057
3666
  if (!this.PrimaryKeys || this.PrimaryKeys.length === 0)
3058
3667
  return;
3059
3668
  const allPKsSet = this.PrimaryKeys.every(pk => pk.Value !== null && pk.Value !== undefined);
@@ -3197,8 +3806,8 @@ export class BaseEntity {
3197
3806
  }
3198
3807
  if (params.includeRelatedEntityData) {
3199
3808
  // now, get the related entity data
3200
- for (let i = 0; i < this._EntityInfo.RelatedEntities.length; i++) {
3201
- const re = this._EntityInfo.RelatedEntities[i];
3809
+ for (let i = 0; i < this._entityInfo.RelatedEntities.length; i++) {
3810
+ const re = this._entityInfo.RelatedEntities[i];
3202
3811
  const pre = params.relatedEntityList ? params.relatedEntityList.find(r => r.relatedEntityName === re.RelatedEntity) : null;
3203
3812
  if (pre) {
3204
3813
  // pre now has the param that matches the related entity (re) that we are looking at
@@ -3256,7 +3865,7 @@ export class BaseEntity {
3256
3865
  this._compositeKey = null;
3257
3866
  this._resultHistory = [];
3258
3867
  this._recordLoaded = false;
3259
- this._Fields = [];
3868
+ this._fields = [];
3260
3869
  this._fieldsHydrated = false;
3261
3870
  this._raw = null;
3262
3871
  this._rawConverted = null;
@@ -3292,14 +3901,14 @@ export class BaseEntity {
3292
3901
  for (const rawField of this.EntityInfo.Fields) {
3293
3902
  const key = this.EntityInfo.Name + '.' + rawField.Name;
3294
3903
  const newField = MJGlobal.Instance.ClassFactory.CreateInstance(EntityField, key, rawField);
3295
- this._Fields.push(newField);
3904
+ this._fields.push(newField);
3296
3905
  }
3297
3906
  // 2. If we have raw data from LoadFromData, populate Fields. The first set of each Field
3298
3907
  // runs through the EntityField setter with `_NeverSet=true`, which simultaneously sets
3299
3908
  // Value AND OldValue (so the field is not marked dirty). This matches the old
3300
3909
  // SetMany(replaceOldValues=true) semantics on initial load.
3301
3910
  if (this._raw) {
3302
- for (const field of this._Fields) {
3911
+ for (const field of this._fields) {
3303
3912
  const value = this._raw[field.Name];
3304
3913
  if (value !== undefined) {
3305
3914
  // Populating fields from the loaded row writes EntityField.Value directly, which does
@@ -3408,10 +4017,12 @@ export class BaseEntity {
3408
4017
  // when setting keys.
3409
4018
  if (this._parentEntity) {
3410
4019
  this._parentEntity.NewRecord();
4020
+ // The parent's NewRecord() just nulled its back-link. Put this child back.
4021
+ this.linkParentBackToThisChild();
3411
4022
  for (const pk of this.EntityInfo.PrimaryKeys) {
3412
4023
  const parentValue = this._parentEntity.Get(pk.Name);
3413
4024
  if (parentValue != null) {
3414
- this.SetLocal(pk.Name, parentValue);
4025
+ this.setLocal(pk.Name, parentValue);
3415
4026
  // The shared PK is ReadOnly, so SetLocal above consumed its
3416
4027
  // one-time write (_NeverSet -> false). Restore _NeverSet so the
3417
4028
  // child's OWN mirror can be re-written if the shared key is set
@@ -3516,6 +4127,42 @@ export class BaseEntity {
3516
4127
  ClearRestoreContext() {
3517
4128
  this._restoreContext = null;
3518
4129
  }
4130
+ /**
4131
+ * Returns the active clone context for the next save, if any.
4132
+ *
4133
+ * Read by the data provider when generating the RecordChange SQL: when
4134
+ * non-null, the resulting RecordChange row is written with
4135
+ * `Source='Clone'` and `ChangeContext` populated with structured JSON provenance.
4136
+ * Returns null for ordinary saves.
4137
+ */
4138
+ get CloneContext() {
4139
+ return this._cloneContext;
4140
+ }
4141
+ /**
4142
+ * Marks the next Save() as part of a record clone operation.
4143
+ *
4144
+ * The provider will write a new RecordChange entry with `Source='Clone'`
4145
+ * and `ChangeContext` populated with structured JSON provenance.
4146
+ *
4147
+ * The context persists on the entity until either (a) overwritten by a
4148
+ * subsequent SetCloneContext() call or (b) explicitly cleared via
4149
+ * ClearCloneContext(). It is NOT auto-cleared inside Save() because
4150
+ * TransactionGroup execution is deferred.
4151
+ *
4152
+ * @param context Structured clone context. Throws if missing required fields.
4153
+ */
4154
+ SetCloneContext(context) {
4155
+ if (!context || !context.CloneLogID || !context.SourceRecordID || !context.RootEntityName) {
4156
+ throw new Error('BaseEntity.SetCloneContext: context is required with CloneLogID, SourceRecordID, and RootEntityName');
4157
+ }
4158
+ this._cloneContext = context;
4159
+ }
4160
+ /**
4161
+ * Clears any pending clone context. Safe to call when no context is set.
4162
+ */
4163
+ ClearCloneContext() {
4164
+ this._cloneContext = null;
4165
+ }
3519
4166
  /**
3520
4167
  * Saves the current state of the object to the database. Uses the active provider to handle the actual saving of the record.
3521
4168
  * If the record is new, it will be created, if it already exists, it will be updated.
@@ -3563,7 +4210,7 @@ export class BaseEntity {
3563
4210
  // Root.Save() would wait on the first _pendingSave$ (which is itself waiting
3564
4211
  // for the leaf chain), creating a circular wait that hangs forever.
3565
4212
  if (options?.IsParentEntitySave) {
3566
- return this._InnerSave(options);
4213
+ return this._innerSave(options);
3567
4214
  }
3568
4215
  // If a save is already in progress, return its promise. This check MUST run before graph
3569
4216
  // routing: a composite save is still a save, and two concurrent Save() calls on one record
@@ -3604,7 +4251,7 @@ export class BaseEntity {
3604
4251
  // Create a new observable that debounces duplicative calls, and executes the save.
3605
4252
  this._pendingSave$ = of(options).pipe(
3606
4253
  // Execute the actual save logic.
3607
- switchMap(opts => from(this._InnerSave(opts))),
4254
+ switchMap(opts => from(this._innerSave(opts))),
3608
4255
  // When the save completes (whether successfully or not), clear the pending save observable.
3609
4256
  finalize(() => { this._pendingSave$ = null; }),
3610
4257
  // Ensure that all subscribers get the same result.
@@ -3617,10 +4264,14 @@ export class BaseEntity {
3617
4264
  * @param options
3618
4265
  * @returns
3619
4266
  */
3620
- async _InnerSave(options) {
4267
+ async _innerSave(options) {
3621
4268
  const currentResultCount = this.ResultHistory.length;
3622
4269
  const newResult = new BaseEntityResult();
3623
4270
  newResult.StartedAt = new Date();
4271
+ // The unit of work this save runs inside, if any (see UnitOfWork), and this save's own part
4272
+ // in one, taken only when this entity starts an IS-A chain save whose writes a failure undoes.
4273
+ const enclosing = this._unitOfWork;
4274
+ let part = null;
3624
4275
  try {
3625
4276
  const initialDirtyState = this.Dirty; // save this because parent entity save cycle, if any, will clear their dirty flags
3626
4277
  const _options = options ? options : new EntitySaveOptions();
@@ -3638,7 +4289,20 @@ export class BaseEntity {
3638
4289
  // save — this joins it as a savepoint rather than starting a second physical
3639
4290
  // transaction. Before 6.2 this path called BeginISATransaction(), which was blind to
3640
4291
  // any existing transaction and produced torn writes; see EntityTransactionScope.
3641
- await this.beginEntityTransactionScope(isISAInitiator);
4292
+ const scopeOpened = await this.beginEntityTransactionScope(isISAInitiator);
4293
+ // Each level of the chain is finalized as saved and clean when its own write returns,
4294
+ // before the chain commits. A failure that undoes those writes has to put the chain
4295
+ // back in memory too, so the chain save is a unit of work, captured before the parents
4296
+ // save. The writes are undone when this save holds a scope, and on a provider without
4297
+ // entity transactions, which doesn't talk to a database directly: there each parent's
4298
+ // save is recorded in memory and the leaf's one write carries the chain
4299
+ // (GraphQLDataProvider). Anywhere else a level may really have written (in a
4300
+ // TransactionGroup, a level outside the group does), and marking it unsaved would make
4301
+ // the retry insert it twice.
4302
+ if (isISAInitiator && !this.TransactionGroup &&
4303
+ (scopeOpened || this.ProviderToUse?.SupportsEntityTransactions !== true)) {
4304
+ part = BaseEntity.beginUnitOfWork(enclosing, BaseEntity.captureChains([this]));
4305
+ }
3642
4306
  // Save parent chain first (root → branch → immediate parent)
3643
4307
  // Parent calls Save() recursively which handles its own parents, permissions, validation
3644
4308
  if (this._parentEntity) {
@@ -3653,8 +4317,9 @@ export class BaseEntity {
3653
4317
  parentSaveOptions.ISAActiveChildEntityName = this.EntityInfo.Name;
3654
4318
  const parentResult = await this._parentEntity.Save(parentSaveOptions); // we know parent entity exists hre
3655
4319
  if (!parentResult) {
3656
- // Parent save failed — rollback if we started the transaction
3657
- await this.rollbackEntityTransactionScope();
4320
+ // Parent save failed — roll back if we started the transaction, and put back
4321
+ // the levels above it that had already saved
4322
+ await this.rollbackChainSave(part);
3658
4323
  // RECORD the failure on THIS entity's ResultHistory before returning. Without
3659
4324
  // this the caller gets `false` with LatestResult === null and an empty
3660
4325
  // ResultHistory, because every result was written to the PARENT object — which
@@ -3765,7 +4430,7 @@ export class BaseEntity {
3765
4430
  }
3766
4431
  if (valResult.Success) {
3767
4432
  // Run registered PreSave hooks (e.g., tenant validation)
3768
- await this.RunPreSaveHooks();
4433
+ await this.runPreSaveHooks();
3769
4434
  // Optimistic-UI hook: every pre-flight check (Validate, ValidateAsync,
3770
4435
  // PreSave hooks) has passed and the DB write is imminent. Fire the optional
3771
4436
  // OnValidated callback so a UI can render the now-known-valid change before
@@ -3793,9 +4458,10 @@ export class BaseEntity {
3793
4458
  // this scope exists to prevent.
3794
4459
  if (result) {
3795
4460
  await this.commitEntityTransactionScope();
4461
+ BaseEntity.settleCommit(part);
3796
4462
  }
3797
4463
  else {
3798
- await this.rollbackEntityTransactionScope();
4464
+ await this.rollbackChainSave(part);
3799
4465
  }
3800
4466
  return result;
3801
4467
  }
@@ -3843,12 +4509,15 @@ export class BaseEntity {
3843
4509
  // commit, so later "successful" saves were silently non-durable. Committing an
3844
4510
  // empty scope writes nothing; it only releases the transaction.
3845
4511
  await this.commitEntityTransactionScope();
4512
+ BaseEntity.settleCommit(part);
3846
4513
  return true; // nothing to save since we're not dirty
3847
4514
  }
3848
4515
  }
3849
4516
  catch (e) {
3850
- // Roll back the scope this entity opened, if any. No-op when it holds none.
3851
- await this.rollbackEntityTransactionScope();
4517
+ // Roll back the scope this entity opened, if any (a no-op when it holds none), and put
4518
+ // back what the chain's saves changed in memory, this entity included when the commit
4519
+ // is what threw. Before the result below, which reads IsSaved and the old values.
4520
+ await this.rollbackChainSave(part);
3852
4521
  if (currentResultCount === this.ResultHistory.length) {
3853
4522
  // this means that NO new results were added to the history anywhere
3854
4523
  // so we need to add a new result to the history here
@@ -3867,7 +4536,7 @@ export class BaseEntity {
3867
4536
  * Runs all registered PreSave hooks. If any hook returns false or a string
3868
4537
  * (error message), the save is rejected by throwing an Error.
3869
4538
  */
3870
- async RunPreSaveHooks() {
4539
+ async runPreSaveHooks() {
3871
4540
  const preSaveHooks = GetDataHooks('PreSave');
3872
4541
  for (const hook of preSaveHooks) {
3873
4542
  const hookResult = await hook(this, this.ActiveUser);
@@ -3924,7 +4593,7 @@ export class BaseEntity {
3924
4593
  if (result)
3925
4594
  result.NewValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.Value }; }); // set the latest values here
3926
4595
  // Cache the record name for faster lookups (updates cache with potentially new name)
3927
- this.CacheRecordName();
4596
+ this.cacheRecordName();
3928
4597
  this.RaiseEvent('save', null, saveSubType);
3929
4598
  return true;
3930
4599
  }
@@ -3944,7 +4613,7 @@ export class BaseEntity {
3944
4613
  * Caches the entity record name in the provider's EntityRecordNameCache for faster lookups.
3945
4614
  * Called automatically after successful Load(), LoadFromData(), and Save() operations.
3946
4615
  */
3947
- CacheRecordName() {
4616
+ cacheRecordName() {
3948
4617
  // Only cache if we have a valid primary key
3949
4618
  if (!this.PrimaryKey || !this.PrimaryKey.HasValue) {
3950
4619
  return;
@@ -4235,9 +4904,12 @@ export class BaseEntity {
4235
4904
  if (!this.IsSaved) {
4236
4905
  this.init(); // wipe out current data if we're loading on top of existing record
4237
4906
  }
4238
- const data = await this.ProviderToUse.Load(this, CompositeKey, EntityRelationshipsToLoad, this.ActiveUser);
4907
+ const data = await this.readRowForLoad(CompositeKey, EntityRelationshipsToLoad);
4239
4908
  if (!data) {
4240
- LogError(`Error in BaseEntity.Load(${this.EntityInfo.Name}, Key: ${CompositeKey.ToString()}`);
4909
+ // A subtype-hint probe asks whether this row exists, so "no" is an answer, not an error
4910
+ if (!this._subtypeHintProbe) {
4911
+ BaseEntity.logLoadFoundNoRow(this.EntityInfo.Name, CompositeKey);
4912
+ }
4241
4913
  return false; // no data loaded, return false
4242
4914
  }
4243
4915
  // IS-A: hydrate parent chain from loaded data before populating self.
@@ -4273,7 +4945,7 @@ export class BaseEntity {
4273
4945
  this._everSaved = true; // Mark as saved since we loaded from database
4274
4946
  this._compositeKey = CompositeKey; // set the composite key to the one we just loaded
4275
4947
  // Cache the record name for faster lookups
4276
- this.CacheRecordName();
4948
+ this.cacheRecordName();
4277
4949
  // IS-A: discover child entity records after load completes.
4278
4950
  // This populates _childEntity so Save/Delete can delegate to the leaf.
4279
4951
  // Only runs for parent-type entities; idempotent via _childEntityDiscoveryDone.
@@ -4296,6 +4968,27 @@ export class BaseEntity {
4296
4968
  this._isLoading = false;
4297
4969
  }
4298
4970
  }
4971
+ /**
4972
+ * The provider's read of the row {@link InnerLoad} loads. While this record is a subtype-hint
4973
+ * probe, a throw from the read is recorded on the probe before it propagates, so the parent
4974
+ * that asked can tell it apart from a failure after the row loaded.
4975
+ */
4976
+ async readRowForLoad(key, relationshipsToLoad) {
4977
+ const user = this.ActiveUser;
4978
+ try {
4979
+ return await this.ProviderToUse.Load(this, key, relationshipsToLoad, user);
4980
+ }
4981
+ catch (e) {
4982
+ if (this._subtypeHintProbe) {
4983
+ this._subtypeHintProbe.RowReadFailed = true;
4984
+ }
4985
+ throw e;
4986
+ }
4987
+ }
4988
+ /** The error {@link InnerLoad} logs when the provider finds no row for the key. */
4989
+ static logLoadFoundNoRow(entityName, key) {
4990
+ LogError(`Error in BaseEntity.Load(${entityName}, Key: ${key.ToString()}`);
4991
+ }
4299
4992
  /**
4300
4993
  * Re-fetches the current record from the database using its existing primary key, replacing all in-memory field values
4301
4994
  * with the latest data from the database. This is useful when you know (or suspect) the record has been modified externally
@@ -4383,7 +5076,7 @@ export class BaseEntity {
4383
5076
  const isPlainObject = data && typeof data === 'object' && !Array.isArray(data) && !(data instanceof Date);
4384
5077
  const canTakeFastPath = isPlainObject &&
4385
5078
  !this._fieldsHydrated &&
4386
- this._Fields.length === 0 &&
5079
+ this._fields.length === 0 &&
4387
5080
  !this._parentEntity &&
4388
5081
  !this.EntityInfo?.IsParentType;
4389
5082
  if (canTakeFastPath) {
@@ -4439,7 +5132,7 @@ export class BaseEntity {
4439
5132
  }
4440
5133
  // Cache the record name for faster lookups if successfully loaded
4441
5134
  if (this._recordLoaded) {
4442
- this.CacheRecordName();
5135
+ this.cacheRecordName();
4443
5136
  // IS-A: discover child entity records after data load completes.
4444
5137
  await this.InitializeChildEntity();
4445
5138
  }
@@ -4614,6 +5307,12 @@ export class BaseEntity {
4614
5307
  return this.deleteGraph(plan, options);
4615
5308
  }
4616
5309
  }
5310
+ // IS-A parent chain deletes bypass the debounce, as parent chain saves do: the leaf's
5311
+ // call back up the chain would otherwise wait on the pending delete that handed the
5312
+ // delete to the leaf, and Delete() would never return (MJ#4850).
5313
+ if (options?.IsParentEntityDelete) {
5314
+ return this._innerDelete(options);
5315
+ }
4617
5316
  // If a delete is already in progress, return its promise.
4618
5317
  if (this._pendingDelete$) {
4619
5318
  return firstValueFrom(this._pendingDelete$);
@@ -4621,7 +5320,7 @@ export class BaseEntity {
4621
5320
  // Create a new observable that debounces duplicative calls, and executes the delete.
4622
5321
  this._pendingDelete$ = of(options).pipe(
4623
5322
  // Execute the actual delete logic.
4624
- switchMap(opts => from(this._InnerDelete(opts))),
5323
+ switchMap(opts => from(this._innerDelete(opts))),
4625
5324
  // When the delete completes (whether successfully or not), clear the pending delete observable.
4626
5325
  finalize(() => { this._pendingDelete$ = null; }),
4627
5326
  // Ensure that all subscribers get the same result.
@@ -4634,10 +5333,17 @@ export class BaseEntity {
4634
5333
  * @param options
4635
5334
  * @returns
4636
5335
  */
4637
- async _InnerDelete(options) {
5336
+ async _innerDelete(options) {
4638
5337
  const currentResultCount = this.ResultHistory.length;
4639
5338
  const newResult = new BaseEntityResult();
4640
5339
  newResult.StartedAt = new Date();
5340
+ // The unit of work this delete runs inside, if any (see UnitOfWork), and this delete's own
5341
+ // part in one, taken when it opens a scope for an IS-A chain.
5342
+ const enclosing = this._unitOfWork;
5343
+ let part = null;
5344
+ // Set once this record's own row is deleted. From then on its history holds the provider's
5345
+ // entry for that delete, which doesn't report a failure later in the chain.
5346
+ let ownRowDeleted = false;
4641
5347
  try {
4642
5348
  const _options = options ? options : new EntityDeleteOptions();
4643
5349
  // IS-A leaf delegation: if this entity has a child entity and we are NOT
@@ -4684,7 +5390,15 @@ export class BaseEntity {
4684
5390
  // Open (or join) a transaction scope for the parent chain — see the matching
4685
5391
  // comment in _InnerSave and EntityTransactionScope for why this is provider-
4686
5392
  // arbitrated rather than IS-A-specific.
4687
- await this.beginEntityTransactionScope(isISAInitiator);
5393
+ const scopeOpened = await this.beginEntityTransactionScope(isISAInitiator);
5394
+ // A chain delete that holds a scope is a unit of work. Each parent is deleted inside
5395
+ // it and isn't reset when its delete returns: the unit resets the chain once it
5396
+ // commits, so a failure that rolls it back leaves every level as it was. Without a
5397
+ // scope each delete stands as it runs and resets its record at once, as before.
5398
+ if (scopeOpened) {
5399
+ part = BaseEntity.beginUnitOfWork(enclosing, BaseEntity.captureChains([this]));
5400
+ }
5401
+ const unit = part?.unit ?? enclosing;
4688
5402
  this.CheckPermissions(EntityPermissionType.Delete, true); // this will throw an error and exit out if we don't have permission
4689
5403
  // Raise delete_started event before the actual delete operation begins
4690
5404
  this.RaiseEvent('delete_started', null);
@@ -4699,6 +5413,7 @@ export class BaseEntity {
4699
5413
  });
4700
5414
  // Delete OWN row first (FK constraint: child must be deleted before parent)
4701
5415
  if (await this.ProviderToUse.Delete(this, _options, this.ActiveUser)) {
5416
+ ownRowDeleted = true;
4702
5417
  // IS-A: after own delete succeeds, cascade to parent chain
4703
5418
  if (hasParentChain) {
4704
5419
  // For overlapping subtypes, check if other children still reference
@@ -4710,38 +5425,40 @@ export class BaseEntity {
4710
5425
  parentDeleteOptions.SkipEntityActions = _options.SkipEntityActions;
4711
5426
  parentDeleteOptions.ReplayOnly = _options.ReplayOnly;
4712
5427
  parentDeleteOptions.IsParentEntityDelete = true;
4713
- const parentResult = await this._parentEntity.Delete(parentDeleteOptions);
5428
+ // The parent's delete belongs to this chain's unit of work, so it doesn't
5429
+ // reset the parent when it returns: the unit does, once it commits.
5430
+ const parentResult = await BaseEntity.inUnitOfWork(this._parentEntity, unit, parent => parent.Delete(parentDeleteOptions));
4714
5431
  if (!parentResult) {
4715
5432
  // Parent delete failed — rollback if we started the transaction
4716
5433
  await this.rollbackEntityTransactionScope();
5434
+ BaseEntity.settleRollback(part);
4717
5435
  // RECORD the failure on THIS entity's ResultHistory before returning —
4718
- // symmetric with the parent-SAVE-failure path in _InnerSave. Without this
4719
- // the caller gets `false` with LatestResult === null and an empty
4720
- // ResultHistory, because every result was written to the PARENT object,
4721
- // which callers have no reference to (`_parentEntity` is private). Note
4722
- // THIS entity's own row was already deleted successfully above; it is the
4723
- // parent-chain delete that failed and rolled the transaction back.
4724
- if (currentResultCount === this.ResultHistory.length) {
4725
- const parentLatest = this._parentEntity.LatestResult;
4726
- const parentErrors = parentLatest?.Errors ?? [];
4727
- // A failed parent commonly reports its detail ONLY in Errors, so fall
4728
- // back to the error text rather than a message that says nothing.
4729
- const detail = parentLatest?.Message ||
4730
- parentErrors.map(e => e?.Message ?? String(e)).filter(Boolean).join('; ') ||
4731
- 'no error detail was reported by the parent';
4732
- newResult.Success = false;
4733
- newResult.Type = 'delete';
4734
- newResult.Message =
4735
- `Failed to delete parent entity '${this._parentEntity.EntityInfo?.Name}': ${detail}`;
4736
- // Surface the parent's field-level errors so the caller can act on them.
4737
- newResult.Errors = parentErrors;
4738
- // When `detail` was built from `parentErrors` (no parent Message), `Message` already renders
4739
- // them — say so, or CompleteMessage repeats every one.
4740
- newResult.MessageIncludesErrors = !parentLatest?.Message && parentErrors.length > 0;
4741
- newResult.OriginalValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.OldValue }; });
4742
- newResult.EndedAt = new Date();
4743
- this.RegisterResultHistoryEntry(newResult);
4744
- }
5436
+ // symmetric with the parent-SAVE-failure path in _InnerSave. Every result
5437
+ // for the parent's failure was written to the PARENT object, which callers
5438
+ // have no reference to (`_parentEntity` is private). THIS entity's own row
5439
+ // was already deleted successfully above, and the only entry its history
5440
+ // gained is the provider's for that delete, which the rollback just undid.
5441
+ // So the failure is recorded whatever that entry says: skipping it when
5442
+ // the history had grown left the caller holding the provider's entry.
5443
+ const parentLatest = this._parentEntity.LatestResult;
5444
+ const parentErrors = parentLatest?.Errors ?? [];
5445
+ // A failed parent commonly reports its detail ONLY in Errors, so fall
5446
+ // back to the error text rather than a message that says nothing.
5447
+ const detail = parentLatest?.Message ||
5448
+ parentErrors.map(e => e?.Message ?? String(e)).filter(Boolean).join('; ') ||
5449
+ 'no error detail was reported by the parent';
5450
+ newResult.Success = false;
5451
+ newResult.Type = 'delete';
5452
+ newResult.Message =
5453
+ `Failed to delete parent entity '${this._parentEntity.EntityInfo?.Name}': ${detail}`;
5454
+ // Surface the parent's field-level errors so the caller can act on them.
5455
+ newResult.Errors = parentErrors;
5456
+ // When `detail` was built from `parentErrors` (no parent Message), `Message` already renders
5457
+ // them — say so, or CompleteMessage repeats every one.
5458
+ newResult.MessageIncludesErrors = !parentLatest?.Message && parentErrors.length > 0;
5459
+ newResult.OriginalValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.OldValue }; });
5460
+ newResult.EndedAt = new Date();
5461
+ this.RegisterResultHistoryEntry(newResult);
4745
5462
  return false;
4746
5463
  }
4747
5464
  }
@@ -4752,8 +5469,16 @@ export class BaseEntity {
4752
5469
  // NOT part of a transaction - raise event immediately
4753
5470
  // record deleted correctly
4754
5471
  this.RaiseEvent('delete', { OldValues: oldVals });
4755
- // wipe out the current data to flush out the DIRTY flags by calling NewRecord()
4756
- this.NewRecord(); // will trigger a new record event here too
5472
+ if (unit) {
5473
+ // Deleted inside a unit of work, which resets the record once it commits:
5474
+ // now, when this delete owns the unit and has just committed it.
5475
+ unit.DeletedRecords.push(this);
5476
+ BaseEntity.settleCommit(part);
5477
+ }
5478
+ else {
5479
+ // wipe out the current data to flush out the DIRTY flags by calling NewRecord()
5480
+ this.NewRecord(); // will trigger a new record event here too
5481
+ }
4757
5482
  }
4758
5483
  else {
4759
5484
  // part of a transaction, wait for the transaction to submit successfully and then
@@ -4796,6 +5521,7 @@ export class BaseEntity {
4796
5521
  // every subsequent "committed" write on this provider silently never commits.
4797
5522
  // (Also: don't wipe out the entity like we do when the Delete() worked.)
4798
5523
  await this.rollbackEntityTransactionScope();
5524
+ BaseEntity.settleRollback(part);
4799
5525
  return false;
4800
5526
  }
4801
5527
  }
@@ -4803,9 +5529,11 @@ export class BaseEntity {
4803
5529
  catch (e) {
4804
5530
  // Roll back the scope this entity opened, if any. No-op when it holds none.
4805
5531
  await this.rollbackEntityTransactionScope();
4806
- if (currentResultCount === this.ResultHistory.length) {
4807
- // this means that NO new results were added to the history anywhere
4808
- // so we need to add a new result to the history here
5532
+ BaseEntity.settleRollback(part);
5533
+ // Record the failure when nothing else did. Once this record's own row was deleted, its
5534
+ // history holds the provider's entry for that delete, which doesn't report this failure
5535
+ // (a failed commit, most often), so the failure is recorded then too.
5536
+ if (ownRowDeleted || currentResultCount === this.ResultHistory.length) {
4809
5537
  newResult.Success = false;
4810
5538
  newResult.Type = 'delete';
4811
5539
  newResult.Message = e.message || null;
@@ -4892,13 +5620,13 @@ export class BaseEntity {
4892
5620
  if (!entityInfo) {
4893
5621
  return { LeafEntityName: entityName, IsLeaf: true };
4894
5622
  }
4895
- return BaseEntity.ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser, md);
5623
+ return BaseEntity.resolveLeafEntityRecursive(entityInfo, primaryKey, contextUser, md);
4896
5624
  }
4897
5625
  /**
4898
5626
  * Internal recursive helper for leaf entity resolution.
4899
5627
  * Walks down the child hierarchy until no more children are found.
4900
5628
  */
4901
- static async ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser, provider) {
5629
+ static async resolveLeafEntityRecursive(entityInfo, primaryKey, contextUser, provider) {
4902
5630
  const childEntities = entityInfo.ChildEntities;
4903
5631
  if (childEntities.length === 0) {
4904
5632
  return { LeafEntityName: entityInfo.Name, IsLeaf: true };
@@ -4918,7 +5646,7 @@ export class BaseEntity {
4918
5646
  }, contextUser);
4919
5647
  if (result?.Success && result.Results?.length > 0) {
4920
5648
  // Found a child — recurse to see if there's an even more specific leaf
4921
- return BaseEntity.ResolveLeafEntityRecursive(child, primaryKey, contextUser, provider);
5649
+ return BaseEntity.resolveLeafEntityRecursive(child, primaryKey, contextUser, provider);
4922
5650
  }
4923
5651
  }
4924
5652
  // No child found — this entity IS the leaf