@memberjunction/core 6.1.0-edge.4 → 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 (98) hide show
  1. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts +27 -0
  2. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts.map +1 -0
  3. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.js +6 -0
  4. package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.js.map +1 -0
  5. package/dist/generic/baseEngine.d.ts.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 +172 -0
  9. package/dist/generic/baseEntity.d.ts.map +1 -1
  10. package/dist/generic/baseEntity.js +622 -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 +53 -2
  21. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  22. package/dist/generic/databaseProviderBase.js +92 -13
  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 +136 -7
  33. package/dist/generic/entityInfo.d.ts.map +1 -1
  34. package/dist/generic/entityInfo.js +204 -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 +36 -2
  65. package/dist/generic/interfaces.d.ts.map +1 -1
  66. package/dist/generic/interfaces.js +25 -0
  67. package/dist/generic/interfaces.js.map +1 -1
  68. package/dist/generic/localCacheManager.d.ts +4 -2
  69. package/dist/generic/localCacheManager.d.ts.map +1 -1
  70. package/dist/generic/localCacheManager.js +5 -3
  71. package/dist/generic/localCacheManager.js.map +1 -1
  72. package/dist/generic/metadata.d.ts +11 -0
  73. package/dist/generic/metadata.d.ts.map +1 -1
  74. package/dist/generic/metadata.js +29 -0
  75. package/dist/generic/metadata.js.map +1 -1
  76. package/dist/generic/providerBase.d.ts +118 -3
  77. package/dist/generic/providerBase.d.ts.map +1 -1
  78. package/dist/generic/providerBase.js +356 -70
  79. package/dist/generic/providerBase.js.map +1 -1
  80. package/dist/generic/relatedRecordBatchLoader.js +2 -2
  81. package/dist/generic/relatedRecordBatchLoader.js.map +1 -1
  82. package/dist/generic/relatedRecordCollection.d.ts +2 -1
  83. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  84. package/dist/generic/relatedRecordCollection.js +16 -4
  85. package/dist/generic/relatedRecordCollection.js.map +1 -1
  86. package/dist/generic/schemaHydration.d.ts +54 -0
  87. package/dist/generic/schemaHydration.d.ts.map +1 -0
  88. package/dist/generic/schemaHydration.js +75 -0
  89. package/dist/generic/schemaHydration.js.map +1 -0
  90. package/dist/generic/transactionGroup.d.ts +18 -0
  91. package/dist/generic/transactionGroup.d.ts.map +1 -1
  92. package/dist/generic/transactionGroup.js +22 -0
  93. package/dist/generic/transactionGroup.js.map +1 -1
  94. package/dist/index.d.ts +6 -0
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +6 -0
  97. package/dist/index.js.map +1 -1
  98. 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
@@ -874,6 +1012,106 @@ export class BaseEntity {
874
1012
  // Cache the parent field names for O(1) routing lookups
875
1013
  this._parentEntityFieldNames = this.EntityInfo.ParentEntityFieldNames;
876
1014
  }
1015
+ /**
1016
+ * IS-A PROMOTION (#3825): binds this NEW child record to an EXISTING parent row, so saving it
1017
+ * ADDS a subtype to a person/org/product that already exists instead of trying to create a
1018
+ * duplicate parent.
1019
+ *
1020
+ * Before this existed the operation was impossible: `NewRecord()` always starts a fresh parent
1021
+ * chain, so "this existing Person is now also an Applicant" INSERTed a second Person and
1022
+ * collided with the existing primary key (or, with parent fields unset, failed the parent's
1023
+ * NOT NULL validation as if it were brand new). Discovery ran the other way only — a loaded
1024
+ * parent finds its existing child — and promotion is the normal case in a multi-app install,
1025
+ * where a shared entity like Person accumulates subtypes owned by different applications.
1026
+ *
1027
+ * What it does, in the existing machinery rather than beside it:
1028
+ * 1. LOADS the parent chain by the supplied key (`InnerLoad`, which also hydrates any
1029
+ * grandparents from the same row). A loaded parent saves as an UPDATE, which is the whole
1030
+ * trick — the chain save that already runs parent-first now updates the existing row and
1031
+ * INSERTs only this child.
1032
+ * 2. Mirrors the shared primary key into this child's local fields, restoring `_NeverSet`
1033
+ * exactly as `NewRecord()`'s adoption path does, so the ReadOnly mirror stays writable for
1034
+ * the rest of the lifecycle.
1035
+ *
1036
+ * Everything else is deliberately UNTOUCHED: field routing still sends parent-held values to
1037
+ * the (now loaded) parent, permissions and validation run at every level, and
1038
+ * `EnforceDisjointSubtype` still refuses a second subtype where the parent forbids overlap.
1039
+ * If loading the parent discovers an existing child of ANOTHER subtype, the chain save is
1040
+ * unaffected — parent saves run with `IsParentEntitySave`, which bypasses leaf delegation.
1041
+ *
1042
+ * Call AFTER `NewRecord()` and BEFORE `Save()`:
1043
+ * ```typescript
1044
+ * const applicant = await md.GetEntityObject<ApplicantEntity>('Applicants', contextUser);
1045
+ * applicant.NewRecord();
1046
+ * if (!await applicant.AttachToParent(CompositeKey.FromID(personId))) {
1047
+ * // no such parent row — decide whether to create a fresh chain instead
1048
+ * }
1049
+ * applicant.Set('CompanyID', companyId); // child-held fields as usual
1050
+ * await applicant.Save(); // Person UPDATEd, Applicant INSERTed, one transaction
1051
+ * ```
1052
+ *
1053
+ * @param parentKey Primary key of the EXISTING parent row to promote.
1054
+ * @returns `true` when the parent loaded and this record is now bound to it; `false` when no
1055
+ * parent row exists under that key (this record is left exactly as it was — still a fresh
1056
+ * chain — so the caller can choose to save it as one).
1057
+ * @throws When this entity is not an IS-A child type, or has already been saved — promotion
1058
+ * is a decision about what a NEW record IS, not an edit to an existing one.
1059
+ */
1060
+ async AttachToParent(parentKey) {
1061
+ if (!this.EntityInfo.IsChildType || !this._parentEntity) {
1062
+ throw new Error(`AttachToParent: '${this.EntityInfo.Name}' is not an IS-A child type — there is no parent to attach to.`);
1063
+ }
1064
+ if (this.IsSaved) {
1065
+ throw new Error(`AttachToParent: '${this.EntityInfo.Name}' record is already saved. Promotion binds a NEW record to an existing parent; it cannot re-parent a saved one.`);
1066
+ }
1067
+ // The PARENT's primary keys drive everything here, not the child's. The shared-PK mirror
1068
+ // is a same-name convention, and a child EntityInfo is not obliged to re-declare the key as
1069
+ // its own PrimaryKey — iterating the child's list silently does nothing on such a schema,
1070
+ // which is exactly the empty-loop bug the first draft of this method had.
1071
+ const parentPks = this._parentEntity.EntityInfo.PrimaryKeys;
1072
+ // Captured BEFORE the load: InnerLoad wipes the parent chain's state before it reads, so a
1073
+ // MISSING parent row would otherwise leave the fresh chain gutted — root PK nulled, record
1074
+ // unsaveable — when the contract is "left exactly as it was, so the caller can still save
1075
+ // it as a fresh chain".
1076
+ const freshPkValues = parentPks.map(pk => ({
1077
+ name: pk.Name,
1078
+ value: this._parentEntity.Get(pk.Name),
1079
+ }));
1080
+ const loaded = await this._parentEntity.InnerLoad(parentKey);
1081
+ if (!loaded) {
1082
+ // Restore the fresh chain the failed load destroyed: re-seed the parent chain, then put
1083
+ // the ORIGINAL minted key back (Set routes to the root), so the record the caller holds
1084
+ // is bit-for-bit the fresh record they built.
1085
+ this._parentEntity.NewRecord();
1086
+ for (const pk of freshPkValues) {
1087
+ if (pk.value != null) {
1088
+ this._parentEntity.Set(pk.name, pk.value);
1089
+ this.mirrorSharedKey(pk.name, pk.value);
1090
+ }
1091
+ }
1092
+ return false;
1093
+ }
1094
+ for (const pk of parentPks) {
1095
+ const parentValue = this._parentEntity.Get(pk.Name);
1096
+ if (parentValue != null) {
1097
+ this.mirrorSharedKey(pk.Name, parentValue);
1098
+ }
1099
+ }
1100
+ return true;
1101
+ }
1102
+ /**
1103
+ * Writes one shared-key value onto this child's LOCAL mirror, when a same-named field exists —
1104
+ * a schema that leaves the shared key entirely to routing has no mirror to maintain, and that
1105
+ * is fine. Restores `_NeverSet` exactly as `NewRecord()`'s adoption path does, so the ReadOnly
1106
+ * mirror stays writable for the rest of the lifecycle.
1107
+ */
1108
+ mirrorSharedKey(name, value) {
1109
+ if (!this.GetFieldByName(name)) {
1110
+ return;
1111
+ }
1112
+ this.SetLocal(name, value);
1113
+ this.GetFieldByName(name)?.ResetNeverSetFlag();
1114
+ }
877
1115
  /**
878
1116
  * Discovers and initializes the IS-A child entity for a loaded record.
879
1117
  *
@@ -962,6 +1200,249 @@ export class BaseEntity {
962
1200
  // InitializeChildEntity is idempotent via _childEntityDiscoveryDone flag
963
1201
  await childEntity.InitializeChildEntity();
964
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
+ }
965
1446
  captureChainDirtyState() {
966
1447
  const snapshots = [];
967
1448
  let curr = this;
@@ -1002,6 +1483,10 @@ export class BaseEntity {
1002
1483
  // Replace it with `this`, then walk upward replacing each parent.
1003
1484
  let childParent = childEntity._parentEntity;
1004
1485
  let ourInstance = this;
1486
+ if (!childParent) {
1487
+ childEntity._parentEntity = this;
1488
+ return;
1489
+ }
1005
1490
  while (childParent && ourInstance) {
1006
1491
  // Replace the child's parent reference with our shared instance
1007
1492
  if (childParent === childEntity._parentEntity) {
@@ -1246,6 +1731,8 @@ export class BaseEntity {
1246
1731
  throw new Error(`BaseEntity.ConstructUninitializedEntity: ClassFactory could not construct '${entityName}'. ` +
1247
1732
  `Ensure the entity class is registered.`);
1248
1733
  }
1734
+ // Same rebind as GetEntityObject — 1-arg subclasses drop the ClassFactory provider.
1735
+ instance.BindProvider(provider);
1249
1736
  await instance.Config(this.ContextCurrentUser);
1250
1737
  await instance.InitializeParentEntity();
1251
1738
  // Recurse so a *new* peer's own embeds are constructed (required nested
@@ -1441,7 +1928,7 @@ export class BaseEntity {
1441
1928
  // One `RunViews` for all remaining collections — N declared collections cost one round trip,
1442
1929
  // not N. Params are built per collection so each keeps its own filter and ordering. The key
1443
1930
  // is escaped exactly as RelatedRecordCollection.Load() and the batch loader escape it.
1444
- 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
1445
1932
  const rv = new RunView(this.ProviderToUse);
1446
1933
  const results = await rv.RunViews(needsDatabase.map(c => ({
1447
1934
  EntityName: c.RelatedEntityName,
@@ -1470,7 +1957,7 @@ export class BaseEntity {
1470
1957
  seedEmbedLoadVisited() {
1471
1958
  const seeded = new Set();
1472
1959
  const name = this.EntityInfo?.Name;
1473
- 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
1474
1961
  if (name && pk !== null && pk !== undefined && pk !== '') {
1475
1962
  seeded.add(`${name}:${String(pk)}`);
1476
1963
  }
@@ -2064,6 +2551,46 @@ export class BaseEntity {
2064
2551
  }
2065
2552
  return this._fieldCache.get(lcase) || null;
2066
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
+ }
2067
2594
  /**
2068
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.
2069
2596
  * @param codeName
@@ -2088,26 +2615,25 @@ export class BaseEntity {
2088
2615
  }
2089
2616
  return this._codeNameCache.get(lcase) || null;
2090
2617
  }
2091
- /**
2092
- * 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.
2093
- *
2094
- * @remarks
2095
- * Dirtiness rolls up **companions** as well as fields. Without that rollup, a clean parent with
2096
- * three brand-new children reports `Dirty === false`, `_InnerSave` takes its not-dirty early
2097
- * return, and the children are silently never persisted — the save reports success and writes
2098
- * nothing. See {@link EntityCompanion.Dirty}.
2099
- */
2100
2618
  get Dirty() {
2101
- if (!this.IsSaved)
2102
- return true;
2103
- if (this.companionsDirty)
2104
- return true;
2105
- // Raw mode means LoadFromData populated us but no mutation has happened — nothing can be
2106
- // dirty. Avoid hydrating just to check.
2107
- if (!this._fieldsHydrated)
2108
- return this._parentEntity?.Dirty ?? false;
2109
- return this._Fields.some(f => f.Dirty) ||
2110
- (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
+ }
2111
2637
  }
2112
2638
  /**
2113
2639
  * Whether any registered companion holds unsaved work.
@@ -2142,7 +2668,7 @@ export class BaseEntity {
2142
2668
  * Helper method to return just the first Primary Key
2143
2669
  */
2144
2670
  get FirstPrimaryKey() {
2145
- return this.PrimaryKeys[0];
2671
+ return this.PrimaryKeys[0]; // first-pk-ok: the accessor itself
2146
2672
  }
2147
2673
  /**
2148
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.
@@ -2535,7 +3061,7 @@ export class BaseEntity {
2535
3061
  async GetRelatedEntityDataExt(re, filter = null, maxRecords = null) {
2536
3062
  // we need to query the database to get related entity info
2537
3063
  const params = EntityInfo.BuildRelationshipViewParams(this, re, filter, maxRecords);
2538
- const rv = new RunView();
3064
+ const rv = new RunView(this.RunViewProviderToUse);
2539
3065
  const result = await rv.RunView(params, this._contextCurrentUser);
2540
3066
  if (result && result.Success) {
2541
3067
  return {
@@ -2700,7 +3226,7 @@ export class BaseEntity {
2700
3226
  // Root of an IS-A chain, or a standalone (non-IS-A) entity: generate
2701
3227
  // a single GUID/UUID PK here (SQL Server `uniqueidentifier` /
2702
3228
  // PostgreSQL `uuid`).
2703
- const pk = this.EntityInfo.PrimaryKeys[0];
3229
+ const pk = this.EntityInfo.FirstPrimaryKey; // first-pk-ok: guarded by PrimaryKeys.length === 1 above
2704
3230
  if (!pk.AutoIncrement &&
2705
3231
  pk.IsUniqueIdentifier &&
2706
3232
  !this.Get(pk.Name)) {
@@ -3571,40 +4097,45 @@ export class BaseEntity {
3571
4097
  await this.DeserializeCompanions(payloads);
3572
4098
  }
3573
4099
  }
3574
- /**
3575
- * 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
3576
- * override or wrap this base class method to add other logic for validation.
3577
- *
3578
- * @returns ValidationResult The validation result
3579
- */
3580
4100
  Validate() {
3581
- const result = new ValidationResult();
3582
- result.Success = true; // start off with assumption of success, if any field fails, we'll set this to false
3583
- // IS-A composition: validate parent entity first to collect all chain errors
3584
- if (this._parentEntity) {
3585
- const parentResult = this._parentEntity.Validate();
3586
- if (!parentResult.Success) {
3587
- result.Success = false;
3588
- 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
3589
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;
3590
4135
  }
3591
- // Validate own fields — for IS-A entities, skip parent field mirrors since
3592
- // those are validated via _parentEntity above
3593
- for (let field of this.Fields) {
3594
- if (this._parentEntityFieldNames?.has(field.Name))
3595
- continue; // skip parent field mirrors — authoritative validation is on _parentEntity
3596
- const err = field.Validate();
3597
- err.Errors.forEach(element => {
3598
- result.Errors.push(element);
3599
- });
3600
- 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;
3601
4138
  }
3602
- // Companions validate LAST but still BEFORE any write, over their complete state including
3603
- // pending removals. That ordering is what lets a cross-child invariant — "debits must equal
3604
- // credits", "a confirmed order must have lines" — be enforced against the whole graph rather
3605
- // than discovered halfway through persisting it.
3606
- this.validateCompanions(result);
3607
- return result;
3608
4139
  }
3609
4140
  /**
3610
4141
  * Fans synchronous validation out to every registered companion.
@@ -3920,11 +4451,12 @@ export class BaseEntity {
3920
4451
  if (childEntities.length === 0) {
3921
4452
  return { HasChildren: false, ChildEntityName: '' };
3922
4453
  }
3923
- // Use RunView to check each child entity for records with our PK
3924
- 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);
3925
4457
  const pkValue = this.PrimaryKey.Values();
3926
4458
  for (const childEntity of childEntities) {
3927
- const pkField = childEntity.PrimaryKeys[0];
4459
+ const pkField = childEntity.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
3928
4460
  if (!pkField)
3929
4461
  continue;
3930
4462
  const result = await rv.RunView({
@@ -3983,21 +4515,21 @@ export class BaseEntity {
3983
4515
  if (!entityInfo) {
3984
4516
  return { LeafEntityName: entityName, IsLeaf: true };
3985
4517
  }
3986
- return BaseEntity.ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser);
4518
+ return BaseEntity.ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser, md);
3987
4519
  }
3988
4520
  /**
3989
4521
  * Internal recursive helper for leaf entity resolution.
3990
4522
  * Walks down the child hierarchy until no more children are found.
3991
4523
  */
3992
- static async ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser) {
4524
+ static async ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser, provider) {
3993
4525
  const childEntities = entityInfo.ChildEntities;
3994
4526
  if (childEntities.length === 0) {
3995
4527
  return { LeafEntityName: entityInfo.Name, IsLeaf: true };
3996
4528
  }
3997
- const rv = new RunView();
4529
+ const rv = new RunView((provider ?? BaseEntity.Provider));
3998
4530
  const pkValue = primaryKey.Values();
3999
4531
  for (const child of childEntities) {
4000
- const childPK = child.PrimaryKeys[0];
4532
+ const childPK = child.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
4001
4533
  if (!childPK)
4002
4534
  continue;
4003
4535
  const result = await rv.RunView({
@@ -4009,7 +4541,7 @@ export class BaseEntity {
4009
4541
  }, contextUser);
4010
4542
  if (result?.Success && result.Results?.length > 0) {
4011
4543
  // Found a child — recurse to see if there's an even more specific leaf
4012
- return BaseEntity.ResolveLeafEntityRecursive(child, primaryKey, contextUser);
4544
+ return BaseEntity.ResolveLeafEntityRecursive(child, primaryKey, contextUser, provider);
4013
4545
  }
4014
4546
  }
4015
4547
  // No child found — this entity IS the leaf
@@ -4042,16 +4574,19 @@ export class BaseEntity {
4042
4574
  const pkValue = this.PrimaryKey.Values();
4043
4575
  if (!pkValue)
4044
4576
  return;
4045
- // Build all sibling queries and execute them in a single batch
4046
- const rv = new RunView();
4047
- 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
4048
4583
  if (validSiblings.length === 0)
4049
4584
  return;
4050
4585
  const viewParams = validSiblings.map(sibling => ({
4051
4586
  EntityName: sibling.Name,
4052
- ExtraFilter: `${sibling.PrimaryKeys[0].Name} = '${pkValue}'`,
4587
+ ExtraFilter: `${sibling.FirstPrimaryKey.Name} = '${pkValue}'`, // first-pk-ok: IS-A shared key
4053
4588
  ResultType: 'simple',
4054
- Fields: [sibling.PrimaryKeys[0].Name],
4589
+ Fields: [sibling.FirstPrimaryKey.Name], // first-pk-ok: IS-A shared key
4055
4590
  MaxRows: 1
4056
4591
  }));
4057
4592
  const results = await rv.RunViews(viewParams, this._contextCurrentUser);
@@ -4344,7 +4879,7 @@ export class BaseEntity {
4344
4879
  LogError(`BaseEntity.GetDescendants(): No recursive foreign key field found on entity ${this.EntityInfo?.Name}`);
4345
4880
  return [];
4346
4881
  }
4347
- const pkName = this.FirstPrimaryKey?.Name ?? 'ID';
4882
+ const pkName = this.FirstPrimaryKey.Name; // first-pk-ok: getRecursiveForeignKeyField already gates hierarchy traversal to single-column keys
4348
4883
  const rootId = this.Get(pkName);
4349
4884
  if (!rootId)
4350
4885
  return [];
@@ -4353,7 +4888,7 @@ export class BaseEntity {
4353
4888
  const filter = maxDepth != null
4354
4889
  ? `${rootFieldName} = '${rootId}' AND ${depthFieldName} <= ${maxDepth}`
4355
4890
  : `${rootFieldName} = '${rootId}'`;
4356
- const rv = new RunView();
4891
+ const rv = new RunView(this.RunViewProviderToUse);
4357
4892
  const result = await rv.RunView({
4358
4893
  EntityName: this.EntityInfo.Name,
4359
4894
  ExtraFilter: filter,
@@ -4372,7 +4907,7 @@ export class BaseEntity {
4372
4907
  LogError(`BaseEntity.GetAncestors(): No recursive foreign key field found on entity ${this.EntityInfo?.Name}`);
4373
4908
  return [];
4374
4909
  }
4375
- const pkName = this.FirstPrimaryKey?.Name ?? 'ID';
4910
+ const pkName = this.FirstPrimaryKey.Name; // first-pk-ok: getRecursiveForeignKeyField already gates hierarchy traversal to single-column keys
4376
4911
  const currentId = this.Get(pkName);
4377
4912
  const pathFieldName = `${fkField.Name}Path`;
4378
4913
  const depthFieldName = `${fkField.Name}Depth`;
@@ -4382,7 +4917,7 @@ export class BaseEntity {
4382
4917
  const rawIds = path.split('/').filter(id => id.length > 0 && id !== currentId);
4383
4918
  if (rawIds.length === 0)
4384
4919
  return [];
4385
- const rv = new RunView();
4920
+ const rv = new RunView(this.RunViewProviderToUse);
4386
4921
  const idList = rawIds.map(id => `'${id}'`).join(',');
4387
4922
  const result = await rv.RunView({
4388
4923
  EntityName: this.EntityInfo.Name,
@@ -4402,11 +4937,11 @@ export class BaseEntity {
4402
4937
  LogError(`BaseEntity.GetChildren(): No recursive foreign key field found on entity ${this.EntityInfo?.Name}`);
4403
4938
  return [];
4404
4939
  }
4405
- const pkName = this.FirstPrimaryKey?.Name ?? 'ID';
4940
+ const pkName = this.FirstPrimaryKey.Name; // first-pk-ok: getRecursiveForeignKeyField already gates hierarchy traversal to single-column keys
4406
4941
  const currentId = this.Get(pkName);
4407
4942
  if (!currentId)
4408
4943
  return [];
4409
- const rv = new RunView();
4944
+ const rv = new RunView(this.RunViewProviderToUse);
4410
4945
  const result = await rv.RunView({
4411
4946
  EntityName: this.EntityInfo.Name,
4412
4947
  ExtraFilter: `${fkField.Name} = '${currentId}'`,