@memberjunction/core 6.1.4 → 6.2.0-edge.1

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 (125) hide show
  1. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts +14 -0
  2. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts.map +1 -1
  3. package/dist/generic/JSONType-interfaces/IQueryConfiguration.d.ts +60 -0
  4. package/dist/generic/JSONType-interfaces/IQueryConfiguration.d.ts.map +1 -0
  5. package/dist/generic/JSONType-interfaces/IQueryConfiguration.js +6 -0
  6. package/dist/generic/JSONType-interfaces/IQueryConfiguration.js.map +1 -0
  7. package/dist/generic/QueryCache.d.ts +17 -0
  8. package/dist/generic/QueryCache.d.ts.map +1 -1
  9. package/dist/generic/QueryCache.js +25 -5
  10. package/dist/generic/QueryCache.js.map +1 -1
  11. package/dist/generic/RegisterForStartup.d.ts +5 -5
  12. package/dist/generic/RegisterForStartup.js +12 -12
  13. package/dist/generic/applicationInfo.d.ts +4 -2
  14. package/dist/generic/applicationInfo.d.ts.map +1 -1
  15. package/dist/generic/applicationInfo.js +13 -9
  16. package/dist/generic/applicationInfo.js.map +1 -1
  17. package/dist/generic/baseEngine.d.ts +16 -1
  18. package/dist/generic/baseEngine.d.ts.map +1 -1
  19. package/dist/generic/baseEngine.js +61 -20
  20. package/dist/generic/baseEngine.js.map +1 -1
  21. package/dist/generic/baseEngineRegistry.d.ts +22 -9
  22. package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
  23. package/dist/generic/baseEngineRegistry.js +43 -25
  24. package/dist/generic/baseEngineRegistry.js.map +1 -1
  25. package/dist/generic/baseEntity.d.ts +232 -18
  26. package/dist/generic/baseEntity.d.ts.map +1 -1
  27. package/dist/generic/baseEntity.js +581 -113
  28. package/dist/generic/baseEntity.js.map +1 -1
  29. package/dist/generic/baseInfo.d.ts +12 -0
  30. package/dist/generic/baseInfo.d.ts.map +1 -1
  31. package/dist/generic/baseInfo.js +22 -1
  32. package/dist/generic/baseInfo.js.map +1 -1
  33. package/dist/generic/column-descriptors.d.ts +25 -5
  34. package/dist/generic/column-descriptors.d.ts.map +1 -1
  35. package/dist/generic/column-descriptors.js +45 -5
  36. package/dist/generic/column-descriptors.js.map +1 -1
  37. package/dist/generic/compositeKey.d.ts +1 -1
  38. package/dist/generic/compositeKey.js +2 -2
  39. package/dist/generic/data-table.js +2 -2
  40. package/dist/generic/data-table.js.map +1 -1
  41. package/dist/generic/databaseProviderBase.d.ts +7 -0
  42. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  43. package/dist/generic/databaseProviderBase.js +19 -4
  44. package/dist/generic/databaseProviderBase.js.map +1 -1
  45. package/dist/generic/entityInfo.d.ts +209 -38
  46. package/dist/generic/entityInfo.d.ts.map +1 -1
  47. package/dist/generic/entityInfo.js +362 -77
  48. package/dist/generic/entityInfo.js.map +1 -1
  49. package/dist/generic/entitySubtypeResolver.d.ts +51 -5
  50. package/dist/generic/entitySubtypeResolver.d.ts.map +1 -1
  51. package/dist/generic/entitySubtypeResolver.js +50 -3
  52. package/dist/generic/entitySubtypeResolver.js.map +1 -1
  53. package/dist/generic/filters/compositeFilter.d.ts +19 -19
  54. package/dist/generic/filters/compositeFilter.js +60 -60
  55. package/dist/generic/graphqlTypeNames.d.ts +6 -0
  56. package/dist/generic/graphqlTypeNames.d.ts.map +1 -1
  57. package/dist/generic/graphqlTypeNames.js +18 -6
  58. package/dist/generic/graphqlTypeNames.js.map +1 -1
  59. package/dist/generic/interfaces.d.ts +35 -1
  60. package/dist/generic/interfaces.d.ts.map +1 -1
  61. package/dist/generic/interfaces.js +3 -3
  62. package/dist/generic/interfaces.js.map +1 -1
  63. package/dist/generic/logging.d.ts +2 -0
  64. package/dist/generic/logging.d.ts.map +1 -1
  65. package/dist/generic/logging.js +7 -3
  66. package/dist/generic/logging.js.map +1 -1
  67. package/dist/generic/metadata.d.ts +1 -1
  68. package/dist/generic/metadata.js +3 -3
  69. package/dist/generic/providerBase.d.ts +35 -7
  70. package/dist/generic/providerBase.d.ts.map +1 -1
  71. package/dist/generic/providerBase.js +87 -27
  72. package/dist/generic/providerBase.js.map +1 -1
  73. package/dist/generic/queryInfo.d.ts +47 -0
  74. package/dist/generic/queryInfo.d.ts.map +1 -1
  75. package/dist/generic/queryInfo.js +76 -0
  76. package/dist/generic/queryInfo.js.map +1 -1
  77. package/dist/generic/queryInfoInterfaces.d.ts +6 -0
  78. package/dist/generic/queryInfoInterfaces.d.ts.map +1 -1
  79. package/dist/generic/queryResultEnricher.d.ts +2 -0
  80. package/dist/generic/queryResultEnricher.d.ts.map +1 -1
  81. package/dist/generic/queryResultEnricher.js +5 -1
  82. package/dist/generic/queryResultEnricher.js.map +1 -1
  83. package/dist/generic/querySQLFilters.d.ts +4 -0
  84. package/dist/generic/querySQLFilters.d.ts.map +1 -1
  85. package/dist/generic/querySQLFilters.js +10 -2
  86. package/dist/generic/querySQLFilters.js.map +1 -1
  87. package/dist/generic/relatedRecordCollection.d.ts +2 -0
  88. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  89. package/dist/generic/relatedRecordCollection.js +5 -1
  90. package/dist/generic/relatedRecordCollection.js.map +1 -1
  91. package/dist/generic/remoteOperationDispatch.d.ts +2 -0
  92. package/dist/generic/remoteOperationDispatch.d.ts.map +1 -1
  93. package/dist/generic/remoteOperationDispatch.js +5 -1
  94. package/dist/generic/remoteOperationDispatch.js.map +1 -1
  95. package/dist/generic/runQuerySQLFilterImplementations.d.ts +8 -0
  96. package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
  97. package/dist/generic/runQuerySQLFilterImplementations.js +20 -4
  98. package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
  99. package/dist/generic/schemaHydration.d.ts +8 -0
  100. package/dist/generic/schemaHydration.d.ts.map +1 -1
  101. package/dist/generic/schemaHydration.js +21 -5
  102. package/dist/generic/schemaHydration.js.map +1 -1
  103. package/dist/generic/securityInfo.d.ts +11 -9
  104. package/dist/generic/securityInfo.d.ts.map +1 -1
  105. package/dist/generic/securityInfo.js +31 -27
  106. package/dist/generic/securityInfo.js.map +1 -1
  107. package/dist/generic/telemetryManager.d.ts +16 -0
  108. package/dist/generic/telemetryManager.d.ts.map +1 -1
  109. package/dist/generic/telemetryManager.js +52 -20
  110. package/dist/generic/telemetryManager.js.map +1 -1
  111. package/dist/generic/transactionGroup.d.ts +1 -1
  112. package/dist/generic/transactionGroup.js +4 -4
  113. package/dist/generic/transactionGroup.js.map +1 -1
  114. package/dist/generic/util.d.ts +22 -0
  115. package/dist/generic/util.d.ts.map +1 -1
  116. package/dist/generic/util.js +33 -0
  117. package/dist/generic/util.js.map +1 -1
  118. package/dist/index.d.ts +1 -0
  119. package/dist/index.d.ts.map +1 -1
  120. package/dist/index.js +1 -0
  121. package/dist/index.js.map +1 -1
  122. package/dist/views/viewInfo.d.ts +2 -2
  123. package/dist/views/viewInfo.js +13 -13
  124. package/dist/views/viewInfo.js.map +1 -1
  125. 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 } 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,25 @@ 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
555
  * Used by graph rollback so a retried save still sees the pre-attempt dirty set.
544
556
  */
545
557
  RestoreOldValue(value) {
546
- this._OldValue = value;
558
+ this._oldValue = value;
547
559
  }
548
560
  /**
549
561
  * @deprecated No-op as of the active-status relocation. Active-status (deprecated/disabled)
@@ -563,7 +575,7 @@ let EntityField = class EntityField {
563
575
  * Returns the old value of the field. This is the value that was set when the field was last loaded from the database.
564
576
  */
565
577
  get OldValue() {
566
- return this._OldValue;
578
+ return this._oldValue;
567
579
  }
568
580
  };
569
581
  EntityField = EntityField_1 = __decorate([
@@ -714,6 +726,10 @@ export class BaseEntityResult {
714
726
  */
715
727
  export class BaseEntityEvent {
716
728
  }
729
+ /** Entity names compare the way metadata lookups do: trimmed and case-insensitive. */
730
+ function sameEntityName(a, b) {
731
+ return a.trim().toLowerCase() === b.trim().toLowerCase();
732
+ }
717
733
  export class BaseEntity {
718
734
  /**
719
735
  * Maximum number of `BaseEntityResult` entries retained in `_resultHistory` per entity
@@ -791,7 +807,7 @@ export class BaseEntity {
791
807
  * `BaseEntity` are reserved for paths that have already triggered hydration or paths that
792
808
  * deliberately want to inspect hydration state (e.g., `Dirty` getter).
793
809
  */
794
- this._Fields = [];
810
+ this._fields = [];
795
811
  /**
796
812
  * Tracks whether `_Fields` has been populated. `false` while in "raw mode" (data accessible
797
813
  * via `_raw` but no EntityField instances built yet); flips to `true` after the first call to
@@ -956,6 +972,17 @@ export class BaseEntity {
956
972
  * loaded record. Reset to false on NewRecord() and init().
957
973
  */
958
974
  this._childEntityDiscoveryDone = false;
975
+ /**
976
+ * Set only while this record's parent loads it because the parent's subtype rule named it
977
+ * (see {@link tryLinkHintedChildEntity}); read by {@link InnerLoad}.
978
+ */
979
+ this._subtypeHintProbe = null;
980
+ /**
981
+ * Set only while {@link AttachToParent} loads this record as the parent it promotes. Child
982
+ * discovery then doesn't ask the subtype rule: the child row being added doesn't exist yet, so
983
+ * a hint would name it and miss.
984
+ */
985
+ this._skipSubtypeLoadHint = false;
959
986
  /**
960
987
  * For **overlapping** subtype parents (AllowMultipleSubtypes = true), stores
961
988
  * the list of child entity type names that have records for this PK.
@@ -1037,7 +1064,7 @@ export class BaseEntity {
1037
1064
  */
1038
1065
  this._skipEmbeddings = false;
1039
1066
  this._eventSubject = new Subject();
1040
- this._EntityInfo = Entity;
1067
+ this._entityInfo = Entity;
1041
1068
  EntityInfo.AssertEntityActiveStatus(Entity, 'BaseEntity::constructor');
1042
1069
  this._provider = Provider;
1043
1070
  this.init();
@@ -1165,7 +1192,16 @@ export class BaseEntity {
1165
1192
  name: pk.Name,
1166
1193
  value: this._parentEntity.Get(pk.Name),
1167
1194
  }));
1168
- const loaded = await this._parentEntity.InnerLoad(parentKey);
1195
+ // The parent's subtype rule isn't asked for a load hint here: it would name the child this
1196
+ // promotion is adding, whose row doesn't exist yet, and the miss would cost a round trip.
1197
+ this._parentEntity._skipSubtypeLoadHint = true;
1198
+ let loaded;
1199
+ try {
1200
+ loaded = await this._parentEntity.InnerLoad(parentKey);
1201
+ }
1202
+ finally {
1203
+ this._parentEntity._skipSubtypeLoadHint = false;
1204
+ }
1169
1205
  if (!loaded) {
1170
1206
  // Restore the fresh chain the failed load destroyed: re-seed the parent chain, then put
1171
1207
  // the ORIGINAL minted key back (Set routes to the root), so the record the caller holds
@@ -1197,7 +1233,7 @@ export class BaseEntity {
1197
1233
  if (!this.GetFieldByName(name)) {
1198
1234
  return;
1199
1235
  }
1200
- this.SetLocal(name, value);
1236
+ this.setLocal(name, value);
1201
1237
  this.GetFieldByName(name)?.ResetNeverSetFlag();
1202
1238
  }
1203
1239
  /**
@@ -1211,6 +1247,10 @@ export class BaseEntity {
1211
1247
  * This ensures that Save/Delete operations always delegate to the leaf entity,
1212
1248
  * running the full validation and event chain at every level.
1213
1249
  *
1250
+ * For a disjoint parent, the entity's subtype rule is asked first (see
1251
+ * {@link resolveSubtypeLoadHint}); when it names a child, that child's load, which
1252
+ * happens anyway, checks the answer, and the discovery query runs only on a miss.
1253
+ *
1214
1254
  * Must be called AFTER a record is loaded (PK must be available).
1215
1255
  * Skipped for entities that are not parent types or have already been discovered.
1216
1256
  */
@@ -1225,12 +1265,48 @@ export class BaseEntity {
1225
1265
  await this.discoverOverlappingChildren();
1226
1266
  }
1227
1267
  else {
1228
- // Disjoint: discover single child, auto-chain (current behavior)
1229
- const childEntityName = await this.discoverChildEntityName();
1230
- if (!childEntityName)
1268
+ // Disjoint: discover the single child and auto-chain it
1269
+ await this.initializeDisjointChildEntity();
1270
+ }
1271
+ }
1272
+ /**
1273
+ * Finds and links the one child of a loaded disjoint parent.
1274
+ *
1275
+ * Without a hint this is one discovery query plus the child's load. With a hint from the
1276
+ * entity's subtype rule, the child's load checks the hint, and the discovery query runs
1277
+ * only when that load comes back empty or wasn't tried.
1278
+ *
1279
+ * For well-formed data the outcome is the one the discovery query alone would give: a hint
1280
+ * changes the number of round trips, not which child is linked or whether the load succeeds.
1281
+ * Three benign exceptions:
1282
+ * - a failure reading the hinted child's row falls back to the discovery query, so a transient
1283
+ * failure now recovers (a lasting one reads the row twice, then fails as before);
1284
+ * - when the data breaks the disjoint rule with two child rows, the discovery query's
1285
+ * unordered `UNION ALL` links either one, where a hint links the one the rule names;
1286
+ * - a provider without `FindISAChildEntity` links nothing without a hint, and the hinted child
1287
+ * with one.
1288
+ */
1289
+ async initializeDisjointChildEntity() {
1290
+ const hintedName = await this.resolveSubtypeLoadHint();
1291
+ const hintOutcome = hintedName
1292
+ ? await this.tryLinkHintedChildEntity(hintedName)
1293
+ : 'NotTried';
1294
+ if (hintOutcome === 'Linked')
1295
+ return;
1296
+ const childEntityName = await this.discoverChildEntityName();
1297
+ if (hintedName && hintOutcome === 'NotFound') {
1298
+ if (childEntityName && sameEntityName(childEntityName, hintedName)) {
1299
+ // The row exists but its load came back empty (for example, a row filter hides it).
1300
+ // Loading it again would too, and the discovery-only path unlinks it the same way,
1301
+ // after logging the empty load that the hinted attempt kept quiet.
1302
+ BaseEntity.logLoadFoundNoRow(hintedName, this.PrimaryKey);
1231
1303
  return;
1232
- await this.createAndLinkChildEntity(childEntityName);
1304
+ }
1305
+ this.reportSubtypeLoadHintMiss(hintedName, childEntityName);
1233
1306
  }
1307
+ if (!childEntityName)
1308
+ return;
1309
+ await this.createAndLinkChildEntity(childEntityName);
1234
1310
  }
1235
1311
  /**
1236
1312
  * Uses the provider's FindISAChildEntity method (single UNION ALL query) to
@@ -1263,12 +1339,29 @@ export class BaseEntity {
1263
1339
  * Creates the child entity instance, wires up the shared instance chain
1264
1340
  * (child._parentEntity = this, this._childEntity = child), loads the
1265
1341
  * child's data, and recursively discovers further children.
1342
+ *
1343
+ * @returns true when the child's row loaded and the child is linked; false when the load
1344
+ * came back empty, in which case the link is undone.
1266
1345
  */
1267
1346
  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).
1347
+ const childEntity = await this.createChildEntityObject(childEntityName);
1348
+ return this.linkAndLoadChildEntity(childEntity);
1349
+ }
1350
+ /**
1351
+ * Creates an unlinked child entity instance through this entity's provider, so the child
1352
+ * shares the same connection (correct in multi-provider scenarios — never the global default).
1353
+ */
1354
+ async createChildEntityObject(childEntityName) {
1270
1355
  const childProvider = this.ProviderToUse;
1271
- const childEntity = await childProvider.GetEntityObject(childEntityName, this._contextCurrentUser);
1356
+ return childProvider.GetEntityObject(childEntityName, this._contextCurrentUser);
1357
+ }
1358
+ /**
1359
+ * Links `childEntity` into this entity's chain and loads its row under the shared key.
1360
+ *
1361
+ * @returns true when the row loaded and the child is linked; false when the load came back
1362
+ * empty, in which case the link is undone.
1363
+ */
1364
+ async linkAndLoadChildEntity(childEntity) {
1272
1365
  // Wire up the shared instance chain: child's parent IS this entity (same object)
1273
1366
  // We need to replace the child's auto-initialized parent with our existing instance
1274
1367
  this.replaceChildParentChain(childEntity);
@@ -1280,20 +1373,245 @@ export class BaseEntity {
1280
1373
  if (!loaded) {
1281
1374
  // Load failed — clean up the link
1282
1375
  this._childEntity = null;
1283
- return;
1376
+ return false;
1284
1377
  }
1285
1378
  // Re-apply any pending modifications so child hydration does not overwrite unsaved in-memory edits
1286
1379
  this.restoreChainDirtyState(dirtySnapshots);
1287
1380
  // Recursively discover grandchildren (child may also be a parent type)
1288
1381
  // InitializeChildEntity is idempotent via _childEntityDiscoveryDone flag
1289
1382
  await childEntity.InitializeChildEntity();
1383
+ return true;
1384
+ }
1385
+ /**
1386
+ * Tries to link the child that the subtype rule named, by loading its row directly. That
1387
+ * load is the check on the hint.
1388
+ *
1389
+ * The hint isn't tried when the child object can't be created or the user can't read that
1390
+ * entity: the discovery-only path decides those cases, so a hint can neither expose a child
1391
+ * nor fail a load that works without it. A failure READING the hinted row is logged and
1392
+ * treated the same way: the discovery path runs, and reads the row again only if the discovery
1393
+ * query finds it. A failure after the row was read (the child's hydration, its eager
1394
+ * companions, its own child discovery) propagates, as it does on the discovery-only path.
1395
+ *
1396
+ * @returns `'Linked'` when the hinted child loaded and is linked, `'NotFound'` when its load
1397
+ * came back empty, and `'NotTried'` when it wasn't loaded or reading its row failed.
1398
+ */
1399
+ async tryLinkHintedChildEntity(hintedName) {
1400
+ const childEntity = await this.createReadableHintedChild(hintedName);
1401
+ if (!childEntity) {
1402
+ return 'NotTried';
1403
+ }
1404
+ const probe = { RowReadFailed: false };
1405
+ childEntity._subtypeHintProbe = probe;
1406
+ try {
1407
+ return (await this.linkAndLoadChildEntity(childEntity)) ? 'Linked' : 'NotFound';
1408
+ }
1409
+ catch (e) {
1410
+ if (!probe.RowReadFailed) {
1411
+ throw e;
1412
+ }
1413
+ // Nothing after the row read ran, so the parent chain and its unsaved edits are untouched.
1414
+ this._childEntity = null;
1415
+ 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.`);
1416
+ return 'NotTried';
1417
+ }
1418
+ finally {
1419
+ childEntity._subtypeHintProbe = null;
1420
+ }
1421
+ }
1422
+ /**
1423
+ * Creates the child object the subtype rule named, or returns null when the user can't read
1424
+ * that entity. A failure to create it is logged and returns null too, so the discovery-only
1425
+ * path decides.
1426
+ */
1427
+ async createReadableHintedChild(hintedName) {
1428
+ try {
1429
+ const childEntity = await this.createChildEntityObject(hintedName);
1430
+ return childEntity.CheckPermissions(EntityPermissionType.Read, false) ? childEntity : null;
1431
+ }
1432
+ catch (e) {
1433
+ 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.`);
1434
+ return null;
1435
+ }
1436
+ }
1437
+ /**
1438
+ * Asks the entity's subtype rule which child a LOADED record has, without running a query.
1439
+ *
1440
+ * - A registered {@link EntitySubtypeResolver} owns the rule. It gives a hint only when its
1441
+ * class overrides {@link EntitySubtypeResolver.ResolveLoadHint}. A resolver that doesn't
1442
+ * gives none, and the `SubtypeSelector` isn't consulted in its place: at create time the
1443
+ * resolver overrides the selector, so the selector may not describe the same rule.
1444
+ * - Otherwise a `SubtypeSelector` that sets `UseForLoadedRecords` is walked through rows that
1445
+ * loaded `BaseEngine` caches already hold. A hop that isn't cached gives no hint, not a query.
1446
+ * - An entity with no rule gets no hint. The single-child fallback of
1447
+ * {@link ResolveSubtypeEntityName} is a default for new records, not a rule about existing ones.
1448
+ * - While {@link AttachToParent} loads this record, no rule is asked: the child row being added
1449
+ * doesn't exist yet.
1450
+ *
1451
+ * An answer of "no subtype" also gives no hint: only the discovery query can tell whether an
1452
+ * older record still has a child row.
1453
+ *
1454
+ * @returns A declared IsA child's entity name, or null when there is no usable hint. Never throws.
1455
+ */
1456
+ async resolveSubtypeLoadHint() {
1457
+ if (this._skipSubtypeLoadHint) {
1458
+ return null;
1459
+ }
1460
+ const registration = MJGlobal.Instance.ClassFactory.GetRegistration(EntitySubtypeResolver, this.EntityInfo.Name);
1461
+ if (registration) {
1462
+ const resolver = BaseEntity.loadHintResolverFor(this.EntityInfo.Name, registration);
1463
+ return resolver ? this.askResolverForLoadHint(resolver) : null;
1464
+ }
1465
+ const selector = this.EntityInfo.SubtypeSelectorConfig;
1466
+ const selectorPath = selector?.UseForLoadedRecords ? selector.Path?.trim() : null;
1467
+ return selectorPath ? this.evaluateSelectorForLoadHint(selectorPath) : null;
1468
+ }
1469
+ /**
1470
+ * The resolver to ask for load hints for an entity, decided once per registration and cached:
1471
+ * null when the registered class doesn't override {@link EntitySubtypeResolver.ResolveLoadHint},
1472
+ * which is read from the class without constructing it, or when constructing it failed.
1473
+ */
1474
+ static loadHintResolverFor(entityName, registration) {
1475
+ const key = entityName.trim().toLowerCase();
1476
+ const cached = BaseEntity._subtypeLoadHintResolvers.get(key);
1477
+ if (cached?.Registration === registration) {
1478
+ return cached.Resolver;
1479
+ }
1480
+ const resolver = BaseEntity.overridesResolveLoadHint(registration)
1481
+ ? BaseEntity.constructLoadHintResolver(entityName)
1482
+ : null;
1483
+ BaseEntity._subtypeLoadHintResolvers.set(key, { Registration: registration, Resolver: resolver });
1484
+ return resolver;
1485
+ }
1486
+ /** Whether a registered resolver class overrides `ResolveLoadHint`, read from its prototype. */
1487
+ static overridesResolveLoadHint(registration) {
1488
+ const prototype = registration.SubClass?.prototype;
1489
+ const resolveLoadHint = prototype?.ResolveLoadHint;
1490
+ return typeof resolveLoadHint === 'function' && resolveLoadHint !== EntitySubtypeResolver.prototype.ResolveLoadHint;
1491
+ }
1492
+ /**
1493
+ * Constructs the registered resolver once, to ask it for load hints. A failure is logged and
1494
+ * gives null; since the answer is cached, that is once per entity.
1495
+ */
1496
+ static constructLoadHintResolver(entityName) {
1497
+ try {
1498
+ const resolution = MJGlobal.Instance.ClassFactory.TryCreateInstance(EntitySubtypeResolver, entityName);
1499
+ if (resolution.Resolved && resolution.Instance) {
1500
+ return resolution.Instance;
1501
+ }
1502
+ LogError(`IS-A load hint: the EntitySubtypeResolver registered for '${entityName}' did not resolve (${resolution.Reason ?? 'no reason given'}); loading its records without a hint.`);
1503
+ }
1504
+ catch (e) {
1505
+ LogError(`IS-A load hint: constructing the EntitySubtypeResolver registered for '${entityName}' failed (${BaseEntity.errorText(e)}); loading its records without a hint.`);
1506
+ }
1507
+ return null;
1508
+ }
1509
+ /**
1510
+ * Asks the resolver for a load hint. A resolver that throws or rejects gives no hint for that
1511
+ * record, and is logged once per entity rather than per record.
1512
+ */
1513
+ async askResolverForLoadHint(resolver) {
1514
+ try {
1515
+ // A synchronous answer is used as-is, with no extra microtask per record.
1516
+ const raw = resolver.ResolveLoadHint(this);
1517
+ const candidate = raw instanceof Promise ? await raw : raw;
1518
+ return this.declaredChildForLoadHint(candidate, `EntitySubtypeResolver.ResolveLoadHint for '${this.EntityInfo.Name}'`);
1519
+ }
1520
+ catch (e) {
1521
+ 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.`));
1522
+ return null;
1523
+ }
1524
+ }
1525
+ /**
1526
+ * Walks the `SubtypeSelector` path through engine caches only. A path that isn't valid for
1527
+ * this entity's metadata is logged once and gives no hint; the create path throws on it instead.
1528
+ */
1529
+ async evaluateSelectorForLoadHint(path) {
1530
+ try {
1531
+ const pathResult = await this.evaluateSubtypeSelectorPath(path, (entityName, pkValue) => BaseEntity.findSubtypePathTargetInEngineCache(entityName, pkValue));
1532
+ return this.declaredChildForLoadHint(pathResult, `SubtypeSelector path '${path}' on '${this.EntityInfo.Name}'`);
1533
+ }
1534
+ catch (e) {
1535
+ BaseEntity.logSubtypeLoadHintOnce(`${this.EntityInfo.Name}|path|${path}`, () => LogError(`IS-A load hint: ${BaseEntity.errorText(e)} Loading '${this.EntityInfo.Name}' records without a hint.`));
1536
+ return null;
1537
+ }
1538
+ }
1539
+ /**
1540
+ * Turns a rule's answer into a declared child's canonical entity name. Empty means no subtype,
1541
+ * which gives no hint. A name that isn't a declared IsA child is logged once and gives no hint.
1542
+ */
1543
+ declaredChildForLoadHint(candidate, source) {
1544
+ const trimmed = candidate?.trim();
1545
+ if (!trimmed) {
1546
+ return null;
1547
+ }
1548
+ const match = this.findDeclaredChild(trimmed);
1549
+ if (!match) {
1550
+ 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.`));
1551
+ return null;
1552
+ }
1553
+ return match.Name;
1554
+ }
1555
+ /**
1556
+ * Reports a hint the data didn't bear out: the hinted child's row wasn't there.
1557
+ *
1558
+ * When the discovery query finds no child either, that is a normal state, not an error: core
1559
+ * never creates a subtype row on save, so a typed record can exist without one (saved through
1560
+ * a generic form or an import, or older than its type's subtype). It logs one verbose line per
1561
+ * entity and hinted child. When the discovery query finds a DIFFERENT child, the rule and the
1562
+ * data disagree, and each such load costs an extra round trip until one of them is fixed; that
1563
+ * is logged as an error, once per entity, hinted child and child found.
1564
+ */
1565
+ reportSubtypeLoadHintMiss(hintedName, discoveredName) {
1566
+ const entityName = this.EntityInfo.Name;
1567
+ if (!discoveredName) {
1568
+ BaseEntity.logSubtypeLoadHintOnce(`${entityName}|no-row|${hintedName}`, () => LogStatusEx({
1569
+ 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.`,
1570
+ verboseOnly: true,
1571
+ }));
1572
+ return;
1573
+ }
1574
+ 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.`));
1575
+ }
1576
+ /** Keys of load-hint messages already logged, so each is reported once rather than per record. */
1577
+ static { this._loggedSubtypeLoadHintMessages = new Set(); }
1578
+ /** Runs `log` the first time `key` is seen (case-insensitively), and not again until the caches are cleared. */
1579
+ static logSubtypeLoadHintOnce(key, log) {
1580
+ const normalizedKey = key.toLowerCase();
1581
+ if (BaseEntity._loggedSubtypeLoadHintMessages.has(normalizedKey)) {
1582
+ return;
1583
+ }
1584
+ BaseEntity._loggedSubtypeLoadHintMessages.add(normalizedKey);
1585
+ log();
1586
+ }
1587
+ static errorText(e) {
1588
+ return e instanceof Error ? e.message : String(e);
1290
1589
  }
1291
1590
  static { this._subtypeLookupCache = new Map(); }
1591
+ /** Per entity (lowercased name): the resolver asked for load hints, decided once per registration. */
1592
+ static { this._subtypeLoadHintResolvers = new Map(); }
1593
+ /** Indexes over engine-cached arrays, keyed by the live array itself; see {@link findEngineCachedRow}. */
1594
+ static { this._engineCachedRowIndexes = new WeakMap(); }
1292
1595
  /**
1293
- * Clears the static memoization cache used by SubtypeSelector path evaluation.
1596
+ * Clears the static caches IsA subtype resolution keeps: the create path's SubtypeSelector
1597
+ * lookup memo, the per-entity decision about which resolver gives load hints, the indexes over
1598
+ * engine-cached rows, and the record of load-hint messages already logged, so each can be
1599
+ * reported again.
1294
1600
  */
1295
1601
  static ClearSubtypeLookupCache() {
1296
1602
  BaseEntity._subtypeLookupCache.clear();
1603
+ BaseEntity._subtypeLoadHintResolvers.clear();
1604
+ BaseEntity._engineCachedRowIndexes = new WeakMap();
1605
+ BaseEntity._loggedSubtypeLoadHintMessages.clear();
1606
+ }
1607
+ /**
1608
+ * The declared IsA child entity named `name`, matched the way metadata lookups match names
1609
+ * (trimmed, case-insensitive), or undefined when this entity declares no such child. The one
1610
+ * lookup behind {@link ResolveSubtypeEntityName}, {@link EnsureISAChild} and load hints: the
1611
+ * create path throws on a miss, the load path logs it.
1612
+ */
1613
+ findDeclaredChild(name) {
1614
+ return this.EntityInfo.ChildEntities?.find(c => sameEntityName(c.Name, name));
1297
1615
  }
1298
1616
  /**
1299
1617
  * Prospective counterpart to FindISAChildEntity.
@@ -1303,6 +1621,12 @@ export class BaseEntity {
1303
1621
  * 3. Unconditional single-child IsA fallback (ChildEntities.length === 1)
1304
1622
  * 4. Otherwise null (no subtype)
1305
1623
  *
1624
+ * This is the create-time ladder, and steps 1 and 2 may query. When a record is loaded,
1625
+ * BaseEntity asks a narrower, query-free version of it for a hint instead: the resolver's
1626
+ * `ResolveLoadHint` (not `Resolve`) when its class overrides it, the selector only when it sets
1627
+ * `UseForLoadedRecords` and only through rows that loaded `BaseEngine` caches hold, and never
1628
+ * step 3.
1629
+ *
1306
1630
  * @see plans/sync-composition-axes.md
1307
1631
  */
1308
1632
  async ResolveSubtypeEntityName() {
@@ -1317,8 +1641,7 @@ export class BaseEntity {
1317
1641
  const raw = resolution.Instance.Resolve(this);
1318
1642
  const candidate = raw instanceof Promise ? await raw : raw;
1319
1643
  if (candidate != null && candidate.trim() !== '') {
1320
- const trimmed = candidate.trim();
1321
- const match = this.EntityInfo.ChildEntities.find(c => c.Name.trim().toLowerCase() === trimmed.toLowerCase());
1644
+ const match = this.findDeclaredChild(candidate);
1322
1645
  if (!match) {
1323
1646
  throw new Error(`EntitySubtypeResolver for '${this.EntityInfo.Name}' returned '${candidate}', which is not a declared IsA child entity of '${this.EntityInfo.Name}'.`);
1324
1647
  }
@@ -1330,10 +1653,9 @@ export class BaseEntity {
1330
1653
  // 2. Entity.SubtypeSelector declarative path
1331
1654
  const selectorConfig = this.EntityInfo.SubtypeSelectorConfig;
1332
1655
  if (selectorConfig && selectorConfig.Path && selectorConfig.Path.trim() !== '') {
1333
- const pathResult = await this.evaluateSubtypeSelectorPath(selectorConfig.Path.trim());
1656
+ const pathResult = await this.evaluateSubtypeSelectorPath(selectorConfig.Path.trim(), (entityName, pkValue) => this.getSubtypePathTargetEntity(entityName, pkValue));
1334
1657
  if (pathResult != null && pathResult.trim() !== '') {
1335
- const trimmed = pathResult.trim();
1336
- const match = this.EntityInfo.ChildEntities.find(c => c.Name.trim().toLowerCase() === trimmed.toLowerCase());
1658
+ const match = this.findDeclaredChild(pathResult);
1337
1659
  if (!match) {
1338
1660
  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
1661
  }
@@ -1364,7 +1686,7 @@ export class BaseEntity {
1364
1686
  if (!entityName) {
1365
1687
  return null;
1366
1688
  }
1367
- const matchedChild = this.EntityInfo.ChildEntities?.find(c => c.Name.trim().toLowerCase() === entityName.trim().toLowerCase());
1689
+ const matchedChild = this.findDeclaredChild(entityName);
1368
1690
  if (!matchedChild) {
1369
1691
  throw new Error(`'${entityName}' is not a declared IsA child entity of '${this.EntityInfo.Name}'.`);
1370
1692
  }
@@ -1438,7 +1760,19 @@ export class BaseEntity {
1438
1760
  }
1439
1761
  }
1440
1762
  }
1441
- async evaluateSubtypeSelectorPath(path) {
1763
+ /**
1764
+ * Walks a `SubtypeSelector` path from this record to the column holding the subtype's entity
1765
+ * name, and returns that column's value, or null when a hop's foreign key is empty, a target
1766
+ * isn't found, or the terminal value is empty.
1767
+ *
1768
+ * @param path The dotted path, e.g. `ProductTypeID.ProductExtensionEntity`.
1769
+ * @param findTarget Finds the record a foreign key points at. Create time passes a finder that
1770
+ * may query; load time passes one that reads engine caches only, so a hop missing from them
1771
+ * ends the walk with null instead of a query.
1772
+ * @throws When the path doesn't fit this entity's metadata (a field isn't found, or a hop isn't
1773
+ * a foreign key).
1774
+ */
1775
+ async evaluateSubtypeSelectorPath(path, findTarget) {
1442
1776
  const segments = path.split('.').map(s => s.trim()).filter(Boolean);
1443
1777
  if (segments.length === 0)
1444
1778
  return null;
@@ -1458,7 +1792,7 @@ export class BaseEntity {
1458
1792
  if (!relatedEntityName) {
1459
1793
  throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': field '${fieldName}' on entity '${currentEntityInfo.Name}' is not a foreign key relationship.`);
1460
1794
  }
1461
- const targetEntity = await this.getSubtypePathTargetEntity(relatedEntityName, fkValue);
1795
+ const targetEntity = await findTarget(relatedEntityName, fkValue);
1462
1796
  if (!targetEntity) {
1463
1797
  return null;
1464
1798
  }
@@ -1482,21 +1816,10 @@ export class BaseEntity {
1482
1816
  return BaseEntity._subtypeLookupCache.get(cacheKey) ?? null;
1483
1817
  }
1484
1818
  // 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
- }
1819
+ const cached = BaseEntity.findSubtypePathTargetInEngineCache(entityName, pkValue);
1820
+ if (cached) {
1821
+ BaseEntity._subtypeLookupCache.set(cacheKey, cached);
1822
+ return cached;
1500
1823
  }
1501
1824
  // 2. Fall back to loading via provider
1502
1825
  const provider = this.ProviderToUse;
@@ -1531,6 +1854,84 @@ export class BaseEntity {
1531
1854
  BaseEntity._subtypeLookupCache.set(cacheKey, null);
1532
1855
  return null;
1533
1856
  }
1857
+ /**
1858
+ * Finds the record a `SubtypeSelector` hop points at among the entity objects that loaded
1859
+ * `BaseEngine` caches already hold. Never queries, and never reads the create path's memo, whose
1860
+ * entries can outlive a change to the row: engine caches are kept current by entity events.
1861
+ *
1862
+ * `pkValue` is one foreign-key column's value, matched against the first primary key of the
1863
+ * cached rows' entity the way UUIDs compare (trimmed, case-insensitive).
1864
+ *
1865
+ * @returns The cached record, or null when no loaded engine holds it as an entity object.
1866
+ */
1867
+ static findSubtypePathTargetInEngineCache(entityName, pkValue) {
1868
+ const key = NormalizeUUID(String(pkValue));
1869
+ for (const match of BaseEngineRegistry.Instance.FindCachedEntity(entityName)) {
1870
+ const found = BaseEntity.findEngineCachedRow(match.records, key);
1871
+ if (found) {
1872
+ return found;
1873
+ }
1874
+ }
1875
+ return null;
1876
+ }
1877
+ /**
1878
+ * Finds the row whose key is `key` in an engine's live cached array through an index over the
1879
+ * array, not a scan per lookup. Keys are read with `Get()`, which leaves the rows an engine holds
1880
+ * in raw mode unhydrated.
1881
+ *
1882
+ * The array is the engine's own and changes under the index: a refresh replaces it, and a save,
1883
+ * delete or remote invalidation pushes a row, splices one out, or swaps one for a copy with the
1884
+ * same key. So the index is keyed by the array itself, and rebuilt when the array's length or
1885
+ * last element differs from when it was built, which every added or removed row changes. A hit
1886
+ * is checked against the row now at that position, and a row that moved makes the index rebuild
1887
+ * before the answer is trusted. A key that isn't indexed is a miss, which gives no hint on load
1888
+ * and falls back to a query at create time, as for a row no engine caches.
1889
+ */
1890
+ static findEngineCachedRow(records, key) {
1891
+ let index = BaseEntity.currentEngineCachedRowIndex(records);
1892
+ let position = index.Positions.get(key);
1893
+ if (position !== undefined && BaseEntity.engineCachedRowKey(records[position], index.KeyField) !== key) {
1894
+ index = BaseEntity.buildEngineCachedRowIndex(records);
1895
+ position = index.Positions.get(key);
1896
+ }
1897
+ return position === undefined ? null : records[position];
1898
+ }
1899
+ /** The index over `records`, rebuilt when the array's length or last element changed since it was built. */
1900
+ static currentEngineCachedRowIndex(records) {
1901
+ const index = BaseEntity._engineCachedRowIndexes.get(records);
1902
+ if (index && index.Length === records.length && index.LastRow === records[records.length - 1]) {
1903
+ return index;
1904
+ }
1905
+ return BaseEntity.buildEngineCachedRowIndex(records);
1906
+ }
1907
+ /** Indexes `records` by key. Anything that isn't an entity object (a `'simple'` cache holds plain rows) is skipped. */
1908
+ static buildEngineCachedRowIndex(records) {
1909
+ const firstEntityRow = records.find(r => typeof r?.Get === 'function');
1910
+ const keyField = firstEntityRow?.EntityInfo?.FirstPrimaryKey?.Name ?? null; // first-pk-ok: FK target — a SubtypeSelector hop's foreign key holds one key value
1911
+ const positions = new Map();
1912
+ for (let i = 0; i < records.length; i++) {
1913
+ const rowKey = BaseEntity.engineCachedRowKey(records[i], keyField);
1914
+ if (rowKey !== null && !positions.has(rowKey)) {
1915
+ positions.set(rowKey, i);
1916
+ }
1917
+ }
1918
+ const index = {
1919
+ Length: records.length,
1920
+ LastRow: records[records.length - 1],
1921
+ KeyField: keyField,
1922
+ Positions: positions,
1923
+ };
1924
+ BaseEntity._engineCachedRowIndexes.set(records, index);
1925
+ return index;
1926
+ }
1927
+ /** A cached row's key, normalized with `NormalizeUUID`; null for a row that isn't an entity object or has no key value. */
1928
+ static engineCachedRowKey(row, keyField) {
1929
+ if (!keyField || typeof row?.Get !== 'function') {
1930
+ return null;
1931
+ }
1932
+ const value = row.Get(keyField);
1933
+ return value == null ? null : NormalizeUUID(String(value));
1934
+ }
1534
1935
  captureChainDirtyState() {
1535
1936
  const snapshots = [];
1536
1937
  let curr = this;
@@ -2280,13 +2681,13 @@ export class BaseEntity {
2280
2681
  * binds this via {@link ExecuteEntitySavePlan}'s `SaveSelfOnly` callback.
2281
2682
  */
2282
2683
  saveAsGraphNode(options) {
2283
- return this._InnerSave(options);
2684
+ return this._innerSave(options);
2284
2685
  }
2285
2686
  /**
2286
2687
  * Delete-path counterpart of {@link saveAsGraphNode}.
2287
2688
  */
2288
2689
  deleteAsGraphNode(options) {
2289
- return this._InnerDelete(options);
2690
+ return this._innerDelete(options);
2290
2691
  }
2291
2692
  /**
2292
2693
  * Captures saved/dirty baselines for every plan participant so a rolled-back
@@ -2620,12 +3021,12 @@ export class BaseEntity {
2620
3021
  * Access to the underlying metadata for the entity object.
2621
3022
  */
2622
3023
  get EntityInfo() {
2623
- return this._EntityInfo;
3024
+ return this._entityInfo;
2624
3025
  }
2625
3026
  get Fields() {
2626
3027
  // Caller wants to walk Fields — promote raw data into real EntityField instances now.
2627
3028
  this.hydrateFieldsIfNeeded();
2628
- return this._Fields;
3029
+ return this._fields;
2629
3030
  }
2630
3031
  /**
2631
3032
  * 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 +3042,7 @@ export class BaseEntity {
2641
3042
  const lcase = fieldName.trim().toLowerCase(); // do this once as we will use it multiple times
2642
3043
  if (this._fieldCache === null) {
2643
3044
  this._fieldCache = new Map();
2644
- for (const f of this._Fields) {
3045
+ for (const f of this._fields) {
2645
3046
  if (!this._fieldCache.has(f.Name.trim().toLowerCase())) {
2646
3047
  this._fieldCache.set(f.Name.trim().toLowerCase(), f);
2647
3048
  }
@@ -2704,7 +3105,7 @@ export class BaseEntity {
2704
3105
  if (this._codeNameCache === null) {
2705
3106
  this._codeNameCache = new Map();
2706
3107
  // First-write-wins on duplicate code names — matches prior Array.find() behavior
2707
- for (const f of this._Fields) {
3108
+ for (const f of this._fields) {
2708
3109
  const codeKey = f.CodeName.trim().toLowerCase();
2709
3110
  if (!this._codeNameCache.has(codeKey)) {
2710
3111
  this._codeNameCache.set(codeKey, f);
@@ -2726,7 +3127,7 @@ export class BaseEntity {
2726
3127
  // dirty. Avoid hydrating just to check.
2727
3128
  if (!this._fieldsHydrated)
2728
3129
  return this._parentEntity?.Dirty ?? false;
2729
- return this._Fields.some(f => f.Dirty) ||
3130
+ return this._fields.some(f => f.Dirty) ||
2730
3131
  (this._parentEntity?.Dirty ?? false);
2731
3132
  }
2732
3133
  finally {
@@ -2791,17 +3192,17 @@ export class BaseEntity {
2791
3192
  if (this._parentEntity && this._parentEntityFieldNames?.has(FieldName)) {
2792
3193
  this._parentEntity.Set(FieldName, Value); // recursive for N-level chains (parent asserts its own field)
2793
3194
  // Also mirror the value on our own virtual EntityField for UI compatibility
2794
- this.SetLocal(FieldName, Value);
3195
+ this.setLocal(FieldName, Value);
2795
3196
  }
2796
3197
  else {
2797
3198
  // Active-status enforcement choke point for own-field writes. Fast-path gated on the
2798
3199
  // memoized EntityInfo.HasInactiveFields so all-Active entities (the overwhelming majority)
2799
3200
  // pay only a single cached boolean check.
2800
3201
  if (this.EntityInfo?.HasInactiveFields) {
2801
- this.AssertFieldActiveStatus(FieldName, 'BaseEntity.Set');
3202
+ this.assertFieldActiveStatus(FieldName, 'BaseEntity.Set');
2802
3203
  }
2803
- this.AssertFieldReadable(FieldName);
2804
- this.SetLocal(FieldName, Value);
3204
+ this.assertFieldReadable(FieldName);
3205
+ this.setLocal(FieldName, Value);
2805
3206
  }
2806
3207
  }
2807
3208
  /**
@@ -2814,7 +3215,7 @@ export class BaseEntity {
2814
3215
  * Callers should gate this behind `EntityInfo.HasInactiveFields` so the common all-Active entity
2815
3216
  * skips the field lookup entirely.
2816
3217
  */
2817
- AssertFieldActiveStatus(fieldName, caller) {
3218
+ assertFieldActiveStatus(fieldName, caller) {
2818
3219
  const fi = this.EntityInfo?.FieldByName(fieldName);
2819
3220
  if (fi) {
2820
3221
  EntityFieldInfo.AssertEntityFieldActiveStatus(fi, caller);
@@ -2841,7 +3242,7 @@ export class BaseEntity {
2841
3242
  * build) reads `EntityField.Value` directly and never routes through here — that exemption
2842
3243
  * is load-bearing, not an oversight. Do not "fix" it.
2843
3244
  */
2844
- AssertFieldReadable(fieldName) {
3245
+ assertFieldReadable(fieldName) {
2845
3246
  const denied = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedReadFields(u));
2846
3247
  if (denied?.has(fieldName?.trim().toLowerCase())) {
2847
3248
  throw new FieldSecurityError(fieldName, this.EntityInfo.Name);
@@ -2851,7 +3252,7 @@ export class BaseEntity {
2851
3252
  * Internal helper that sets a field value directly on THIS entity's own Fields array
2852
3253
  * without IS-A routing. Used by Set() for own-fields and for mirroring parent field values.
2853
3254
  */
2854
- SetLocal(FieldName, Value) {
3255
+ setLocal(FieldName, Value) {
2855
3256
  const field = this.GetFieldByName(FieldName);
2856
3257
  if (field != null) {
2857
3258
  if (field.EntityFieldInfo.TSType === EntityFieldTSType.Date && (typeof Value === 'string' || typeof Value === 'number')) {
@@ -2912,12 +3313,12 @@ export class BaseEntity {
2912
3313
  // EntityInfo.HasInactiveFields so all-Active entities pay only a single cached boolean check
2913
3314
  // before the hot read paths below.
2914
3315
  if (this.EntityInfo?.HasInactiveFields) {
2915
- this.AssertFieldActiveStatus(FieldName, 'BaseEntity.Get');
3316
+ this.assertFieldActiveStatus(FieldName, 'BaseEntity.Get');
2916
3317
  }
2917
3318
  // Field security sits BEFORE the raw-mode fast path below, not after: an entity whose
2918
3319
  // fields are not yet hydrated would otherwise return denied values straight out of _raw
2919
3320
  // without the gate ever running.
2920
- this.AssertFieldReadable(FieldName);
3321
+ this.assertFieldReadable(FieldName);
2921
3322
  // Raw mode fast path: read directly from the cached data without building EntityField
2922
3323
  // instances. This is the dominant cost in engine warm-loads — generated typed getters
2923
3324
  // (e.g. `get Name() { return this.Get('Name'); }`) flow through here, so a consumer that
@@ -2931,7 +3332,7 @@ export class BaseEntity {
2931
3332
  // back into `_raw` — the row may be shared, frozen cache state. Fields needing no
2932
3333
  // conversion (the vast majority) never touch the memo at all, so the fast path stays
2933
3334
  // a single property read.
2934
- const fi = this._EntityInfo?.FieldByName(FieldName);
3335
+ const fi = this._entityInfo?.FieldByName(FieldName);
2935
3336
  if (fi?.TSType === EntityFieldTSType.Date && (typeof value === 'string' || typeof value === 'number')) {
2936
3337
  const memo = this._rawConverted?.get(FieldName);
2937
3338
  if (memo !== undefined)
@@ -2945,7 +3346,7 @@ export class BaseEntity {
2945
3346
  const memo = this._rawConverted?.get(FieldName);
2946
3347
  if (memo !== undefined)
2947
3348
  return memo;
2948
- value = value.replace(/ +$/, '');
3349
+ value = trimTrailingSpaces(value);
2949
3350
  this.memoizeRawConversion(FieldName, value);
2950
3351
  }
2951
3352
  return value;
@@ -2991,7 +3392,7 @@ export class BaseEntity {
2991
3392
  EntityFieldInfo.AssertEntityFieldActiveStatus(field.EntityFieldInfo, 'BaseEntity.SetMany');
2992
3393
  }
2993
3394
  // Use SetLocal here so we set on OUR fields (mirrors for parent fields)
2994
- this.SetLocal(key, object[key]);
3395
+ this.setLocal(key, object[key]);
2995
3396
  if (replaceOldValues) {
2996
3397
  field.ResetOldValue();
2997
3398
  }
@@ -3005,7 +3406,7 @@ export class BaseEntity {
3005
3406
  EntityFieldInfo.AssertEntityFieldActiveStatus(field.EntityFieldInfo, 'BaseEntity.SetMany');
3006
3407
  }
3007
3408
  // Use SetLocal here so we set on OUR fields (mirrors for parent fields)
3008
- this.SetLocal(field.Name, object[key]);
3409
+ this.setLocal(field.Name, object[key]);
3009
3410
  if (replaceOldValues) {
3010
3411
  field.ResetOldValue();
3011
3412
  }
@@ -3045,7 +3446,7 @@ export class BaseEntity {
3045
3446
  // load — without this, the parent would think it's a new record and attempt CREATE
3046
3447
  // instead of UPDATE on save.
3047
3448
  if (replaceOldValues) {
3048
- this.UpdateSavedStateFromPrimaryKeys();
3449
+ this.updateSavedStateFromPrimaryKeys();
3049
3450
  }
3050
3451
  }
3051
3452
  /**
@@ -3053,7 +3454,7 @@ export class BaseEntity {
3053
3454
  * a saved/loaded record. This enables entities populated via SetMany (e.g., IS-A parent
3054
3455
  * entities) to correctly recognize themselves as existing records.
3055
3456
  */
3056
- UpdateSavedStateFromPrimaryKeys() {
3457
+ updateSavedStateFromPrimaryKeys() {
3057
3458
  if (!this.PrimaryKeys || this.PrimaryKeys.length === 0)
3058
3459
  return;
3059
3460
  const allPKsSet = this.PrimaryKeys.every(pk => pk.Value !== null && pk.Value !== undefined);
@@ -3118,6 +3519,49 @@ export class BaseEntity {
3118
3519
  GetChangesSinceLastSave() {
3119
3520
  return this.GetAll(false, true);
3120
3521
  }
3522
+ /**
3523
+ * Computes a deterministic SHA-256 content hash of the entity's field values using
3524
+ * the canonicalizer and Web Crypto API from `@memberjunction/global`.
3525
+ *
3526
+ * By default, hashes all loaded non-system fields (excluding `__mj_*` columns).
3527
+ * The result is byte-for-byte identical across client and server tiers.
3528
+ *
3529
+ * @param options Optional configuration specifying subset of fields and/or whether to exclude system fields.
3530
+ */
3531
+ async ComputeContentHash(options) {
3532
+ const excludeSystem = options?.ExcludeSystemFields ?? true;
3533
+ const allData = this.GetAll(false, false);
3534
+ // If specific fields are requested, validate each exists (case-insensitively) and resolve canonical name
3535
+ let targetFieldNames;
3536
+ if (options?.Fields && options.Fields.length > 0) {
3537
+ targetFieldNames = options.Fields.map((req) => {
3538
+ const fieldInfo = this.GetFieldByName(req);
3539
+ if (!fieldInfo) {
3540
+ throw new Error(`ComputeContentHash: field '${req}' does not exist on entity '${this.EntityInfo.Name}'`);
3541
+ }
3542
+ return fieldInfo.Name;
3543
+ });
3544
+ }
3545
+ else {
3546
+ targetFieldNames = Object.keys(allData);
3547
+ }
3548
+ const excludeSet = new Set((options?.ExcludeFields ?? []).map((f) => f.trim().toLowerCase()));
3549
+ const data = {};
3550
+ for (const name of targetFieldNames) {
3551
+ if (excludeSystem && name.startsWith('__mj_'))
3552
+ continue;
3553
+ if (excludeSet.has(name.toLowerCase()))
3554
+ continue;
3555
+ const val = allData[name];
3556
+ if (val !== undefined) {
3557
+ data[name] = val;
3558
+ }
3559
+ }
3560
+ if (Object.keys(data).length === 0) {
3561
+ throw new Error(`ComputeContentHash: cannot compute content hash on empty basis for entity '${this.EntityInfo.Name}'`);
3562
+ }
3563
+ return ComputeContentHashAsync(data);
3564
+ }
3121
3565
  /**
3122
3566
  * This utility method calls GetDataObject() internally and formats the result as a JSON string. If you want to get the data as an object instead of a string, call GetDataObject() directly.
3123
3567
  * @param params
@@ -3154,8 +3598,8 @@ export class BaseEntity {
3154
3598
  }
3155
3599
  if (params.includeRelatedEntityData) {
3156
3600
  // now, get the related entity data
3157
- for (let i = 0; i < this._EntityInfo.RelatedEntities.length; i++) {
3158
- const re = this._EntityInfo.RelatedEntities[i];
3601
+ for (let i = 0; i < this._entityInfo.RelatedEntities.length; i++) {
3602
+ const re = this._entityInfo.RelatedEntities[i];
3159
3603
  const pre = params.relatedEntityList ? params.relatedEntityList.find(r => r.relatedEntityName === re.RelatedEntity) : null;
3160
3604
  if (pre) {
3161
3605
  // pre now has the param that matches the related entity (re) that we are looking at
@@ -3213,7 +3657,7 @@ export class BaseEntity {
3213
3657
  this._compositeKey = null;
3214
3658
  this._resultHistory = [];
3215
3659
  this._recordLoaded = false;
3216
- this._Fields = [];
3660
+ this._fields = [];
3217
3661
  this._fieldsHydrated = false;
3218
3662
  this._raw = null;
3219
3663
  this._rawConverted = null;
@@ -3249,14 +3693,14 @@ export class BaseEntity {
3249
3693
  for (const rawField of this.EntityInfo.Fields) {
3250
3694
  const key = this.EntityInfo.Name + '.' + rawField.Name;
3251
3695
  const newField = MJGlobal.Instance.ClassFactory.CreateInstance(EntityField, key, rawField);
3252
- this._Fields.push(newField);
3696
+ this._fields.push(newField);
3253
3697
  }
3254
3698
  // 2. If we have raw data from LoadFromData, populate Fields. The first set of each Field
3255
3699
  // runs through the EntityField setter with `_NeverSet=true`, which simultaneously sets
3256
3700
  // Value AND OldValue (so the field is not marked dirty). This matches the old
3257
3701
  // SetMany(replaceOldValues=true) semantics on initial load.
3258
3702
  if (this._raw) {
3259
- for (const field of this._Fields) {
3703
+ for (const field of this._fields) {
3260
3704
  const value = this._raw[field.Name];
3261
3705
  if (value !== undefined) {
3262
3706
  // Populating fields from the loaded row writes EntityField.Value directly, which does
@@ -3368,7 +3812,7 @@ export class BaseEntity {
3368
3812
  for (const pk of this.EntityInfo.PrimaryKeys) {
3369
3813
  const parentValue = this._parentEntity.Get(pk.Name);
3370
3814
  if (parentValue != null) {
3371
- this.SetLocal(pk.Name, parentValue);
3815
+ this.setLocal(pk.Name, parentValue);
3372
3816
  // The shared PK is ReadOnly, so SetLocal above consumed its
3373
3817
  // one-time write (_NeverSet -> false). Restore _NeverSet so the
3374
3818
  // child's OWN mirror can be re-written if the shared key is set
@@ -3520,7 +3964,7 @@ export class BaseEntity {
3520
3964
  // Root.Save() would wait on the first _pendingSave$ (which is itself waiting
3521
3965
  // for the leaf chain), creating a circular wait that hangs forever.
3522
3966
  if (options?.IsParentEntitySave) {
3523
- return this._InnerSave(options);
3967
+ return this._innerSave(options);
3524
3968
  }
3525
3969
  // If a save is already in progress, return its promise. This check MUST run before graph
3526
3970
  // routing: a composite save is still a save, and two concurrent Save() calls on one record
@@ -3561,7 +4005,7 @@ export class BaseEntity {
3561
4005
  // Create a new observable that debounces duplicative calls, and executes the save.
3562
4006
  this._pendingSave$ = of(options).pipe(
3563
4007
  // Execute the actual save logic.
3564
- switchMap(opts => from(this._InnerSave(opts))),
4008
+ switchMap(opts => from(this._innerSave(opts))),
3565
4009
  // When the save completes (whether successfully or not), clear the pending save observable.
3566
4010
  finalize(() => { this._pendingSave$ = null; }),
3567
4011
  // Ensure that all subscribers get the same result.
@@ -3574,7 +4018,7 @@ export class BaseEntity {
3574
4018
  * @param options
3575
4019
  * @returns
3576
4020
  */
3577
- async _InnerSave(options) {
4021
+ async _innerSave(options) {
3578
4022
  const currentResultCount = this.ResultHistory.length;
3579
4023
  const newResult = new BaseEntityResult();
3580
4024
  newResult.StartedAt = new Date();
@@ -3722,7 +4166,7 @@ export class BaseEntity {
3722
4166
  }
3723
4167
  if (valResult.Success) {
3724
4168
  // Run registered PreSave hooks (e.g., tenant validation)
3725
- await this.RunPreSaveHooks();
4169
+ await this.runPreSaveHooks();
3726
4170
  // Optimistic-UI hook: every pre-flight check (Validate, ValidateAsync,
3727
4171
  // PreSave hooks) has passed and the DB write is imminent. Fire the optional
3728
4172
  // OnValidated callback so a UI can render the now-known-valid change before
@@ -3824,7 +4268,7 @@ export class BaseEntity {
3824
4268
  * Runs all registered PreSave hooks. If any hook returns false or a string
3825
4269
  * (error message), the save is rejected by throwing an Error.
3826
4270
  */
3827
- async RunPreSaveHooks() {
4271
+ async runPreSaveHooks() {
3828
4272
  const preSaveHooks = GetDataHooks('PreSave');
3829
4273
  for (const hook of preSaveHooks) {
3830
4274
  const hookResult = await hook(this, this.ActiveUser);
@@ -3881,7 +4325,7 @@ export class BaseEntity {
3881
4325
  if (result)
3882
4326
  result.NewValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.Value }; }); // set the latest values here
3883
4327
  // Cache the record name for faster lookups (updates cache with potentially new name)
3884
- this.CacheRecordName();
4328
+ this.cacheRecordName();
3885
4329
  this.RaiseEvent('save', null, saveSubType);
3886
4330
  return true;
3887
4331
  }
@@ -3901,7 +4345,7 @@ export class BaseEntity {
3901
4345
  * Caches the entity record name in the provider's EntityRecordNameCache for faster lookups.
3902
4346
  * Called automatically after successful Load(), LoadFromData(), and Save() operations.
3903
4347
  */
3904
- CacheRecordName() {
4348
+ cacheRecordName() {
3905
4349
  // Only cache if we have a valid primary key
3906
4350
  if (!this.PrimaryKey || !this.PrimaryKey.HasValue) {
3907
4351
  return;
@@ -4192,9 +4636,12 @@ export class BaseEntity {
4192
4636
  if (!this.IsSaved) {
4193
4637
  this.init(); // wipe out current data if we're loading on top of existing record
4194
4638
  }
4195
- const data = await this.ProviderToUse.Load(this, CompositeKey, EntityRelationshipsToLoad, this.ActiveUser);
4639
+ const data = await this.readRowForLoad(CompositeKey, EntityRelationshipsToLoad);
4196
4640
  if (!data) {
4197
- LogError(`Error in BaseEntity.Load(${this.EntityInfo.Name}, Key: ${CompositeKey.ToString()}`);
4641
+ // A subtype-hint probe asks whether this row exists, so "no" is an answer, not an error
4642
+ if (!this._subtypeHintProbe) {
4643
+ BaseEntity.logLoadFoundNoRow(this.EntityInfo.Name, CompositeKey);
4644
+ }
4198
4645
  return false; // no data loaded, return false
4199
4646
  }
4200
4647
  // IS-A: hydrate parent chain from loaded data before populating self.
@@ -4230,7 +4677,7 @@ export class BaseEntity {
4230
4677
  this._everSaved = true; // Mark as saved since we loaded from database
4231
4678
  this._compositeKey = CompositeKey; // set the composite key to the one we just loaded
4232
4679
  // Cache the record name for faster lookups
4233
- this.CacheRecordName();
4680
+ this.cacheRecordName();
4234
4681
  // IS-A: discover child entity records after load completes.
4235
4682
  // This populates _childEntity so Save/Delete can delegate to the leaf.
4236
4683
  // Only runs for parent-type entities; idempotent via _childEntityDiscoveryDone.
@@ -4253,6 +4700,27 @@ export class BaseEntity {
4253
4700
  this._isLoading = false;
4254
4701
  }
4255
4702
  }
4703
+ /**
4704
+ * The provider's read of the row {@link InnerLoad} loads. While this record is a subtype-hint
4705
+ * probe, a throw from the read is recorded on the probe before it propagates, so the parent
4706
+ * that asked can tell it apart from a failure after the row loaded.
4707
+ */
4708
+ async readRowForLoad(key, relationshipsToLoad) {
4709
+ const user = this.ActiveUser;
4710
+ try {
4711
+ return await this.ProviderToUse.Load(this, key, relationshipsToLoad, user);
4712
+ }
4713
+ catch (e) {
4714
+ if (this._subtypeHintProbe) {
4715
+ this._subtypeHintProbe.RowReadFailed = true;
4716
+ }
4717
+ throw e;
4718
+ }
4719
+ }
4720
+ /** The error {@link InnerLoad} logs when the provider finds no row for the key. */
4721
+ static logLoadFoundNoRow(entityName, key) {
4722
+ LogError(`Error in BaseEntity.Load(${entityName}, Key: ${key.ToString()}`);
4723
+ }
4256
4724
  /**
4257
4725
  * Re-fetches the current record from the database using its existing primary key, replacing all in-memory field values
4258
4726
  * with the latest data from the database. This is useful when you know (or suspect) the record has been modified externally
@@ -4340,7 +4808,7 @@ export class BaseEntity {
4340
4808
  const isPlainObject = data && typeof data === 'object' && !Array.isArray(data) && !(data instanceof Date);
4341
4809
  const canTakeFastPath = isPlainObject &&
4342
4810
  !this._fieldsHydrated &&
4343
- this._Fields.length === 0 &&
4811
+ this._fields.length === 0 &&
4344
4812
  !this._parentEntity &&
4345
4813
  !this.EntityInfo?.IsParentType;
4346
4814
  if (canTakeFastPath) {
@@ -4396,7 +4864,7 @@ export class BaseEntity {
4396
4864
  }
4397
4865
  // Cache the record name for faster lookups if successfully loaded
4398
4866
  if (this._recordLoaded) {
4399
- this.CacheRecordName();
4867
+ this.cacheRecordName();
4400
4868
  // IS-A: discover child entity records after data load completes.
4401
4869
  await this.InitializeChildEntity();
4402
4870
  }
@@ -4578,7 +5046,7 @@ export class BaseEntity {
4578
5046
  // Create a new observable that debounces duplicative calls, and executes the delete.
4579
5047
  this._pendingDelete$ = of(options).pipe(
4580
5048
  // Execute the actual delete logic.
4581
- switchMap(opts => from(this._InnerDelete(opts))),
5049
+ switchMap(opts => from(this._innerDelete(opts))),
4582
5050
  // When the delete completes (whether successfully or not), clear the pending delete observable.
4583
5051
  finalize(() => { this._pendingDelete$ = null; }),
4584
5052
  // Ensure that all subscribers get the same result.
@@ -4591,7 +5059,7 @@ export class BaseEntity {
4591
5059
  * @param options
4592
5060
  * @returns
4593
5061
  */
4594
- async _InnerDelete(options) {
5062
+ async _innerDelete(options) {
4595
5063
  const currentResultCount = this.ResultHistory.length;
4596
5064
  const newResult = new BaseEntityResult();
4597
5065
  newResult.StartedAt = new Date();
@@ -4849,13 +5317,13 @@ export class BaseEntity {
4849
5317
  if (!entityInfo) {
4850
5318
  return { LeafEntityName: entityName, IsLeaf: true };
4851
5319
  }
4852
- return BaseEntity.ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser, md);
5320
+ return BaseEntity.resolveLeafEntityRecursive(entityInfo, primaryKey, contextUser, md);
4853
5321
  }
4854
5322
  /**
4855
5323
  * Internal recursive helper for leaf entity resolution.
4856
5324
  * Walks down the child hierarchy until no more children are found.
4857
5325
  */
4858
- static async ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser, provider) {
5326
+ static async resolveLeafEntityRecursive(entityInfo, primaryKey, contextUser, provider) {
4859
5327
  const childEntities = entityInfo.ChildEntities;
4860
5328
  if (childEntities.length === 0) {
4861
5329
  return { LeafEntityName: entityInfo.Name, IsLeaf: true };
@@ -4875,7 +5343,7 @@ export class BaseEntity {
4875
5343
  }, contextUser);
4876
5344
  if (result?.Success && result.Results?.length > 0) {
4877
5345
  // Found a child — recurse to see if there's an even more specific leaf
4878
- return BaseEntity.ResolveLeafEntityRecursive(child, primaryKey, contextUser, provider);
5346
+ return BaseEntity.resolveLeafEntityRecursive(child, primaryKey, contextUser, provider);
4879
5347
  }
4880
5348
  }
4881
5349
  // No child found — this entity IS the leaf