@memberjunction/core 6.1.0-edge.1 → 6.1.0-edge.3

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 (99) hide show
  1. package/LICENSE +180 -4
  2. package/dist/generic/InMemoryLocalStorageProvider.d.ts +6 -0
  3. package/dist/generic/InMemoryLocalStorageProvider.d.ts.map +1 -1
  4. package/dist/generic/InMemoryLocalStorageProvider.js +6 -0
  5. package/dist/generic/InMemoryLocalStorageProvider.js.map +1 -1
  6. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +120 -0
  7. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -0
  8. package/dist/generic/JSONType-interfaces/IEntityConfiguration.js +6 -0
  9. package/dist/generic/JSONType-interfaces/IEntityConfiguration.js.map +1 -0
  10. package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts +31 -0
  11. package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts.map +1 -0
  12. package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.js +6 -0
  13. package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.js.map +1 -0
  14. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts +75 -0
  15. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts.map +1 -0
  16. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.js +6 -0
  17. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.js.map +1 -0
  18. package/dist/generic/JSONType-interfaces/IFileStorageProviderConfiguration.d.ts +19 -0
  19. package/dist/generic/JSONType-interfaces/IFileStorageProviderConfiguration.d.ts.map +1 -0
  20. package/dist/generic/JSONType-interfaces/IFileStorageProviderConfiguration.js +6 -0
  21. package/dist/generic/JSONType-interfaces/IFileStorageProviderConfiguration.js.map +1 -0
  22. package/dist/generic/authTypes.d.ts +69 -0
  23. package/dist/generic/authTypes.d.ts.map +1 -1
  24. package/dist/generic/baseEngine.d.ts +14 -0
  25. package/dist/generic/baseEngine.d.ts.map +1 -1
  26. package/dist/generic/baseEngine.js +53 -13
  27. package/dist/generic/baseEngine.js.map +1 -1
  28. package/dist/generic/baseEntity.d.ts +150 -23
  29. package/dist/generic/baseEntity.d.ts.map +1 -1
  30. package/dist/generic/baseEntity.js +435 -66
  31. package/dist/generic/baseEntity.js.map +1 -1
  32. package/dist/generic/baseInfo.d.ts.map +1 -1
  33. package/dist/generic/baseInfo.js +16 -4
  34. package/dist/generic/baseInfo.js.map +1 -1
  35. package/dist/generic/compositeKey.d.ts +30 -0
  36. package/dist/generic/compositeKey.d.ts.map +1 -1
  37. package/dist/generic/compositeKey.js +97 -0
  38. package/dist/generic/compositeKey.js.map +1 -1
  39. package/dist/generic/dataHooks.d.ts +5 -0
  40. package/dist/generic/dataHooks.d.ts.map +1 -1
  41. package/dist/generic/dataHooks.js +27 -3
  42. package/dist/generic/dataHooks.js.map +1 -1
  43. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  44. package/dist/generic/databaseProviderBase.js +9 -5
  45. package/dist/generic/databaseProviderBase.js.map +1 -1
  46. package/dist/generic/embeddedRecord.d.ts +123 -0
  47. package/dist/generic/embeddedRecord.d.ts.map +1 -0
  48. package/dist/generic/embeddedRecord.js +356 -0
  49. package/dist/generic/embeddedRecord.js.map +1 -0
  50. package/dist/generic/entityCompanion.d.ts +20 -2
  51. package/dist/generic/entityCompanion.d.ts.map +1 -1
  52. package/dist/generic/entityCompanion.js +17 -1
  53. package/dist/generic/entityCompanion.js.map +1 -1
  54. package/dist/generic/entityConfiguration.d.ts +131 -0
  55. package/dist/generic/entityConfiguration.d.ts.map +1 -0
  56. package/dist/generic/entityConfiguration.js +346 -0
  57. package/dist/generic/entityConfiguration.js.map +1 -0
  58. package/dist/generic/entityInfo.d.ts +274 -4
  59. package/dist/generic/entityInfo.d.ts.map +1 -1
  60. package/dist/generic/entityInfo.js +502 -14
  61. package/dist/generic/entityInfo.js.map +1 -1
  62. package/dist/generic/entitySavePlan.d.ts +40 -11
  63. package/dist/generic/entitySavePlan.d.ts.map +1 -1
  64. package/dist/generic/entitySavePlan.js +70 -2
  65. package/dist/generic/entitySavePlan.js.map +1 -1
  66. package/dist/generic/interfaces.d.ts +35 -24
  67. package/dist/generic/interfaces.d.ts.map +1 -1
  68. package/dist/generic/interfaces.js +6 -24
  69. package/dist/generic/interfaces.js.map +1 -1
  70. package/dist/generic/localCacheManager.d.ts +123 -5
  71. package/dist/generic/localCacheManager.d.ts.map +1 -1
  72. package/dist/generic/localCacheManager.js +264 -9
  73. package/dist/generic/localCacheManager.js.map +1 -1
  74. package/dist/generic/providerBase.d.ts +118 -0
  75. package/dist/generic/providerBase.d.ts.map +1 -1
  76. package/dist/generic/providerBase.js +311 -11
  77. package/dist/generic/providerBase.js.map +1 -1
  78. package/dist/generic/relatedRecordCollection.d.ts +36 -4
  79. package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
  80. package/dist/generic/relatedRecordCollection.js +70 -8
  81. package/dist/generic/relatedRecordCollection.js.map +1 -1
  82. package/dist/generic/runQuery.d.ts +14 -0
  83. package/dist/generic/runQuery.d.ts.map +1 -1
  84. package/dist/generic/runQuery.js.map +1 -1
  85. package/dist/generic/saveEntityGraphOperation.js +1 -1
  86. package/dist/generic/saveEntityGraphOperation.js.map +1 -1
  87. package/dist/generic/securityInfo.d.ts.map +1 -1
  88. package/dist/generic/securityInfo.js +15 -5
  89. package/dist/generic/securityInfo.js.map +1 -1
  90. package/dist/index.d.ts +3 -0
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +3 -0
  93. package/dist/index.js.map +1 -1
  94. package/dist/views/runView.d.ts +58 -0
  95. package/dist/views/runView.d.ts.map +1 -1
  96. package/dist/views/runView.js +20 -0
  97. package/dist/views/runView.js.map +1 -1
  98. package/package.json +5 -4
  99. package/readme.md +64 -1
@@ -8,7 +8,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
8
8
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
9
  };
10
10
  var EntityField_1;
11
- import { MJEventType, MJGlobal, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, WarningManager } from '@memberjunction/global';
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
14
  import { EntityDeleteOptions, EntitySaveOptions, ProviderType } from './interfaces.js';
@@ -18,6 +18,7 @@ import { LogDebug, LogError } from './logging.js';
18
18
  import { CompositeKey } from './compositeKey.js';
19
19
  import { RelatedRecordCollection } from './relatedRecordCollection.js';
20
20
  import { COMPANION_PAYLOAD_KEY } from './entityCompanion.js';
21
+ import { EmbeddedRecord } from './embeddedRecord.js';
21
22
  import { EntitySavePlan, ExecuteEntitySavePlan } from './entitySavePlan.js';
22
23
  import { BaseRemotableOperation } from './baseRemotableOperation.js';
23
24
  import { SAVE_ENTITY_GRAPH_OPERATION_KEY, } from './saveEntityGraphOperation.js';
@@ -312,6 +313,20 @@ let EntityField = class EntityField {
312
313
  }
313
314
  }
314
315
  }
316
+ // Value-list validation (MJ issue #3969): a field whose ValueListType is `List` carries an
317
+ // EXHAUSTIVE set of legal values in metadata, and for an `IN (...)` CHECK constraint that
318
+ // list is the only runtime representation CodeGen produces — so without this rung an
319
+ // out-of-list value reached the database as a raw constraint violation attributed to no
320
+ // field, on every path that is not a form. Which modes and value types the rule applies
321
+ // to, how values are compared, and why, all live in
322
+ // EntityFieldInfo.ValueIsPermittedByValueList: the normalized set of legal values is
323
+ // derived from immutable metadata SHARED by every EntityField instance of this field, so
324
+ // it is built once per field rather than rebuilt on every record's validation.
325
+ if (!ef.ValueIsPermittedByValueList(this.Value)) {
326
+ result.Success = false;
327
+ const nullNote = ef.AllowsNull ? ' (or null)' : '';
328
+ result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} must be one of: ${ef.ValueListValuesForDisplay}${nullNote}. Current value is '${this.Value}'`, this.Value));
329
+ }
315
330
  }
316
331
  return result;
317
332
  }
@@ -422,6 +437,13 @@ let EntityField = class EntityField {
422
437
  ResetOldValue() {
423
438
  this._OldValue = this.Value;
424
439
  }
440
+ /**
441
+ * Restores the dirty-tracking baseline to a previously captured value.
442
+ * Used by graph rollback so a retried save still sees the pre-attempt dirty set.
443
+ */
444
+ RestoreOldValue(value) {
445
+ this._OldValue = value;
446
+ }
425
447
  /**
426
448
  * @deprecated No-op as of the active-status relocation. Active-status (deprecated/disabled)
427
449
  * assertions are now enforced at BaseEntity.Get/Set/SetMany — the entry points genuine code uses —
@@ -519,9 +541,6 @@ export class BaseEntityResult {
519
541
  */
520
542
  export class BaseEntityEvent {
521
543
  }
522
- /**
523
- * Base class used for all entity objects. This class is abstract and is sub-classes for each particular entity using the CodeGen tool. This class provides the basic functionality for loading, saving, and validating entity objects.
524
- */
525
544
  export class BaseEntity {
526
545
  /**
527
546
  * Maximum number of `BaseEntityResult` entries retained in `_resultHistory` per entity
@@ -614,6 +633,21 @@ export class BaseEntity {
614
633
  * authoritative source.
615
634
  */
616
635
  this._raw = null;
636
+ /**
637
+ * Per-instance memo for values `Get()` derives from `_raw` — a parsed `Date`, an rtrimmed
638
+ * fixed-width string. Lazily created; only converted fields ever get an entry.
639
+ *
640
+ * This deliberately does NOT write back into `_raw`. `LoadFromData`'s fast path keeps the
641
+ * caller's row BY REFERENCE, and that row is frequently a LocalCacheManager entry, which the
642
+ * cache deep-freezes on reference-sharing providers. Memoizing into the row therefore made a
643
+ * plain field READ throw — and gating that write on a once-sampled `Object.isFrozen` could
644
+ * not be made correct, because the freeze is asynchronous relative to the consumer (cache
645
+ * writes are not always awaited), so the sample can be stale by the first read.
646
+ *
647
+ * Keeping the memo here makes freeze timing irrelevant AND restores the optimization on
648
+ * frozen rows, which the isFrozen-guard version had to give up.
649
+ */
650
+ this._rawConverted = null;
617
651
  /**
618
652
  * Whether a database record has been loaded into this instance (via `Load`, `NewRecord`,
619
653
  * `LoadFromData`, etc.). Used to gate operations that require loaded state and to distinguish
@@ -913,6 +947,8 @@ export class BaseEntity {
913
947
  // We need to replace the child's auto-initialized parent with our existing instance
914
948
  this.replaceChildParentChain(childEntity);
915
949
  this._childEntity = childEntity;
950
+ // Capture any pending modifications on this entity or its ancestors before child InnerLoad
951
+ const dirtySnapshots = this.captureChainDirtyState();
916
952
  // Load the child's record using our shared PK
917
953
  const loaded = await childEntity.InnerLoad(this.PrimaryKey);
918
954
  if (!loaded) {
@@ -920,10 +956,34 @@ export class BaseEntity {
920
956
  this._childEntity = null;
921
957
  return;
922
958
  }
959
+ // Re-apply any pending modifications so child hydration does not overwrite unsaved in-memory edits
960
+ this.restoreChainDirtyState(dirtySnapshots);
923
961
  // Recursively discover grandchildren (child may also be a parent type)
924
962
  // InitializeChildEntity is idempotent via _childEntityDiscoveryDone flag
925
963
  await childEntity.InitializeChildEntity();
926
964
  }
965
+ captureChainDirtyState() {
966
+ const snapshots = [];
967
+ let curr = this;
968
+ while (curr) {
969
+ const dirtyFields = curr.Fields.filter(f => f.Dirty).map(f => ({
970
+ name: f.Name,
971
+ value: f.Value,
972
+ }));
973
+ if (dirtyFields.length > 0) {
974
+ snapshots.push({ entity: curr, dirtyFields });
975
+ }
976
+ curr = curr._parentEntity;
977
+ }
978
+ return snapshots;
979
+ }
980
+ restoreChainDirtyState(snapshots) {
981
+ for (const snap of snapshots) {
982
+ for (const df of snap.dirtyFields) {
983
+ snap.entity.Set(df.name, df.value);
984
+ }
985
+ }
986
+ }
927
987
  /**
928
988
  * Replaces the child entity's auto-initialized parent chain with references
929
989
  * to our existing chain. This ensures all entities in the IS-A hierarchy
@@ -1008,7 +1068,12 @@ export class BaseEntity {
1008
1068
  * values are present (via {@link UpdateSavedStateFromPrimaryKeys}).
1009
1069
  *
1010
1070
  * The parent chain is handled recursively: if this entity has an IS-A parent, the
1011
- * parent is hydrated first (deepest ancestor first) via SetMany's built-in routing.
1071
+ * parent is hydrated first (deepest ancestor first). Each level only receives the
1072
+ * fields it owns — a child's view row is the union of every ancestor plus its own
1073
+ * columns, and passing that whole row to the parent used to trip
1074
+ * `WarningManager` ("fields were not found in entity definitions") for every
1075
+ * child-only column. That is how loading `Accounting Company Profiles` as
1076
+ * entity objects produced a `MJ: Companies` missing-field dump at MJAPI boot.
1012
1077
  *
1013
1078
  * @param data - A plain object whose properties map to field names on this entity
1014
1079
  * (and potentially parent entities in the IS-A chain).
@@ -1025,9 +1090,23 @@ export class BaseEntity {
1025
1090
  // Populate this entity's fields. SetMany also routes parent field values to
1026
1091
  // _parentEntity via the IS-A routing block (which now includes PK fields).
1027
1092
  // replaceOldValues=true ensures OldValue matches Value (no false dirty flags).
1028
- // ignoreNonExistentFields=true because data may contain fields from other
1029
- // entities in the chain that don't exist on this entity.
1030
- this.SetMany(data, true, true, true);
1093
+ // ignoreNonExistentFields=true remains as a safety net; ownedFieldsFrom already
1094
+ // dropped columns that belong to another level of the IS-A chain.
1095
+ this.SetMany(this.ownedFieldsFrom(data), true, true, true);
1096
+ }
1097
+ /**
1098
+ * Columns on `data` that this entity actually defines (by field name or CodeName).
1099
+ * Used by {@link Hydrate} so an IS-A ancestor is not asked to SetMany its child's
1100
+ * extra view columns.
1101
+ */
1102
+ ownedFieldsFrom(data) {
1103
+ const owned = {};
1104
+ for (const key of Object.keys(data)) {
1105
+ if (this.GetFieldByName(key) || this.GetFieldByCodeName(key)) {
1106
+ owned[key] = data[key];
1107
+ }
1108
+ }
1109
+ return owned;
1031
1110
  }
1032
1111
  // ─── Entity Companions ──────────────────────────────────────────────────────
1033
1112
  //
@@ -1108,21 +1187,91 @@ export class BaseEntity {
1108
1187
  DeclareRelatedRecords(options) {
1109
1188
  return this.RegisterCompanion(new RelatedRecordCollection(this, options));
1110
1189
  }
1190
+ /**
1191
+ * Declares a 1:1 embedded peer on this entity, joined by an owner-held foreign key,
1192
+ * and registers it as a companion.
1193
+ *
1194
+ * Call from a field initialiser on a **shared** (client + server) subclass — or let
1195
+ * CodeGen emit it from `EntityField.EmbeddedRecord`. The public surface is the
1196
+ * generated `{Field}_Object` getter, not this companion.
1197
+ *
1198
+ * @typeParam TEmbedded - The peer entity type.
1199
+ * @param options - The declaration.
1200
+ * @returns The registered companion.
1201
+ */
1202
+ DeclareEmbeddedRecord(options) {
1203
+ return this.RegisterCompanion(new EmbeddedRecord(this, options));
1204
+ }
1205
+ /**
1206
+ * Constructs every declared embedded peer without `NewRecord` or `Load`.
1207
+ * Called from `GetEntityObject` after {@link InitializeParentEntity}.
1208
+ *
1209
+ * @param visited - Entity names already being constructed (cycle guard).
1210
+ */
1211
+ async InitializeEmbeddedRecords(visited = new Set()) {
1212
+ if (!this.HasCompanions) {
1213
+ return;
1214
+ }
1215
+ const embeddeds = this.Companions.filter((c) => c instanceof EmbeddedRecord);
1216
+ if (embeddeds.length === 0) {
1217
+ return;
1218
+ }
1219
+ // Copy the visited set per sibling. Sharing one mutable set made two
1220
+ // embeds targeting the same entity (BillTo + ShipTo Address) throw a
1221
+ // false "cycle detected" while the first companion was still in flight.
1222
+ await Promise.all(embeddeds.map(e => e.InitializeInstance(new Set(visited))));
1223
+ }
1224
+ /**
1225
+ * Builds a related entity the way `GetEntityObject` does, minus `NewRecord` / `Load`.
1226
+ * Used by {@link EmbeddedRecord} so construction can thread a cycle-detection set.
1227
+ *
1228
+ * @typeParam T - The entity type to construct.
1229
+ * @param entityName - Metadata entity name.
1230
+ * @param visited - Cycle guard, forwarded into the new instance's own embeddeds.
1231
+ */
1232
+ async ConstructUninitializedEntity(entityName, visited) {
1233
+ const provider = this.ProviderToUse;
1234
+ if (!provider) {
1235
+ throw new Error(`BaseEntity.ConstructUninitializedEntity: no provider; cannot construct '${entityName}'.`);
1236
+ }
1237
+ const entityInfo = provider.EntityByName(entityName);
1238
+ if (!entityInfo) {
1239
+ throw new Error(`BaseEntity.ConstructUninitializedEntity: entity '${entityName}' not found in metadata.`);
1240
+ }
1241
+ let instance = MJGlobal.Instance.ClassFactory.CreateInstance(BaseEntity, entityName, entityInfo, provider);
1242
+ if (!instance) {
1243
+ instance = MJGlobal.Instance.ClassFactory.CreateInstance(BaseEntity, null, entityInfo, provider);
1244
+ }
1245
+ if (!instance) {
1246
+ throw new Error(`BaseEntity.ConstructUninitializedEntity: ClassFactory could not construct '${entityName}'. ` +
1247
+ `Ensure the entity class is registered.`);
1248
+ }
1249
+ await instance.Config(this.ContextCurrentUser);
1250
+ await instance.InitializeParentEntity();
1251
+ // Recurse so a *new* peer's own embeds are constructed (required nested
1252
+ // FKs provision on NewRecord; Ensure on the nested peer does not throw).
1253
+ // InitializeInstance skips when RelatedEntity is already on this path,
1254
+ // so a self-FK / A→B→A cycle still constructs only one extra level.
1255
+ await instance.InitializeEmbeddedRecords(visited);
1256
+ return instance;
1257
+ }
1111
1258
  /**
1112
1259
  * Serializes every registered companion that has something to send.
1113
1260
  *
1114
1261
  * Companions returning `null` are omitted entirely, so a header-only save on a composite entity
1115
1262
  * ships no companion payload at all and costs nothing extra on the wire.
1116
1263
  *
1264
+ * @param mode - `'request'` omits clean saved companions. `'result'` ships
1265
+ * authoritative post-save state so the other tier can mark peers saved.
1117
1266
  * @returns The companion payloads, in declaration order.
1118
1267
  */
1119
- async SerializeCompanions() {
1268
+ async SerializeCompanions(mode = 'request') {
1120
1269
  if (!this.HasCompanions) {
1121
1270
  return [];
1122
1271
  }
1123
1272
  const payloads = [];
1124
1273
  for (const companion of this.Companions) {
1125
- const data = await companion.Serialize();
1274
+ const data = await companion.Serialize(mode);
1126
1275
  if (data !== null && data !== undefined) {
1127
1276
  payloads.push({ Name: companion.Name, Data: data });
1128
1277
  }
@@ -1310,13 +1459,29 @@ export class BaseEntity {
1310
1459
  }
1311
1460
  });
1312
1461
  }
1462
+ /**
1463
+ * Seeds the embed-load cycle set for a nested `InnerLoad`. Called by
1464
+ * {@link EmbeddedRecord.LoadEager} so inherit walks share one `entityName:PK`
1465
+ * path and a self-parented row fails cleanly instead of recursing forever.
1466
+ */
1467
+ SetEmbeddedLoadVisited(visited) {
1468
+ this._embedLoadVisited = visited;
1469
+ }
1470
+ seedEmbedLoadVisited() {
1471
+ const seeded = new Set();
1472
+ const name = this.EntityInfo?.Name;
1473
+ const pk = this.FirstPrimaryKey?.Value;
1474
+ if (name && pk !== null && pk !== undefined && pk !== '') {
1475
+ seeded.add(`${name}:${String(pk)}`);
1476
+ }
1477
+ return seeded;
1478
+ }
1313
1479
  async loadEagerCompanions() {
1314
1480
  if (!this.HasCompanions) {
1315
1481
  return;
1316
1482
  }
1317
- for (const companion of this.Companions) {
1318
- await companion.LoadEager();
1319
- }
1483
+ const visited = this._embedLoadVisited ?? this.seedEmbedLoadVisited();
1484
+ await Promise.all(this.Companions.map(c => c.LoadEager(visited)));
1320
1485
  }
1321
1486
  /**
1322
1487
  * Tells every companion that the unit of work committed, so they can clear pending removals and
@@ -1400,6 +1565,9 @@ export class BaseEntity {
1400
1565
  Label: this.EntityInfo?.Name ?? 'root',
1401
1566
  SelfOnly: true,
1402
1567
  });
1568
+ for (const companion of this.Companions) {
1569
+ companion.ContributePostDeleteWork(plan);
1570
+ }
1403
1571
  return plan;
1404
1572
  }
1405
1573
  /**
@@ -1475,6 +1643,10 @@ export class BaseEntity {
1475
1643
  childSaveOptions.GraphVisited = visited;
1476
1644
  const childDeleteOptions = Object.assign(new EntityDeleteOptions(), deleteOptions ?? {});
1477
1645
  childDeleteOptions.GraphVisited = visited;
1646
+ // Snapshot dirty/saved bookkeeping so a rolled-back graph can be retried.
1647
+ // Node Save() finalizes each participant as saved+clean; DB rollback does
1648
+ // not undo that, and the next Save() would skip the peer and fail the FK.
1649
+ const participants = this.captureGraphParticipants(plan);
1478
1650
  // Acquired INSIDE the try: a begin failure (pool exhausted, dead connection) is a failed
1479
1651
  // save, and Save()/Delete() report failure by returning false — an escaping throw here
1480
1652
  // would break that contract for exactly one path.
@@ -1486,13 +1658,14 @@ export class BaseEntity {
1486
1658
  : null;
1487
1659
  const result = await ExecuteEntitySavePlan(plan, {
1488
1660
  SaveOptions: childSaveOptions,
1489
- RootSaveOptions: this.buildRootSaveOptions(saveOptions),
1490
1661
  DeleteOptions: childDeleteOptions,
1491
- RootDeleteOptions: this.buildRootDeleteOptions(deleteOptions),
1492
1662
  Visited: visited,
1663
+ SaveSelfOnly: (entity, opts) => entity.saveAsGraphNode(opts),
1664
+ DeleteSelfOnly: (entity, opts) => entity.deleteAsGraphNode(opts),
1493
1665
  });
1494
1666
  if (!result.Success) {
1495
1667
  await scope?.Rollback();
1668
+ this.revertGraphParticipants(participants);
1496
1669
  this.registerGraphFailure(result.ErrorMessage, operation);
1497
1670
  this.RaiseEvent('graph_save', { Success: false, NodeCount: plan.NodeCount, Error: result.ErrorMessage });
1498
1671
  return false;
@@ -1513,6 +1686,7 @@ export class BaseEntity {
1513
1686
  LogError(`BaseEntity.executeGraphLocal: rollback failed after graph error for ` +
1514
1687
  `${this.EntityInfo?.Name}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
1515
1688
  }
1689
+ this.revertGraphParticipants(participants);
1516
1690
  const detail = e instanceof Error ? e.message : String(e);
1517
1691
  LogError(`BaseEntity.executeGraphLocal failed for ${this.EntityInfo?.Name}: ${detail}`);
1518
1692
  this.registerGraphFailure(detail, operation);
@@ -1521,31 +1695,54 @@ export class BaseEntity {
1521
1695
  }
1522
1696
  }
1523
1697
  /**
1524
- * Builds the save options for the graph's **root** node.
1525
- *
1526
- * Copies the caller's options onto a real `EntitySaveOptions` instance and stamps
1527
- * `IsGraphNodeSave`, which is what stops the root re-entering graph planning and lets it bypass
1528
- * its own in-flight save debounce.
1529
- *
1530
- * @param source - The caller's options, if any.
1531
- * @returns Options for the root node.
1698
+ * Persist this record as one already-planned graph node. Private: the public
1699
+ * `Save()` contract must not expose a "skip companions" switch. The executor
1700
+ * binds this via {@link ExecuteEntitySavePlan}'s `SaveSelfOnly` callback.
1532
1701
  */
1533
- buildRootSaveOptions(source) {
1534
- const options = Object.assign(new EntitySaveOptions(), source ?? {});
1535
- options.IsGraphNodeSave = true;
1536
- return options;
1702
+ saveAsGraphNode(options) {
1703
+ return this._InnerSave(options);
1537
1704
  }
1538
1705
  /**
1539
- * Builds the delete options for the graph's **root** node. Delete-path counterpart of
1540
- * {@link buildRootSaveOptions}.
1541
- *
1542
- * @param source - The caller's options, if any.
1543
- * @returns Options for the root node.
1706
+ * Delete-path counterpart of {@link saveAsGraphNode}.
1707
+ */
1708
+ deleteAsGraphNode(options) {
1709
+ return this._InnerDelete(options);
1710
+ }
1711
+ /**
1712
+ * Captures saved/dirty baselines for every plan participant so a rolled-back
1713
+ * graph can be retried without re-INSERTing a "saved" peer or skipping it.
1544
1714
  */
1545
- buildRootDeleteOptions(source) {
1546
- const options = Object.assign(new EntityDeleteOptions(), source ?? {});
1547
- options.IsGraphNodeDelete = true;
1548
- return options;
1715
+ captureGraphParticipants(plan) {
1716
+ return plan.Nodes.map(node => ({
1717
+ entity: node.Entity,
1718
+ wasSaved: node.Entity.IsSaved,
1719
+ oldValues: node.Entity.Fields.map(f => ({ name: f.Name, old: f.OldValue })),
1720
+ }));
1721
+ }
1722
+ /**
1723
+ * Restores in-memory saved/dirty state after the database rolled the graph back.
1724
+ */
1725
+ revertGraphParticipants(snapshots) {
1726
+ for (const snap of snapshots) {
1727
+ snap.entity.revertUncommittedGraphSave(snap);
1728
+ }
1729
+ }
1730
+ /**
1731
+ * After a graph node Save() the fields look clean and `_everSaved` is true.
1732
+ * The DB rollback does not undo that. Restore the pre-attempt baseline so
1733
+ * a retry still writes the peer and the owner FK still matches.
1734
+ */
1735
+ revertUncommittedGraphSave(snap) {
1736
+ if (!snap.wasSaved) {
1737
+ this._everSaved = false;
1738
+ this._recordLoaded = false;
1739
+ }
1740
+ for (const captured of snap.oldValues) {
1741
+ const field = this.GetFieldByName(captured.name);
1742
+ if (field) {
1743
+ field.RestoreOldValue(captured.old);
1744
+ }
1745
+ }
1549
1746
  }
1550
1747
  /**
1551
1748
  * Records a graph-level failure on this entity's result history.
@@ -2050,6 +2247,10 @@ export class BaseEntity {
2050
2247
  * @param FieldName
2051
2248
  * @returns
2052
2249
  */
2250
+ /** Records a value derived from `_raw` so later reads skip the conversion. See {@link _rawConverted}. */
2251
+ memoizeRawConversion(fieldName, value) {
2252
+ (this._rawConverted ??= new Map()).set(fieldName, value);
2253
+ }
2053
2254
  Get(FieldName) {
2054
2255
  // IS-A routing: return the authoritative value from the parent entity
2055
2256
  if (this._parentEntity && this._parentEntityFieldNames?.has(FieldName)) {
@@ -2070,20 +2271,26 @@ export class BaseEntity {
2070
2271
  let value = this._raw[FieldName];
2071
2272
  if (value === undefined)
2072
2273
  return null;
2073
- // Date conversion mirrors the hydrated path. Mutating _raw to cache the converted
2074
- // Date avoids reparsing on every read.
2274
+ // Conversions mirror the hydrated path, and memoize into `_rawConverted` rather than
2275
+ // back into `_raw` the row may be shared, frozen cache state. Fields needing no
2276
+ // conversion (the vast majority) never touch the memo at all, so the fast path stays
2277
+ // a single property read.
2075
2278
  const fi = this._EntityInfo?.FieldByName(FieldName);
2076
2279
  if (fi?.TSType === EntityFieldTSType.Date && (typeof value === 'string' || typeof value === 'number')) {
2280
+ const memo = this._rawConverted?.get(FieldName);
2281
+ if (memo !== undefined)
2282
+ return memo;
2077
2283
  const d = new Date(value);
2078
- this._raw[FieldName] = d;
2284
+ this.memoizeRawConversion(FieldName, d);
2079
2285
  return d;
2080
2286
  }
2081
- // Mirror the EntityField.Value setter: rtrim padding for fixed-
2082
- // width string columns. Memoize back into _raw so we don't
2083
- // re-trim on every read.
2287
+ // Mirror the EntityField.Value setter: rtrim padding for fixed-width string columns.
2084
2288
  if (typeof value === 'string' && fi?.FixedWidthColumn) {
2289
+ const memo = this._rawConverted?.get(FieldName);
2290
+ if (memo !== undefined)
2291
+ return memo;
2085
2292
  value = value.replace(/ +$/, '');
2086
- this._raw[FieldName] = value;
2293
+ this.memoizeRawConversion(FieldName, value);
2087
2294
  }
2088
2295
  return value;
2089
2296
  }
@@ -2346,6 +2553,7 @@ export class BaseEntity {
2346
2553
  this._Fields = [];
2347
2554
  this._fieldsHydrated = false;
2348
2555
  this._raw = null;
2556
+ this._rawConverted = null;
2349
2557
  this._fieldCache = null;
2350
2558
  this._codeNameCache = null;
2351
2559
  // Field construction is deferred to hydrateFieldsIfNeeded(). Constructor / init() stays
@@ -2402,8 +2610,10 @@ export class BaseEntity {
2402
2610
  }
2403
2611
  }
2404
2612
  // Raw data has been promoted into Fields — release the reference so we don't carry
2405
- // duplicate state.
2613
+ // duplicate state. Fields hold their own copies, so a frozen source no longer
2614
+ // constrains anything from here on.
2406
2615
  this._raw = null;
2616
+ this._rawConverted = null;
2407
2617
  }
2408
2618
  }
2409
2619
  /**
@@ -2512,9 +2722,24 @@ export class BaseEntity {
2512
2722
  this.Set(kv.FieldName, kv.Value);
2513
2723
  });
2514
2724
  }
2725
+ this.notifyEmbeddedNewRecord();
2515
2726
  this.RaiseEvent('new_record', null);
2516
2727
  return true;
2517
2728
  }
2729
+ /**
2730
+ * Required embeddeds are provisioned here so `{Field}_Object` is usable immediately
2731
+ * after `GetEntityObject` / `NewRecord`. Nullable embeddeds stay unexposed.
2732
+ */
2733
+ notifyEmbeddedNewRecord() {
2734
+ if (!this.HasCompanions) {
2735
+ return;
2736
+ }
2737
+ for (const companion of this.Companions) {
2738
+ if (companion instanceof EmbeddedRecord) {
2739
+ companion.OnOwnerNewRecord();
2740
+ }
2741
+ }
2742
+ }
2518
2743
  /**
2519
2744
  * Returns the active restore context for the next save, if any.
2520
2745
  *
@@ -2613,11 +2838,6 @@ export class BaseEntity {
2613
2838
  if (options?.IsParentEntitySave) {
2614
2839
  return this._InnerSave(options);
2615
2840
  }
2616
- // Executing our own node inside a graph we already planned. Bypass both the debounce and
2617
- // graph routing — see EntitySaveOptions.IsGraphNodeSave for why each matters.
2618
- if (options?.IsGraphNodeSave) {
2619
- return this._InnerSave(options);
2620
- }
2621
2841
  // If a save is already in progress, return its promise. This check MUST run before graph
2622
2842
  // routing: a composite save is still a save, and two concurrent Save() calls on one record
2623
2843
  // (double-click, autosave racing a manual save) must share the in-flight unit of work.
@@ -2649,8 +2869,7 @@ export class BaseEntity {
2649
2869
  }
2650
2870
  // Run the graph through the same pending-save pipeline as a single-row save, so
2651
2871
  // concurrent callers share one execution and one result. The graph's own root node
2652
- // re-enters Save() with IsGraphNodeSave, which bypasses this pipeline above no
2653
- // self-deadlock.
2872
+ // runs via the private saveAsGraphNode path no re-entry into this pipeline.
2654
2873
  this._pendingSave$ = of(options).pipe(switchMap(opts => from(this.saveGraph(plan, opts))), finalize(() => { this._pendingSave$ = null; }), shareReplay(1));
2655
2874
  return firstValueFrom(this._pendingSave$);
2656
2875
  }
@@ -2766,11 +2985,29 @@ export class BaseEntity {
2766
2985
  else {
2767
2986
  // First run synchronous validation
2768
2987
  valResult = this.Validate();
2769
- // Determine if we should run async validation:
2770
- // 1. Explicitly set in options, OR
2771
- // 2. Use the subclass's default if not specified in options
2772
- const skipAsyncValidation = _options.SkipAsyncValidation !== undefined ?
2773
- _options.SkipAsyncValidation : this.DefaultSkipAsyncValidation;
2988
+ // Determine if we should run async validation, in order of authority:
2989
+ // 1. An explicit SkipAsyncValidation in the options.
2990
+ // 2. An explicit DefaultSkipAsyncValidation override on the subclass.
2991
+ // 3. Neither: run it if and only if — a subclass wrote a ValidateAsync
2992
+ // to run. Overriding the method IS the request to run it.
2993
+ //
2994
+ // Case 3 is the fix for a silent no-op. The default is `true`, and the base
2995
+ // ValidateAsync just returns success, so skipping costs a subclass that did
2996
+ // not override it precisely nothing. The flag's only reachable effect was
2997
+ // therefore to disable the async rules of subclasses that WROTE async rules
2998
+ // and never learned a second, separate getter had to be overridden too —
2999
+ // which the ValidateAsync docstring did not mention while promising the
3000
+ // method was "automatically called by Save()".
3001
+ //
3002
+ // That is how OrderEntityServer.ValidateAsync — holding both the "cannot
3003
+ // confirm an order with no lines" guard and an entire per-line validation
3004
+ // loop — was dead on every save in production, and it is the same reasoning
3005
+ // that already exempts companions below.
3006
+ const skipAsyncValidation = _options.SkipAsyncValidation !== undefined
3007
+ ? _options.SkipAsyncValidation
3008
+ : IsMemberOverridden(this, 'DefaultSkipAsyncValidation', BaseEntity)
3009
+ ? this.DefaultSkipAsyncValidation
3010
+ : !IsMemberOverridden(this, 'ValidateAsync', BaseEntity);
2774
3011
  // If not skipping async validation, run it - even if sync validation failed
2775
3012
  // This ensures all validation errors (sync and async) are collected
2776
3013
  if (!skipAsyncValidation) {
@@ -2937,7 +3174,11 @@ export class BaseEntity {
2937
3174
  finalizeSave(data, saveSubType) {
2938
3175
  if (data) {
2939
3176
  this.init(); // wipe out the current data to flush out the DIRTY flags, load the ID as part of this too
2940
- this.SetMany(data, false, true, true); // set the new values from the data returned from the save, this will also reset the old values
3177
+ const fieldData = (data instanceof BaseEntity || (data && typeof data.GetAll === 'function')) ? data.GetAll() : data;
3178
+ // IS-A GetAll() merges the parent, including parent virtuals this entity
3179
+ // does not own (e.g. OrderHeader on Event Order Line). Keep only columns
3180
+ // this entity defines, and ignore anything leftover.
3181
+ this.SetMany(this.ownedFieldsFrom(fieldData), true, true, true);
2941
3182
  this._everSaved = true; // Mark as saved after successful save
2942
3183
  const result = this.LatestResult;
2943
3184
  if (result)
@@ -2975,7 +3216,9 @@ export class BaseEntity {
2975
3216
  }
2976
3217
  // Cache it via this entity's provider so the cache lives on the right connection.
2977
3218
  const md = this.ProviderToUse;
2978
- md.SetCachedRecordName(this.EntityInfo.Name, this.PrimaryKey, recordName);
3219
+ if (typeof md?.SetCachedRecordName === 'function') {
3220
+ md.SetCachedRecordName(this.EntityInfo.Name, this.PrimaryKey, recordName);
3221
+ }
2979
3222
  }
2980
3223
  /**
2981
3224
  * Utility method that returns true if the given permission being checked is enabled for the current user, and false if not.
@@ -3249,6 +3492,10 @@ export class BaseEntity {
3249
3492
  !this.EntityInfo?.IsParentType;
3250
3493
  if (canTakeFastPath) {
3251
3494
  this._raw = data;
3495
+ // Drop any conversions memoized from a previously-loaded row — they describe the old
3496
+ // `_raw`, not this one. No isFrozen probe is needed: `Get()` never writes to `_raw`,
3497
+ // so whether the row is frozen (now, or at any point later) does not affect reads.
3498
+ this._rawConverted = null;
3252
3499
  // Mirror the "are PKs present?" check that the hydrated path does, but read straight
3253
3500
  // from the raw data so we don't trigger hydration.
3254
3501
  const pks = this.EntityInfo?.PrimaryKeys ?? [];
@@ -3395,11 +3642,19 @@ export class BaseEntity {
3395
3642
  }
3396
3643
  /**
3397
3644
  * Default value for whether async validation should be skipped.
3398
- * Subclasses can override this property to enable async validation by default.
3399
- * When the options object is passed to Save(), and it includes a value for the
3400
- * SkipAsyncValidation property, that value will take precedence over this default.
3645
+ *
3646
+ * @remarks
3647
+ * Override this to state a policy explicitly; an explicit override always wins over the
3648
+ * inference described below. When the options object passed to `Save()` includes
3649
+ * `SkipAsyncValidation`, that value takes precedence over both.
3650
+ *
3651
+ * **If no subclass overrides this getter**, the answer is inferred instead: async validation
3652
+ * runs when a subclass has overridden {@link ValidateAsync}, and is skipped when none has.
3653
+ * Reading the literal `true` below as "async validation is off unless you find this getter"
3654
+ * made every hand-written `ValidateAsync` a silent no-op — see the note on that method.
3401
3655
  *
3402
3656
  * @see {@link Save}
3657
+ * @see {@link ValidateAsync}
3403
3658
  *
3404
3659
  * @protected
3405
3660
  */
@@ -3414,6 +3669,15 @@ export class BaseEntity {
3414
3669
  * 1. This should NEVER be called INSTEAD of the synchronous Validate() method
3415
3670
  * 2. This is meant to be overridden by subclasses that need to perform async validations
3416
3671
  * 3. The base implementation just returns success - no actual validation is performed
3672
+ * 4. Overriding this method is what turns it on. You do NOT also have to override
3673
+ * {@link DefaultSkipAsyncValidation} — that getter is for stating a policy explicitly, and
3674
+ * an explicit override of it (either value) still wins. To suppress async validation for one
3675
+ * call, pass `SkipAsyncValidation: true` in the save options.
3676
+ *
3677
+ * Point 4 used to be the opposite, and it was not discoverable: `DefaultSkipAsyncValidation`
3678
+ * defaults to `true`, so an override written against this docstring alone never ran. It reads
3679
+ * as enforced, reviews as enforced, and was not — the failure mode that let an order confirm
3680
+ * with no lines in production.
3417
3681
  *
3418
3682
  * Subclasses should override this to add complex validations that require database queries
3419
3683
  * or other async operations that cannot be performed in the synchronous Validate() method.
@@ -3436,11 +3700,6 @@ export class BaseEntity {
3436
3700
  * @returns Promise<boolean>
3437
3701
  */
3438
3702
  async Delete(options) {
3439
- // Executing our own node inside a delete graph we already planned. Bypass both the debounce
3440
- // and graph routing — see EntityDeleteOptions.IsGraphNodeDelete.
3441
- if (options?.IsGraphNodeDelete) {
3442
- return this._InnerDelete(options);
3443
- }
3444
3703
  // Composite routing: companions that own their children (OnRemove:'delete') contribute
3445
3704
  // child deletions that must run before this row disappears. A single-node plan falls
3446
3705
  // through to the ordinary path, so nothing changes for entities without companions.
@@ -4044,5 +4303,115 @@ export class BaseEntity {
4044
4303
  ResetVectors() {
4045
4304
  this._vectors.clear();
4046
4305
  }
4306
+ /**
4307
+ * Resolves the recursive foreign key field for this entity. If `parentFieldName` is provided,
4308
+ * finds that specific field. Otherwise defaults to 'ParentID' if present, or the first
4309
+ * self-referencing foreign key field found on the entity.
4310
+ */
4311
+ getRecursiveForeignKeyField(parentFieldName) {
4312
+ if (!this.EntityInfo)
4313
+ return null;
4314
+ if (this.PrimaryKeys.length !== 1) {
4315
+ LogError(`BaseEntity hierarchy methods: Entity '${this.EntityInfo.Name}' has ${this.PrimaryKeys.length} primary key fields. MemberJunction hierarchy traversal requires a single-column primary key.`);
4316
+ return null;
4317
+ }
4318
+ const fields = this.EntityInfo.Fields ?? [];
4319
+ if (parentFieldName) {
4320
+ const match = fields.find(f => f.Name.toLowerCase() === parentFieldName.toLowerCase() || f.CodeName.toLowerCase() === parentFieldName.toLowerCase());
4321
+ return match ?? null;
4322
+ }
4323
+ // Check for field explicitly configured with IsHierarchy = true first
4324
+ const explicitHierarchyField = fields.find(f => f.IsHierarchy && (UUIDsEqual(f.RelatedEntityID, this.EntityInfo?.ID) || f.RelatedEntity === this.EntityInfo?.Name));
4325
+ if (explicitHierarchyField)
4326
+ return explicitHierarchyField;
4327
+ // Check for 'ParentID' self-referencing foreign key next
4328
+ const parentIdField = fields.find(f => f.Name.toLowerCase() === 'parentid' && (UUIDsEqual(f.RelatedEntityID, this.EntityInfo?.ID) || f.RelatedEntity === this.EntityInfo?.Name));
4329
+ if (parentIdField)
4330
+ return parentIdField;
4331
+ // Fall back to first recursive foreign key field
4332
+ return fields.find(f => f.RelatedEntityID && (UUIDsEqual(f.RelatedEntityID, this.EntityInfo?.ID) || f.RelatedEntity === this.EntityInfo?.Name)) ?? null;
4333
+ }
4334
+ /**
4335
+ * Retrieves all descendant records in the hierarchy under this record using a single RunView query.
4336
+ * @param optionsOrMaxDepth Optional maximum relative depth to retrieve, or an options object.
4337
+ * @returns Array of descendant entity instances ordered by hierarchy depth.
4338
+ */
4339
+ async GetDescendants(options) {
4340
+ const maxDepth = typeof options === 'number' ? options : options?.maxDepth;
4341
+ const parentFieldName = typeof options === 'object' ? options?.parentFieldName : undefined;
4342
+ const fkField = this.getRecursiveForeignKeyField(parentFieldName);
4343
+ if (!fkField) {
4344
+ LogError(`BaseEntity.GetDescendants(): No recursive foreign key field found on entity ${this.EntityInfo?.Name}`);
4345
+ return [];
4346
+ }
4347
+ const pkName = this.FirstPrimaryKey?.Name ?? 'ID';
4348
+ const rootId = this.Get(pkName);
4349
+ if (!rootId)
4350
+ return [];
4351
+ const rootFieldName = `Root${fkField.Name}`;
4352
+ const depthFieldName = `${fkField.Name}Depth`;
4353
+ const filter = maxDepth != null
4354
+ ? `${rootFieldName} = '${rootId}' AND ${depthFieldName} <= ${maxDepth}`
4355
+ : `${rootFieldName} = '${rootId}'`;
4356
+ const rv = new RunView();
4357
+ const result = await rv.RunView({
4358
+ EntityName: this.EntityInfo.Name,
4359
+ ExtraFilter: filter,
4360
+ OrderBy: `${depthFieldName} ASC`,
4361
+ }, this._contextCurrentUser);
4362
+ return result.Success ? (result.Results ?? []) : [];
4363
+ }
4364
+ /**
4365
+ * Retrieves all ancestor records in the hierarchy from the top-level root down to this record using a single RunView query.
4366
+ * @param parentFieldName Optional recursive foreign key field name (defaults to 'ParentID' or the first recursive FK found).
4367
+ * @returns Array of ancestor entity instances ordered from root down to parent.
4368
+ */
4369
+ async GetAncestors(parentFieldName) {
4370
+ const fkField = this.getRecursiveForeignKeyField(parentFieldName);
4371
+ if (!fkField) {
4372
+ LogError(`BaseEntity.GetAncestors(): No recursive foreign key field found on entity ${this.EntityInfo?.Name}`);
4373
+ return [];
4374
+ }
4375
+ const pkName = this.FirstPrimaryKey?.Name ?? 'ID';
4376
+ const currentId = this.Get(pkName);
4377
+ const pathFieldName = `${fkField.Name}Path`;
4378
+ const depthFieldName = `${fkField.Name}Depth`;
4379
+ const path = this.Get(pathFieldName);
4380
+ if (!path)
4381
+ return [];
4382
+ const rawIds = path.split('/').filter(id => id.length > 0 && id !== currentId);
4383
+ if (rawIds.length === 0)
4384
+ return [];
4385
+ const rv = new RunView();
4386
+ const idList = rawIds.map(id => `'${id}'`).join(',');
4387
+ const result = await rv.RunView({
4388
+ EntityName: this.EntityInfo.Name,
4389
+ ExtraFilter: `${pkName} IN (${idList})`,
4390
+ OrderBy: `${depthFieldName} ASC`,
4391
+ }, this._contextCurrentUser);
4392
+ return result.Success ? (result.Results ?? []) : [];
4393
+ }
4394
+ /**
4395
+ * Retrieves all direct child records of this record using a single RunView query.
4396
+ * @param parentFieldName Optional recursive foreign key field name (defaults to 'ParentID' or the first recursive FK found).
4397
+ * @returns Array of direct child entity instances.
4398
+ */
4399
+ async GetChildren(parentFieldName) {
4400
+ const fkField = this.getRecursiveForeignKeyField(parentFieldName);
4401
+ if (!fkField) {
4402
+ LogError(`BaseEntity.GetChildren(): No recursive foreign key field found on entity ${this.EntityInfo?.Name}`);
4403
+ return [];
4404
+ }
4405
+ const pkName = this.FirstPrimaryKey?.Name ?? 'ID';
4406
+ const currentId = this.Get(pkName);
4407
+ if (!currentId)
4408
+ return [];
4409
+ const rv = new RunView();
4410
+ const result = await rv.RunView({
4411
+ EntityName: this.EntityInfo.Name,
4412
+ ExtraFilter: `${fkField.Name} = '${currentId}'`,
4413
+ }, this._contextCurrentUser);
4414
+ return result.Success ? (result.Results ?? []) : [];
4415
+ }
4047
4416
  }
4048
4417
  //# sourceMappingURL=baseEntity.js.map