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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts +27 -0
  2. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts.map +1 -0
  3. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.js +6 -0
  4. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.js.map +1 -0
  5. package/dist/generic/baseEngine.d.ts.map +1 -1
  6. package/dist/generic/baseEngine.js +2 -3
  7. package/dist/generic/baseEngine.js.map +1 -1
  8. package/dist/generic/baseEntity.d.ts +119 -0
  9. package/dist/generic/baseEntity.d.ts.map +1 -1
  10. package/dist/generic/baseEntity.js +522 -87
  11. package/dist/generic/baseEntity.js.map +1 -1
  12. package/dist/generic/baseInfo.d.ts +6 -0
  13. package/dist/generic/baseInfo.d.ts.map +1 -1
  14. package/dist/generic/baseInfo.js +19 -1
  15. package/dist/generic/baseInfo.js.map +1 -1
  16. package/dist/generic/compositeKey.d.ts +108 -0
  17. package/dist/generic/compositeKey.d.ts.map +1 -1
  18. package/dist/generic/compositeKey.js +225 -10
  19. package/dist/generic/compositeKey.js.map +1 -1
  20. package/dist/generic/databaseProviderBase.d.ts +49 -0
  21. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  22. package/dist/generic/databaseProviderBase.js +90 -11
  23. package/dist/generic/databaseProviderBase.js.map +1 -1
  24. package/dist/generic/embeddedRecord.d.ts +2 -1
  25. package/dist/generic/embeddedRecord.d.ts.map +1 -1
  26. package/dist/generic/embeddedRecord.js +9 -2
  27. package/dist/generic/embeddedRecord.js.map +1 -1
  28. package/dist/generic/entityCompanion.d.ts +6 -1
  29. package/dist/generic/entityCompanion.d.ts.map +1 -1
  30. package/dist/generic/entityCompanion.js +7 -0
  31. package/dist/generic/entityCompanion.js.map +1 -1
  32. package/dist/generic/entityInfo.d.ts +89 -7
  33. package/dist/generic/entityInfo.d.ts.map +1 -1
  34. package/dist/generic/entityInfo.js +127 -14
  35. package/dist/generic/entityInfo.js.map +1 -1
  36. package/dist/generic/entitySubtypeResolver.d.ts +19 -0
  37. package/dist/generic/entitySubtypeResolver.d.ts.map +1 -0
  38. package/dist/generic/entitySubtypeResolver.js +24 -0
  39. package/dist/generic/entitySubtypeResolver.js.map +1 -0
  40. package/dist/generic/entityTransactionScope.d.ts +6 -3
  41. package/dist/generic/entityTransactionScope.d.ts.map +1 -1
  42. package/dist/generic/entityTransactionScope.js +6 -3
  43. package/dist/generic/entityTransactionScope.js.map +1 -1
  44. package/dist/generic/extendedTypeValue.d.ts +47 -0
  45. package/dist/generic/extendedTypeValue.d.ts.map +1 -0
  46. package/dist/generic/extendedTypeValue.js +152 -0
  47. package/dist/generic/extendedTypeValue.js.map +1 -0
  48. package/dist/generic/filters/compositeFilter.d.ts +77 -0
  49. package/dist/generic/filters/compositeFilter.d.ts.map +1 -0
  50. package/dist/generic/filters/compositeFilter.js +324 -0
  51. package/dist/generic/filters/compositeFilter.js.map +1 -0
  52. package/dist/generic/filters/filter.types.d.ts +29 -0
  53. package/dist/generic/filters/filter.types.d.ts.map +1 -0
  54. package/dist/generic/filters/filter.types.js +28 -0
  55. package/dist/generic/filters/filter.types.js.map +1 -0
  56. package/dist/generic/filters/index.d.ts +3 -0
  57. package/dist/generic/filters/index.d.ts.map +1 -0
  58. package/dist/generic/filters/index.js +3 -0
  59. package/dist/generic/filters/index.js.map +1 -0
  60. package/dist/generic/geoFields.d.ts +64 -0
  61. package/dist/generic/geoFields.d.ts.map +1 -0
  62. package/dist/generic/geoFields.js +108 -0
  63. package/dist/generic/geoFields.js.map +1 -0
  64. package/dist/generic/interfaces.d.ts +11 -2
  65. package/dist/generic/interfaces.d.ts.map +1 -1
  66. package/dist/generic/interfaces.js.map +1 -1
  67. package/dist/generic/localCacheManager.d.ts +4 -2
  68. package/dist/generic/localCacheManager.d.ts.map +1 -1
  69. package/dist/generic/localCacheManager.js +5 -3
  70. package/dist/generic/localCacheManager.js.map +1 -1
  71. package/dist/generic/providerBase.d.ts +118 -3
  72. package/dist/generic/providerBase.d.ts.map +1 -1
  73. package/dist/generic/providerBase.js +356 -70
  74. package/dist/generic/providerBase.js.map +1 -1
  75. package/dist/generic/relatedRecordBatchLoader.js +2 -2
  76. package/dist/generic/relatedRecordBatchLoader.js.map +1 -1
  77. package/dist/generic/relatedRecordCollection.d.ts +2 -1
  78. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  79. package/dist/generic/relatedRecordCollection.js +16 -4
  80. package/dist/generic/relatedRecordCollection.js.map +1 -1
  81. package/dist/index.d.ts +5 -0
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +5 -0
  84. package/dist/index.js.map +1 -1
  85. package/package.json +3 -3
@@ -11,11 +11,14 @@ var EntityField_1;
11
11
  import { IsMemberOverridden, MJEventType, MJGlobal, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, WarningManager } from '@memberjunction/global';
12
12
  import { GetDataHooks } from './dataHooks.js';
13
13
  import { EntityFieldInfo, EntityInfo, EntityFieldTSType, EntityPermissionType, RecordChange, ValidationErrorInfo, ValidationResult } from './entityInfo.js';
14
+ import { EntitySubtypeResolver } from './entitySubtypeResolver.js';
15
+ import { BaseEngineRegistry } from './baseEngineRegistry.js';
16
+ import { IsPermittedImageFieldValue, IsValidCssColor, TryParseJsonText } from './extendedTypeValue.js';
14
17
  import { EntityDeleteOptions, EntitySaveOptions, ProviderType } from './interfaces.js';
15
18
  import { Metadata } from './metadata.js';
16
19
  import { RunView } from '../views/runView.js';
17
20
  import { LogDebug, LogError } from './logging.js';
18
- import { CompositeKey } from './compositeKey.js';
21
+ import { CompositeKey, KeyValuePair } from './compositeKey.js';
19
22
  import { RelatedRecordCollection } from './relatedRecordCollection.js';
20
23
  import { COMPANION_PAYLOAD_KEY } from './entityCompanion.js';
21
24
  import { EmbeddedRecord } from './embeddedRecord.js';
@@ -327,6 +330,35 @@ let EntityField = class EntityField {
327
330
  const nullNote = ef.AllowsNull ? ' (or null)' : '';
328
331
  result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} must be one of: ${ef.ValueListValuesForDisplay}${nullNote}. Current value is '${this.Value}'`, this.Value));
329
332
  }
333
+ // ExtendedType semantic checks (Image / Color / JSON). Empty values are handled by
334
+ // the AllowsNull rung above — only non-empty strings are inspected here.
335
+ if (ef.TSType === EntityFieldTSType.String && this.Value != null && this.Value !== '') {
336
+ const text = String(this.Value);
337
+ switch (ef.ExtendedType) {
338
+ case 'JSON': {
339
+ const parsed = TryParseJsonText(text);
340
+ if (parsed.ok === false) {
341
+ result.Success = false;
342
+ result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} must be valid JSON. ${parsed.message}`, this.Value));
343
+ }
344
+ break;
345
+ }
346
+ case 'Color': {
347
+ if (!IsValidCssColor(text)) {
348
+ result.Success = false;
349
+ result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} must be a CSS color (hex, rgb, or hsl)`, this.Value));
350
+ }
351
+ break;
352
+ }
353
+ case 'Image': {
354
+ if (!IsPermittedImageFieldValue(text)) {
355
+ result.Success = false;
356
+ result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} must be an image URL or inline image (data URI / base64)`, this.Value));
357
+ }
358
+ break;
359
+ }
360
+ }
361
+ }
330
362
  }
331
363
  return result;
332
364
  }
@@ -505,6 +537,56 @@ export class BaseEntityResult {
505
537
  this.StartedAt = new Date();
506
538
  this.EndedAt = new Date();
507
539
  }
540
+ /**
541
+ * Renders ONE entry of the {@link Errors} array as human-readable text.
542
+ *
543
+ * `Errors` is typed `any[]`, and two shapes land in it from different places:
544
+ *
545
+ * - **`ValidationErrorInfo`** — carries **`Message`** (capital M), plus `Source`, `Value` and
546
+ * `Type`. This is what `_InnerSave` puts there when validation refuses a save: it throws the
547
+ * `ValidationResult`, and the catch block assigns `newResult.Errors = e.Errors`.
548
+ * - **`Error`** (and anything error-like) — carries lowercase **`message`**.
549
+ *
550
+ * This used to read `err.message` ONLY, so every `ValidationErrorInfo` fell through to
551
+ * `JSON.stringify(err)`. That is not a cosmetic difference: `CompleteMessage` is the string the
552
+ * server hands the client on a failed save — every write-refusal throw in `ResolverBase`
553
+ * (`CreateRecord`/`UpdateRecord`/`DeleteRecord`) puts it in the `GraphQLError`, and
554
+ * `SaveEntityGraphOperation` puts it in `ErrorMessage` — so the whole point of writing a careful,
555
+ * field-named refusal in a subclass's `ValidateAsync()` was defeated at the last step, and the
556
+ * user saw
557
+ * `{"Source":"ParentContractID","Message":"…","Value":null,"Type":"Failure"}` in a toast.
558
+ *
559
+ * Nothing catches this at compile time because `Errors` is `any[]`; nothing catches it at runtime
560
+ * because `JSON.stringify` always succeeds. It is only visible by reading the message a user got.
561
+ *
562
+ * The parameter is `unknown` rather than `any` — per `.claude/rules/typescript-style.md` — because
563
+ * not knowing the shape is the whole reason this helper exists, and `unknown` forces the narrowing
564
+ * that makes each shape's handling explicit. Callers pass `any` (the `Errors` array and the `Error`
565
+ * property are both legacy `any`), which is assignable, so no call site changes.
566
+ *
567
+ * `Message` is preferred over `message` because a `ValidationErrorInfo` has only the former,
568
+ * while an `Error` has only the latter — so the order matters solely for an object carrying both,
569
+ * where the MJ-native field is the better answer.
570
+ *
571
+ * @param err - One entry from the `Errors` array.
572
+ * @returns The entry's human-readable text, falling back to JSON for a shape with neither field.
573
+ */
574
+ static ErrorText(err) {
575
+ if (err === null || err === undefined) {
576
+ return '';
577
+ }
578
+ if (typeof err === 'string') {
579
+ return err;
580
+ }
581
+ if (typeof err === 'object') {
582
+ const shaped = err;
583
+ const text = shaped.Message ?? shaped.message;
584
+ if (typeof text === 'string' && text.trim().length > 0) {
585
+ return text;
586
+ }
587
+ }
588
+ return JSON.stringify(err);
589
+ }
508
590
  /**
509
591
  * Returns a complete message that includes the Message property (if present), the Error property (if present), and any Errors array items (if present).
510
592
  */
@@ -514,23 +596,26 @@ export class BaseEntityResult {
514
596
  if (this.Message && this.Message.trim().length > 0) {
515
597
  msg = this.Message;
516
598
  }
517
- // now check the simple Error property
599
+ // now check the simple Error property. Same shape problem as the Errors array below, so the
600
+ // same helper answers it: a string, an Error (lowercase `message`), or an MJ
601
+ // ValidationErrorInfo (capital `Message`) all render as their text rather than as JSON.
518
602
  if (this.Error) {
519
- msg = (msg ? msg + '\n' : '');
520
- if (typeof this.Error === 'string') {
521
- msg += this.Error;
522
- }
523
- else if (this.Error.message) {
524
- msg += this.Error.message;
525
- }
526
- else {
527
- msg += JSON.stringify(this.Error);
528
- }
603
+ msg = (msg ? msg + '\n' : '') + BaseEntityResult.ErrorText(this.Error);
529
604
  }
530
- // now check the Errors array
605
+ // now check the Errors array.
606
+ //
607
+ // NOT de-duplicated, deliberately. Some producers set BOTH `Message` and `Errors` and build
608
+ // the former out of the latter — `_InnerSave`/`_InnerDelete` do on an IS-A parent failure —
609
+ // so their text does appear twice here. Suppressing a repeat was tried and reverted: any
610
+ // containment test is lossy in ways a reader cannot detect. Three fields failing with the
611
+ // same sentence collapse to one line; an entry whose text is a substring of another is kept
612
+ // or dropped depending on ARRAY ORDER; and a distinct error vanishes when its text happens to
613
+ // appear inside the summary. Saying something twice is ugly. Silently reporting one problem
614
+ // when there were three is the failure this whole class of bug is about, so the duplication
615
+ // stays until a producer-side fix removes it at the source.
531
616
  if (this.Errors && this.Errors.length > 0) {
532
617
  // append
533
- msg = (msg ? msg + '\n' : '') + this.Errors.map(err => err.message || JSON.stringify(err)).join('\n');
618
+ msg = (msg ? msg + '\n' : '') + this.Errors.map(err => BaseEntityResult.ErrorText(err)).join('\n');
534
619
  }
535
620
  return msg;
536
621
  }
@@ -816,6 +901,16 @@ export class BaseEntity {
816
901
  * pay nothing for the feature, not even an empty Map per instance.
817
902
  */
818
903
  this._companions = null;
904
+ /**
905
+ * Returns true if the object is Dirty, meaning something has changed since it was last saved to the database, and false otherwise. For new records, this will always return true.
906
+ *
907
+ * @remarks
908
+ * Dirtiness rolls up **companions** as well as fields. Without that rollup, a clean parent with
909
+ * three brand-new children reports `Dirty === false`, `_InnerSave` takes its not-dirty early
910
+ * return, and the children are silently never persisted — the save reports success and writes
911
+ * nothing. See {@link EntityCompanion.Dirty}.
912
+ */
913
+ this._isCheckingDirty = false;
819
914
  this._compositeKey = null;
820
915
  // ────────────────────────────────────────────────────────────────────
821
916
  // Restore context — populated by callers immediately before Save() to
@@ -833,6 +928,13 @@ export class BaseEntity {
833
928
  this._restoreContext = null;
834
929
  // Holds the current pending save observable (if any)
835
930
  this._pendingSave$ = null;
931
+ /**
932
+ * This method is used automatically within Save() and is used to determine if the state of the object is valid relative to the validation rules that are defined in metadata. In addition, sub-classes can
933
+ * override or wrap this base class method to add other logic for validation.
934
+ *
935
+ * @returns ValidationResult The validation result
936
+ */
937
+ this._isValidating = false;
836
938
  /**
837
939
  * private storage for vectors that might be used for this entity, typically in association with individual fields
838
940
  * however it is possible for the string in the map to be any unique key relative to the object so you could have vectors
@@ -852,6 +954,42 @@ export class BaseEntity {
852
954
  this._provider = Provider;
853
955
  this.init();
854
956
  }
957
+ /**
958
+ * The provider actually stored on this instance, or `null` if none was bound.
959
+ * Unlike {@link ProviderToUse}, this does **not** fall back to the process-wide
960
+ * {@link BaseEntity.Provider}. Use it to detect a dropped constructor argument:
961
+ * `GetEntityObject(graphProvider)` must yield `BoundProvider === graphProvider`.
962
+ */
963
+ get BoundProvider() {
964
+ return this._provider;
965
+ }
966
+ /**
967
+ * Bind this instance to a provider after construction.
968
+ *
969
+ * **Rule (ORM, not just metadata-sync):** every DB read and write on this
970
+ * instance — Save, Load, Delete, RunView, GetEntityObject of children/embeds,
971
+ * lookups, RecordGeoCode — MUST use this provider. Mixing another provider
972
+ * (especially the process-wide host) into the same record graph is a deadlock:
973
+ * a child FK waits on an uncommitted parent on another connection.
974
+ *
975
+ * {@link ProviderBase.GetEntityObject} always calls this so a subclass that
976
+ * declares `constructor(Entity: EntityInfo)` and drops the second ClassFactory
977
+ * argument cannot silently run on the global host.
978
+ */
979
+ BindProvider(provider) {
980
+ this._provider = provider;
981
+ if (this._parentEntity && this._parentEntity.BoundProvider !== provider) {
982
+ this._parentEntity.BindProvider(provider);
983
+ }
984
+ if (this._childEntity && this._childEntity.BoundProvider !== provider) {
985
+ this._childEntity.BindProvider(provider);
986
+ }
987
+ if (this._companions) {
988
+ for (const companion of this._companions.values()) {
989
+ companion.BindProvider(provider);
990
+ }
991
+ }
992
+ }
855
993
  /**
856
994
  * Initializes the IS-A parent entity composition chain. For child type entities,
857
995
  * this creates the parent entity instance (and recursively its parent, etc.) and
@@ -1062,6 +1200,249 @@ export class BaseEntity {
1062
1200
  // InitializeChildEntity is idempotent via _childEntityDiscoveryDone flag
1063
1201
  await childEntity.InitializeChildEntity();
1064
1202
  }
1203
+ static { this._subtypeLookupCache = new Map(); }
1204
+ /**
1205
+ * Clears the static memoization cache used by SubtypeSelector path evaluation.
1206
+ */
1207
+ static ClearSubtypeLookupCache() {
1208
+ BaseEntity._subtypeLookupCache.clear();
1209
+ }
1210
+ /**
1211
+ * Prospective counterpart to FindISAChildEntity.
1212
+ * Evaluates which IsA child subtype entity this record should have based on:
1213
+ * 1. Registered EntitySubtypeResolver (ClassFactory key = entity name)
1214
+ * 2. Entity.SubtypeSelector declarative FK traversal path
1215
+ * 3. Unconditional single-child IsA fallback (ChildEntities.length === 1)
1216
+ * 4. Otherwise null (no subtype)
1217
+ *
1218
+ * @see plans/sync-composition-axes.md
1219
+ */
1220
+ async ResolveSubtypeEntityName() {
1221
+ if (!this.EntityInfo.IsParentType || !this.EntityInfo.ChildEntities || this.EntityInfo.ChildEntities.length === 0) {
1222
+ return null;
1223
+ }
1224
+ // 1. Registered resolver override
1225
+ const reg = MJGlobal.Instance.ClassFactory.GetRegistration(EntitySubtypeResolver, this.EntityInfo.Name);
1226
+ if (reg) {
1227
+ const resolution = MJGlobal.Instance.ClassFactory.TryCreateInstance(EntitySubtypeResolver, this.EntityInfo.Name);
1228
+ if (resolution.Resolved && resolution.Instance) {
1229
+ const raw = resolution.Instance.Resolve(this);
1230
+ const candidate = raw instanceof Promise ? await raw : raw;
1231
+ if (candidate != null && candidate.trim() !== '') {
1232
+ const trimmed = candidate.trim();
1233
+ const match = this.EntityInfo.ChildEntities.find(c => c.Name.trim().toLowerCase() === trimmed.toLowerCase());
1234
+ if (!match) {
1235
+ throw new Error(`EntitySubtypeResolver for '${this.EntityInfo.Name}' returned '${candidate}', which is not a declared IsA child entity of '${this.EntityInfo.Name}'.`);
1236
+ }
1237
+ return match.Name;
1238
+ }
1239
+ return null;
1240
+ }
1241
+ }
1242
+ // 2. Entity.SubtypeSelector declarative path
1243
+ const selectorConfig = this.EntityInfo.SubtypeSelectorConfig;
1244
+ if (selectorConfig && selectorConfig.Path && selectorConfig.Path.trim() !== '') {
1245
+ const pathResult = await this.evaluateSubtypeSelectorPath(selectorConfig.Path.trim());
1246
+ if (pathResult != null && pathResult.trim() !== '') {
1247
+ const trimmed = pathResult.trim();
1248
+ const match = this.EntityInfo.ChildEntities.find(c => c.Name.trim().toLowerCase() === trimmed.toLowerCase());
1249
+ if (!match) {
1250
+ 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}'.`);
1251
+ }
1252
+ return match.Name;
1253
+ }
1254
+ return null;
1255
+ }
1256
+ // 3. Exactly one child, unconditional IsA
1257
+ if (!this.EntityInfo.AllowMultipleSubtypes && this.EntityInfo.ChildEntities.length === 1) {
1258
+ return this.EntityInfo.ChildEntities[0].Name;
1259
+ }
1260
+ // 4. Otherwise null
1261
+ return null;
1262
+ }
1263
+ /**
1264
+ * Create-safe prospective counterpart to InitializeChildEntity.
1265
+ * Unlike createAndLinkChildEntity, does NOT unlink when InnerLoad finds no row —
1266
+ * that is the create case. Idempotent. Defaults to ResolveSubtypeEntityName()
1267
+ * when no name is passed.
1268
+ *
1269
+ * @param entityName Optional explicit child entity name. If omitted, resolved via ResolveSubtypeEntityName().
1270
+ * @returns The linked child BaseEntity, or null if no subtype applies.
1271
+ */
1272
+ async EnsureISAChild(entityName) {
1273
+ if (!entityName) {
1274
+ entityName = await this.ResolveSubtypeEntityName();
1275
+ }
1276
+ if (!entityName) {
1277
+ return null;
1278
+ }
1279
+ const matchedChild = this.EntityInfo.ChildEntities?.find(c => c.Name.trim().toLowerCase() === entityName.trim().toLowerCase());
1280
+ if (!matchedChild) {
1281
+ throw new Error(`'${entityName}' is not a declared IsA child entity of '${this.EntityInfo.Name}'.`);
1282
+ }
1283
+ const resolvedName = matchedChild.Name;
1284
+ if (!this.EntityInfo.AllowMultipleSubtypes) {
1285
+ // Disjoint hierarchy
1286
+ if (this._childEntity) {
1287
+ if (this._childEntity.EntityInfo.Name.trim().toLowerCase() === resolvedName.trim().toLowerCase()) {
1288
+ return this._childEntity; // Idempotent
1289
+ }
1290
+ throw new Error(`Entity '${this.EntityInfo.Name}' already has an attached child entity of type '${this._childEntity.EntityInfo.Name}', cannot attach '${resolvedName}' (AllowMultipleSubtypes is false).`);
1291
+ }
1292
+ const childProvider = this.ProviderToUse;
1293
+ const childEntity = await childProvider.GetEntityObject(resolvedName, this._contextCurrentUser);
1294
+ // Wire up shared instance chain
1295
+ this.replaceChildParentChain(childEntity);
1296
+ this._childEntity = childEntity;
1297
+ const dirtySnapshots = this.captureChainDirtyState();
1298
+ if (this.PrimaryKey && this.PrimaryKey.HasValue) {
1299
+ const loaded = await childEntity.InnerLoad(this.PrimaryKey);
1300
+ if (!loaded) {
1301
+ this.mirrorSharedKeysToChild(childEntity);
1302
+ }
1303
+ }
1304
+ else {
1305
+ this.mirrorSharedKeysToChild(childEntity);
1306
+ }
1307
+ this.restoreChainDirtyState(dirtySnapshots);
1308
+ // Recursively discover grandchildren if the child is also a parent type
1309
+ if (childEntity.EntityInfo.IsParentType) {
1310
+ await childEntity.EnsureISAChild();
1311
+ }
1312
+ return childEntity;
1313
+ }
1314
+ else {
1315
+ // Overlapping hierarchy (AllowMultipleSubtypes = true)
1316
+ if (!this._childEntities) {
1317
+ this._childEntities = [];
1318
+ }
1319
+ if (!this._childEntities.some(c => c.entityName.trim().toLowerCase() === resolvedName.trim().toLowerCase())) {
1320
+ this._childEntities.push({ entityName: resolvedName });
1321
+ }
1322
+ const childProvider = this.ProviderToUse;
1323
+ const childEntity = await childProvider.GetEntityObject(resolvedName, this._contextCurrentUser);
1324
+ this.replaceChildParentChain(childEntity);
1325
+ const dirtySnapshots = this.captureChainDirtyState();
1326
+ if (this.PrimaryKey && this.PrimaryKey.HasValue) {
1327
+ const loaded = await childEntity.InnerLoad(this.PrimaryKey);
1328
+ if (!loaded) {
1329
+ this.mirrorSharedKeysToChild(childEntity);
1330
+ }
1331
+ }
1332
+ else {
1333
+ this.mirrorSharedKeysToChild(childEntity);
1334
+ }
1335
+ this.restoreChainDirtyState(dirtySnapshots);
1336
+ if (childEntity.EntityInfo.IsParentType) {
1337
+ await childEntity.EnsureISAChild();
1338
+ }
1339
+ return childEntity;
1340
+ }
1341
+ }
1342
+ mirrorSharedKeysToChild(childEntity) {
1343
+ const parentPks = this.EntityInfo.PrimaryKeys;
1344
+ if (!parentPks || parentPks.length === 0)
1345
+ return;
1346
+ for (const pk of parentPks) {
1347
+ const val = this.Get(pk.Name);
1348
+ if (val != null) {
1349
+ childEntity.mirrorSharedKey(pk.Name, val);
1350
+ }
1351
+ }
1352
+ }
1353
+ async evaluateSubtypeSelectorPath(path) {
1354
+ const segments = path.split('.').map(s => s.trim()).filter(Boolean);
1355
+ if (segments.length === 0)
1356
+ return null;
1357
+ let currentEntity = this;
1358
+ let currentEntityInfo = this.EntityInfo;
1359
+ for (let i = 0; i < segments.length - 1; i++) {
1360
+ const fieldName = segments[i];
1361
+ const fieldInfo = currentEntityInfo.Fields.find(f => f.Name.trim().toLowerCase() === fieldName.toLowerCase());
1362
+ if (!fieldInfo) {
1363
+ throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': field '${fieldName}' was not found on entity '${currentEntityInfo.Name}'.`);
1364
+ }
1365
+ const fkValue = currentEntity.Get(fieldInfo.Name);
1366
+ if (fkValue == null || fkValue === '') {
1367
+ return null;
1368
+ }
1369
+ const relatedEntityName = fieldInfo.RelatedEntity;
1370
+ if (!relatedEntityName) {
1371
+ throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': field '${fieldName}' on entity '${currentEntityInfo.Name}' is not a foreign key relationship.`);
1372
+ }
1373
+ const targetEntity = await this.getSubtypePathTargetEntity(relatedEntityName, fkValue);
1374
+ if (!targetEntity) {
1375
+ return null;
1376
+ }
1377
+ currentEntity = targetEntity;
1378
+ currentEntityInfo = targetEntity.EntityInfo;
1379
+ }
1380
+ const terminalSegment = segments[segments.length - 1];
1381
+ const terminalField = currentEntityInfo.Fields.find(f => f.Name.trim().toLowerCase() === terminalSegment.toLowerCase());
1382
+ if (!terminalField) {
1383
+ throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': terminal field '${terminalSegment}' was not found on entity '${currentEntityInfo.Name}'.`);
1384
+ }
1385
+ const terminalValue = currentEntity.Get(terminalField.Name);
1386
+ if (terminalValue == null || typeof terminalValue !== 'string' || terminalValue.trim() === '') {
1387
+ return null;
1388
+ }
1389
+ return terminalValue.trim();
1390
+ }
1391
+ async getSubtypePathTargetEntity(entityName, pkValue) {
1392
+ const cacheKey = `${entityName.trim().toLowerCase()}|${String(pkValue).trim().toLowerCase()}`;
1393
+ if (BaseEntity._subtypeLookupCache.has(cacheKey)) {
1394
+ return BaseEntity._subtypeLookupCache.get(cacheKey) ?? null;
1395
+ }
1396
+ // 1. Check BaseEngineRegistry for loaded cached entities
1397
+ const cachedMatches = BaseEngineRegistry.Instance.FindCachedEntity(entityName);
1398
+ if (cachedMatches && cachedMatches.length > 0) {
1399
+ for (const match of cachedMatches) {
1400
+ const found = match.records.find(r => {
1401
+ const firstPK = r.FirstPrimaryKey; // first-pk-ok: FK target — pkValue is one SubtypeSelector FK column's value
1402
+ if (firstPK) {
1403
+ return String(firstPK.Value).trim().toLowerCase() === String(pkValue).trim().toLowerCase();
1404
+ }
1405
+ return false;
1406
+ });
1407
+ if (found) {
1408
+ BaseEntity._subtypeLookupCache.set(cacheKey, found);
1409
+ return found;
1410
+ }
1411
+ }
1412
+ }
1413
+ // 2. Fall back to loading via provider
1414
+ const provider = this.ProviderToUse;
1415
+ if (!provider?.GetEntityObject) {
1416
+ BaseEntity._subtypeLookupCache.set(cacheKey, null);
1417
+ return null;
1418
+ }
1419
+ try {
1420
+ const targetObj = await provider.GetEntityObject(entityName, this._contextCurrentUser);
1421
+ if (!targetObj) {
1422
+ BaseEntity._subtypeLookupCache.set(cacheKey, null);
1423
+ return null;
1424
+ }
1425
+ let key;
1426
+ if (pkValue instanceof CompositeKey) {
1427
+ key = pkValue;
1428
+ }
1429
+ else {
1430
+ key = new CompositeKey();
1431
+ const pkName = targetObj.FirstPrimaryKey.Name; // first-pk-ok: FK target — pkValue is one SubtypeSelector FK column's value
1432
+ key.KeyValuePairs.push(new KeyValuePair(pkName, pkValue));
1433
+ }
1434
+ const loaded = await targetObj.InnerLoad(key);
1435
+ if (loaded) {
1436
+ BaseEntity._subtypeLookupCache.set(cacheKey, targetObj);
1437
+ return targetObj;
1438
+ }
1439
+ }
1440
+ catch {
1441
+ // On load failure, return null
1442
+ }
1443
+ BaseEntity._subtypeLookupCache.set(cacheKey, null);
1444
+ return null;
1445
+ }
1065
1446
  captureChainDirtyState() {
1066
1447
  const snapshots = [];
1067
1448
  let curr = this;
@@ -1102,6 +1483,10 @@ export class BaseEntity {
1102
1483
  // Replace it with `this`, then walk upward replacing each parent.
1103
1484
  let childParent = childEntity._parentEntity;
1104
1485
  let ourInstance = this;
1486
+ if (!childParent) {
1487
+ childEntity._parentEntity = this;
1488
+ return;
1489
+ }
1105
1490
  while (childParent && ourInstance) {
1106
1491
  // Replace the child's parent reference with our shared instance
1107
1492
  if (childParent === childEntity._parentEntity) {
@@ -1346,6 +1731,8 @@ export class BaseEntity {
1346
1731
  throw new Error(`BaseEntity.ConstructUninitializedEntity: ClassFactory could not construct '${entityName}'. ` +
1347
1732
  `Ensure the entity class is registered.`);
1348
1733
  }
1734
+ // Same rebind as GetEntityObject — 1-arg subclasses drop the ClassFactory provider.
1735
+ instance.BindProvider(provider);
1349
1736
  await instance.Config(this.ContextCurrentUser);
1350
1737
  await instance.InitializeParentEntity();
1351
1738
  // Recurse so a *new* peer's own embeds are constructed (required nested
@@ -1541,7 +1928,7 @@ export class BaseEntity {
1541
1928
  // One `RunViews` for all remaining collections — N declared collections cost one round trip,
1542
1929
  // not N. Params are built per collection so each keeps its own filter and ordering. The key
1543
1930
  // is escaped exactly as RelatedRecordCollection.Load() and the batch loader escape it.
1544
- const parentKeyLiteral = String(this.FirstPrimaryKey?.Value).replace(/'/g, "''");
1931
+ const parentKeyLiteral = String(this.FirstPrimaryKey?.Value).replace(/'/g, "''"); // first-pk-ok: RelatedEntityJoinField is one FK column, so the parent key it holds is single-column by design
1545
1932
  const rv = new RunView(this.ProviderToUse);
1546
1933
  const results = await rv.RunViews(needsDatabase.map(c => ({
1547
1934
  EntityName: c.RelatedEntityName,
@@ -1570,7 +1957,7 @@ export class BaseEntity {
1570
1957
  seedEmbedLoadVisited() {
1571
1958
  const seeded = new Set();
1572
1959
  const name = this.EntityInfo?.Name;
1573
- const pk = this.FirstPrimaryKey?.Value;
1960
+ const pk = this.FirstPrimaryKey?.Value; // first-pk-ok: must equal EmbeddedRecord.LoadEager's `entity:fk` cycle token, and an FK holds one column
1574
1961
  if (name && pk !== null && pk !== undefined && pk !== '') {
1575
1962
  seeded.add(`${name}:${String(pk)}`);
1576
1963
  }
@@ -2164,6 +2551,46 @@ export class BaseEntity {
2164
2551
  }
2165
2552
  return this._fieldCache.get(lcase) || null;
2166
2553
  }
2554
+ /**
2555
+ * True when any of the named fields exists on this entity and its current value
2556
+ * differs from the last loaded or saved value.
2557
+ *
2558
+ * This is the boolean form of `GetFieldByName(name)?.Dirty === true`. Prefer it at
2559
+ * call sites that only care whether a column has been edited — pricing, validation,
2560
+ * and "did the user type this" gates — so they do not repeat the optional-chain and
2561
+ * do not treat a missing field as a distinct third state.
2562
+ *
2563
+ * Semantics:
2564
+ * - **Unknown or blank names return `false`.** They are not dirty; they are absent.
2565
+ * Callers that must distinguish "no such field" from "field is clean" should use
2566
+ * {@link GetFieldByName} and inspect the result.
2567
+ * - **Names are case-insensitive and trimmed**, matching {@link GetFieldByName}.
2568
+ * - **Read-only fields are never dirty**, even if their value was overwritten internally.
2569
+ * - **Multiple names are OR'd.** `FieldIsDirty('UnitPrice', 'ProductPriceID')` is true
2570
+ * if either field has been edited. An empty rest list is a single-field check.
2571
+ *
2572
+ * @param fieldName First field to test. A missing/blank name contributes `false`.
2573
+ * @param more Additional field names, each OR'd with the first.
2574
+ * @returns `true` if at least one named field exists and is dirty; otherwise `false`.
2575
+ *
2576
+ * @example
2577
+ * ```ts
2578
+ * // Single field
2579
+ * if (line.FieldIsDirty('UnitPrice')) { ... }
2580
+ *
2581
+ * // Either money column was edited
2582
+ * if (line.FieldIsDirty('UnitPrice', 'ProductPriceID')) { ... }
2583
+ * ```
2584
+ */
2585
+ FieldIsDirty(fieldName, ...more) {
2586
+ const names = more.length === 0 ? [fieldName] : [fieldName, ...more];
2587
+ for (const name of names) {
2588
+ if (this.GetFieldByName(name)?.Dirty === true) {
2589
+ return true;
2590
+ }
2591
+ }
2592
+ return false;
2593
+ }
2167
2594
  /**
2168
2595
  * Convenience method to access a field by code name. This method is case-insensitive and will return null if the field is not found.
2169
2596
  * @param codeName
@@ -2188,26 +2615,25 @@ export class BaseEntity {
2188
2615
  }
2189
2616
  return this._codeNameCache.get(lcase) || null;
2190
2617
  }
2191
- /**
2192
- * Returns true if the object is Dirty, meaning something has changed since it was last saved to the database, and false otherwise. For new records, this will always return true.
2193
- *
2194
- * @remarks
2195
- * Dirtiness rolls up **companions** as well as fields. Without that rollup, a clean parent with
2196
- * three brand-new children reports `Dirty === false`, `_InnerSave` takes its not-dirty early
2197
- * return, and the children are silently never persisted — the save reports success and writes
2198
- * nothing. See {@link EntityCompanion.Dirty}.
2199
- */
2200
2618
  get Dirty() {
2201
- if (!this.IsSaved)
2202
- return true;
2203
- if (this.companionsDirty)
2204
- return true;
2205
- // Raw mode means LoadFromData populated us but no mutation has happened — nothing can be
2206
- // dirty. Avoid hydrating just to check.
2207
- if (!this._fieldsHydrated)
2208
- return this._parentEntity?.Dirty ?? false;
2209
- return this._Fields.some(f => f.Dirty) ||
2210
- (this._parentEntity?.Dirty ?? false);
2619
+ if (this._isCheckingDirty)
2620
+ return false;
2621
+ this._isCheckingDirty = true;
2622
+ try {
2623
+ if (!this.IsSaved)
2624
+ return true;
2625
+ if (this.companionsDirty)
2626
+ return true;
2627
+ // Raw mode means LoadFromData populated us but no mutation has happened — nothing can be
2628
+ // dirty. Avoid hydrating just to check.
2629
+ if (!this._fieldsHydrated)
2630
+ return this._parentEntity?.Dirty ?? false;
2631
+ return this._Fields.some(f => f.Dirty) ||
2632
+ (this._parentEntity?.Dirty ?? false);
2633
+ }
2634
+ finally {
2635
+ this._isCheckingDirty = false;
2636
+ }
2211
2637
  }
2212
2638
  /**
2213
2639
  * Whether any registered companion holds unsaved work.
@@ -2242,7 +2668,7 @@ export class BaseEntity {
2242
2668
  * Helper method to return just the first Primary Key
2243
2669
  */
2244
2670
  get FirstPrimaryKey() {
2245
- return this.PrimaryKeys[0];
2671
+ return this.PrimaryKeys[0]; // first-pk-ok: the accessor itself
2246
2672
  }
2247
2673
  /**
2248
2674
  * Returns true if the record has been loaded from the database, false otherwise. This is useful to check to see if the record is in a "New Record" state or not.
@@ -2635,7 +3061,7 @@ export class BaseEntity {
2635
3061
  async GetRelatedEntityDataExt(re, filter = null, maxRecords = null) {
2636
3062
  // we need to query the database to get related entity info
2637
3063
  const params = EntityInfo.BuildRelationshipViewParams(this, re, filter, maxRecords);
2638
- const rv = new RunView();
3064
+ const rv = new RunView(this.RunViewProviderToUse);
2639
3065
  const result = await rv.RunView(params, this._contextCurrentUser);
2640
3066
  if (result && result.Success) {
2641
3067
  return {
@@ -2800,7 +3226,7 @@ export class BaseEntity {
2800
3226
  // Root of an IS-A chain, or a standalone (non-IS-A) entity: generate
2801
3227
  // a single GUID/UUID PK here (SQL Server `uniqueidentifier` /
2802
3228
  // PostgreSQL `uuid`).
2803
- const pk = this.EntityInfo.PrimaryKeys[0];
3229
+ const pk = this.EntityInfo.FirstPrimaryKey; // first-pk-ok: guarded by PrimaryKeys.length === 1 above
2804
3230
  if (!pk.AutoIncrement &&
2805
3231
  pk.IsUniqueIdentifier &&
2806
3232
  !this.Get(pk.Name)) {
@@ -3671,40 +4097,45 @@ export class BaseEntity {
3671
4097
  await this.DeserializeCompanions(payloads);
3672
4098
  }
3673
4099
  }
3674
- /**
3675
- * This method is used automatically within Save() and is used to determine if the state of the object is valid relative to the validation rules that are defined in metadata. In addition, sub-classes can
3676
- * override or wrap this base class method to add other logic for validation.
3677
- *
3678
- * @returns ValidationResult The validation result
3679
- */
3680
4100
  Validate() {
3681
- const result = new ValidationResult();
3682
- result.Success = true; // start off with assumption of success, if any field fails, we'll set this to false
3683
- // IS-A composition: validate parent entity first to collect all chain errors
3684
- if (this._parentEntity) {
3685
- const parentResult = this._parentEntity.Validate();
3686
- if (!parentResult.Success) {
3687
- result.Success = false;
3688
- parentResult.Errors.forEach(err => result.Errors.push(err));
4101
+ if (this._isValidating) {
4102
+ const emptyResult = new ValidationResult();
4103
+ emptyResult.Success = true;
4104
+ return emptyResult;
4105
+ }
4106
+ this._isValidating = true;
4107
+ try {
4108
+ const result = new ValidationResult();
4109
+ result.Success = true; // start off with assumption of success, if any field fails, we'll set this to false
4110
+ // IS-A composition: validate parent entity first to collect all chain errors
4111
+ if (this._parentEntity) {
4112
+ const parentResult = this._parentEntity.Validate();
4113
+ if (!parentResult.Success) {
4114
+ result.Success = false;
4115
+ parentResult.Errors.forEach(err => result.Errors.push(err));
4116
+ }
4117
+ }
4118
+ // Validate own fields — for IS-A entities, skip parent field mirrors since
4119
+ // those are validated via _parentEntity above
4120
+ for (let field of this.Fields) {
4121
+ if (this._parentEntityFieldNames?.has(field.Name))
4122
+ continue; // skip parent field mirrors — authoritative validation is on _parentEntity
4123
+ const err = field.Validate();
4124
+ err.Errors.forEach(element => {
4125
+ result.Errors.push(element);
4126
+ });
4127
+ result.Success = result.Success && err.Success; // if any field fails, we fail, but keep going to get all of the validation messages
3689
4128
  }
4129
+ // Companions validate LAST but still BEFORE any write, over their complete state including
4130
+ // pending removals. That ordering is what lets a cross-child invariant — "debits must equal
4131
+ // credits", "a confirmed order must have lines" — be enforced against the whole graph rather
4132
+ // than discovered halfway through persisting it.
4133
+ this.validateCompanions(result);
4134
+ return result;
3690
4135
  }
3691
- // Validate own fields — for IS-A entities, skip parent field mirrors since
3692
- // those are validated via _parentEntity above
3693
- for (let field of this.Fields) {
3694
- if (this._parentEntityFieldNames?.has(field.Name))
3695
- continue; // skip parent field mirrors — authoritative validation is on _parentEntity
3696
- const err = field.Validate();
3697
- err.Errors.forEach(element => {
3698
- result.Errors.push(element);
3699
- });
3700
- result.Success = result.Success && err.Success; // if any field fails, we fail, but keep going to get all of the validation messages
4136
+ finally {
4137
+ this._isValidating = false;
3701
4138
  }
3702
- // Companions validate LAST but still BEFORE any write, over their complete state including
3703
- // pending removals. That ordering is what lets a cross-child invariant — "debits must equal
3704
- // credits", "a confirmed order must have lines" — be enforced against the whole graph rather
3705
- // than discovered halfway through persisting it.
3706
- this.validateCompanions(result);
3707
- return result;
3708
4139
  }
3709
4140
  /**
3710
4141
  * Fans synchronous validation out to every registered companion.
@@ -4020,11 +4451,12 @@ export class BaseEntity {
4020
4451
  if (childEntities.length === 0) {
4021
4452
  return { HasChildren: false, ChildEntityName: '' };
4022
4453
  }
4023
- // Use RunView to check each child entity for records with our PK
4024
- const rv = new RunView();
4454
+ // Use RunView on this instance's provider a host RunView cannot see
4455
+ // uncommitted child rows on a graph-scoped connection.
4456
+ const rv = new RunView(this.RunViewProviderToUse);
4025
4457
  const pkValue = this.PrimaryKey.Values();
4026
4458
  for (const childEntity of childEntities) {
4027
- const pkField = childEntity.PrimaryKeys[0];
4459
+ const pkField = childEntity.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
4028
4460
  if (!pkField)
4029
4461
  continue;
4030
4462
  const result = await rv.RunView({
@@ -4083,21 +4515,21 @@ export class BaseEntity {
4083
4515
  if (!entityInfo) {
4084
4516
  return { LeafEntityName: entityName, IsLeaf: true };
4085
4517
  }
4086
- return BaseEntity.ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser);
4518
+ return BaseEntity.ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser, md);
4087
4519
  }
4088
4520
  /**
4089
4521
  * Internal recursive helper for leaf entity resolution.
4090
4522
  * Walks down the child hierarchy until no more children are found.
4091
4523
  */
4092
- static async ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser) {
4524
+ static async ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser, provider) {
4093
4525
  const childEntities = entityInfo.ChildEntities;
4094
4526
  if (childEntities.length === 0) {
4095
4527
  return { LeafEntityName: entityInfo.Name, IsLeaf: true };
4096
4528
  }
4097
- const rv = new RunView();
4529
+ const rv = new RunView((provider ?? BaseEntity.Provider));
4098
4530
  const pkValue = primaryKey.Values();
4099
4531
  for (const child of childEntities) {
4100
- const childPK = child.PrimaryKeys[0];
4532
+ const childPK = child.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
4101
4533
  if (!childPK)
4102
4534
  continue;
4103
4535
  const result = await rv.RunView({
@@ -4109,7 +4541,7 @@ export class BaseEntity {
4109
4541
  }, contextUser);
4110
4542
  if (result?.Success && result.Results?.length > 0) {
4111
4543
  // Found a child — recurse to see if there's an even more specific leaf
4112
- return BaseEntity.ResolveLeafEntityRecursive(child, primaryKey, contextUser);
4544
+ return BaseEntity.ResolveLeafEntityRecursive(child, primaryKey, contextUser, provider);
4113
4545
  }
4114
4546
  }
4115
4547
  // No child found — this entity IS the leaf
@@ -4142,16 +4574,19 @@ export class BaseEntity {
4142
4574
  const pkValue = this.PrimaryKey.Values();
4143
4575
  if (!pkValue)
4144
4576
  return;
4145
- // Build all sibling queries and execute them in a single batch
4146
- const rv = new RunView();
4147
- const validSiblings = siblingChildEntities.filter(s => s.PrimaryKeys[0]);
4577
+ // Build all sibling queries and execute them in a single batch on
4578
+ // this instance's provider so an uncommitted sibling on the same
4579
+ // graph connection is visible (host RunView would miss it).
4580
+ const rv = new RunView(this.RunViewProviderToUse);
4581
+ // first-pk-ok: IS-A siblings share the parent's single key
4582
+ const validSiblings = siblingChildEntities.filter(s => s.FirstPrimaryKey); // first-pk-ok: IS-A siblings share the parent's single key
4148
4583
  if (validSiblings.length === 0)
4149
4584
  return;
4150
4585
  const viewParams = validSiblings.map(sibling => ({
4151
4586
  EntityName: sibling.Name,
4152
- ExtraFilter: `${sibling.PrimaryKeys[0].Name} = '${pkValue}'`,
4587
+ ExtraFilter: `${sibling.FirstPrimaryKey.Name} = '${pkValue}'`, // first-pk-ok: IS-A shared key
4153
4588
  ResultType: 'simple',
4154
- Fields: [sibling.PrimaryKeys[0].Name],
4589
+ Fields: [sibling.FirstPrimaryKey.Name], // first-pk-ok: IS-A shared key
4155
4590
  MaxRows: 1
4156
4591
  }));
4157
4592
  const results = await rv.RunViews(viewParams, this._contextCurrentUser);
@@ -4444,7 +4879,7 @@ export class BaseEntity {
4444
4879
  LogError(`BaseEntity.GetDescendants(): No recursive foreign key field found on entity ${this.EntityInfo?.Name}`);
4445
4880
  return [];
4446
4881
  }
4447
- const pkName = this.FirstPrimaryKey?.Name ?? 'ID';
4882
+ const pkName = this.FirstPrimaryKey.Name; // first-pk-ok: getRecursiveForeignKeyField already gates hierarchy traversal to single-column keys
4448
4883
  const rootId = this.Get(pkName);
4449
4884
  if (!rootId)
4450
4885
  return [];
@@ -4453,7 +4888,7 @@ export class BaseEntity {
4453
4888
  const filter = maxDepth != null
4454
4889
  ? `${rootFieldName} = '${rootId}' AND ${depthFieldName} <= ${maxDepth}`
4455
4890
  : `${rootFieldName} = '${rootId}'`;
4456
- const rv = new RunView();
4891
+ const rv = new RunView(this.RunViewProviderToUse);
4457
4892
  const result = await rv.RunView({
4458
4893
  EntityName: this.EntityInfo.Name,
4459
4894
  ExtraFilter: filter,
@@ -4472,7 +4907,7 @@ export class BaseEntity {
4472
4907
  LogError(`BaseEntity.GetAncestors(): No recursive foreign key field found on entity ${this.EntityInfo?.Name}`);
4473
4908
  return [];
4474
4909
  }
4475
- const pkName = this.FirstPrimaryKey?.Name ?? 'ID';
4910
+ const pkName = this.FirstPrimaryKey.Name; // first-pk-ok: getRecursiveForeignKeyField already gates hierarchy traversal to single-column keys
4476
4911
  const currentId = this.Get(pkName);
4477
4912
  const pathFieldName = `${fkField.Name}Path`;
4478
4913
  const depthFieldName = `${fkField.Name}Depth`;
@@ -4482,7 +4917,7 @@ export class BaseEntity {
4482
4917
  const rawIds = path.split('/').filter(id => id.length > 0 && id !== currentId);
4483
4918
  if (rawIds.length === 0)
4484
4919
  return [];
4485
- const rv = new RunView();
4920
+ const rv = new RunView(this.RunViewProviderToUse);
4486
4921
  const idList = rawIds.map(id => `'${id}'`).join(',');
4487
4922
  const result = await rv.RunView({
4488
4923
  EntityName: this.EntityInfo.Name,
@@ -4502,11 +4937,11 @@ export class BaseEntity {
4502
4937
  LogError(`BaseEntity.GetChildren(): No recursive foreign key field found on entity ${this.EntityInfo?.Name}`);
4503
4938
  return [];
4504
4939
  }
4505
- const pkName = this.FirstPrimaryKey?.Name ?? 'ID';
4940
+ const pkName = this.FirstPrimaryKey.Name; // first-pk-ok: getRecursiveForeignKeyField already gates hierarchy traversal to single-column keys
4506
4941
  const currentId = this.Get(pkName);
4507
4942
  if (!currentId)
4508
4943
  return [];
4509
- const rv = new RunView();
4944
+ const rv = new RunView(this.RunViewProviderToUse);
4510
4945
  const result = await rv.RunView({
4511
4946
  EntityName: this.EntityInfo.Name,
4512
4947
  ExtraFilter: `${fkField.Name} = '${currentId}'`,