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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +180 -0
  2. package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
  3. package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts +28 -0
  4. package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts.map +1 -1
  5. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts +76 -0
  6. package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts.map +1 -1
  7. package/dist/generic/baseEntity.d.ts +201 -11
  8. package/dist/generic/baseEntity.d.ts.map +1 -1
  9. package/dist/generic/baseEntity.js +392 -89
  10. package/dist/generic/baseEntity.js.map +1 -1
  11. package/dist/generic/databaseProviderBase.d.ts +13 -7
  12. package/dist/generic/databaseProviderBase.d.ts.map +1 -1
  13. package/dist/generic/databaseProviderBase.js +18 -9
  14. package/dist/generic/databaseProviderBase.js.map +1 -1
  15. package/dist/generic/entityConfiguration.d.ts +1 -0
  16. package/dist/generic/entityConfiguration.d.ts.map +1 -1
  17. package/dist/generic/entityConfiguration.js.map +1 -1
  18. package/dist/generic/entityInfo.d.ts +28 -1
  19. package/dist/generic/entityInfo.d.ts.map +1 -1
  20. package/dist/generic/entityInfo.js +37 -0
  21. package/dist/generic/entityInfo.js.map +1 -1
  22. package/dist/generic/interfaces.d.ts +12 -3
  23. package/dist/generic/interfaces.d.ts.map +1 -1
  24. package/dist/generic/interfaces.js.map +1 -1
  25. package/dist/generic/providerBase.d.ts +82 -1
  26. package/dist/generic/providerBase.d.ts.map +1 -1
  27. package/dist/generic/providerBase.js +154 -19
  28. package/dist/generic/providerBase.js.map +1 -1
  29. package/package.json +3 -3
@@ -552,11 +552,27 @@ let EntityField = class EntityField {
552
552
  }
553
553
  /**
554
554
  * Restores the dirty-tracking baseline to a previously captured value.
555
- * Used by graph rollback so a retried save still sees the pre-attempt dirty set.
556
555
  */
557
556
  RestoreOldValue(value) {
558
557
  this._oldValue = value;
559
558
  }
559
+ /**
560
+ * Framework-internal: the field's value and dirty-tracking state, so a unit of work that rolls
561
+ * back can put them back with {@link RestoreState}.
562
+ */
563
+ GetState() {
564
+ return { Value: this._value, OldValue: this._oldValue, NotLoaded: this._notLoaded, NeverSet: this._neverSet };
565
+ }
566
+ /**
567
+ * Framework-internal: puts back a state {@link GetState} captured. It writes the state as it was,
568
+ * so the read-only and first-set rules of the {@link Value} setter don't apply.
569
+ */
570
+ RestoreState(state) {
571
+ this._value = state.Value;
572
+ this._oldValue = state.OldValue;
573
+ this._notLoaded = state.NotLoaded;
574
+ this._neverSet = state.NeverSet;
575
+ }
560
576
  /**
561
577
  * @deprecated No-op as of the active-status relocation. Active-status (deprecated/disabled)
562
578
  * assertions are now enforced at BaseEntity.Get/Set/SetMany — the entry points genuine code uses —
@@ -583,6 +599,13 @@ EntityField = EntityField_1 = __decorate([
583
599
  __metadata("design:paramtypes", [EntityFieldInfo, Object])
584
600
  ], EntityField);
585
601
  export { EntityField };
602
+ /**
603
+ * The `ChangeContext` JSON a clone writes on its Record Change rows (`IRecordChangeContext`,
604
+ * Kind 'Clone'). The one serializer every provider path uses, so the shape can't drift.
605
+ */
606
+ export function SerializeCloneChangeContext(context) {
607
+ return JSON.stringify({ Version: 1, Kind: 'Clone', Clone: context });
608
+ }
586
609
  export class DataObjectRelatedEntityParam {
587
610
  }
588
611
  export class DataObjectParams {
@@ -730,6 +753,9 @@ export class BaseEntityEvent {
730
753
  function sameEntityName(a, b) {
731
754
  return a.trim().toLowerCase() === b.trim().toLowerCase();
732
755
  }
756
+ /**
757
+ * Base class used for all entity objects. This class is abstract and is sub-classes for each particular entity using the CodeGen tool. This class provides the basic functionality for loading, saving, and validating entity objects.
758
+ */
733
759
  export class BaseEntity {
734
760
  /**
735
761
  * Maximum number of `BaseEntityResult` entries retained in `_resultHistory` per entity
@@ -1009,6 +1035,12 @@ export class BaseEntity {
1009
1035
  * routing through the provider: participants stay ignorant of one another.
1010
1036
  */
1011
1037
  this._entityTransactionScope = null;
1038
+ /**
1039
+ * The unit of work this record is saved or deleted in, while it runs as part of a larger one: an
1040
+ * IS-A chain's initiator sets it on each parent for the parent's write, and a graph sets it on
1041
+ * each of its records. See {@link UnitOfWork}.
1042
+ */
1043
+ this._unitOfWork = null;
1012
1044
  /**
1013
1045
  * Companions registered on this entity, keyed by {@link EntityCompanion.Name}.
1014
1046
  *
@@ -1041,6 +1073,7 @@ export class BaseEntity {
1041
1073
  // provider may capture the context now but use it later.
1042
1074
  // ────────────────────────────────────────────────────────────────────
1043
1075
  this._restoreContext = null;
1076
+ this._cloneContext = null;
1044
1077
  // Holds the current pending save observable (if any)
1045
1078
  this._pendingSave$ = null;
1046
1079
  /**
@@ -1123,10 +1156,27 @@ export class BaseEntity {
1123
1156
  // route the parent through the same connection — not the global default.
1124
1157
  const parentProvider = this.ProviderToUse;
1125
1158
  this._parentEntity = await parentProvider.GetEntityObject(parentEntityInfo.Name, this._contextCurrentUser);
1159
+ // A chain loaded through the parent is linked the other way too, so the parent's
1160
+ // LeafEntity (and its save hooks) see this child (MJ#4870).
1161
+ this.linkParentBackToThisChild();
1126
1162
  // Recursive: the parent's InitializeParentEntity() was called by GetEntityObject()
1127
1163
  // Cache the parent field names for O(1) routing lookups
1128
1164
  this._parentEntityFieldNames = this.EntityInfo.ParentEntityFieldNames;
1129
1165
  }
1166
+ /**
1167
+ * Points this child's disjoint parent back at this instance (`parent._childEntity = this`).
1168
+ *
1169
+ * Same gate as {@link InitializeParentEntity}: a parent that allows several subtypes keeps no
1170
+ * single child. `NewRecord()` sets `_childEntity` to null, so this has to run AFTER the
1171
+ * parent's `NewRecord()` — calling it before is wiped out.
1172
+ */
1173
+ linkParentBackToThisChild() {
1174
+ const parentEntityInfo = this.EntityInfo?.ParentEntityInfo;
1175
+ if (!this._parentEntity || !parentEntityInfo || parentEntityInfo.AllowMultipleSubtypes) {
1176
+ return;
1177
+ }
1178
+ this._parentEntity._childEntity = this;
1179
+ }
1130
1180
  /**
1131
1181
  * IS-A PROMOTION (#3825): binds this NEW child record to an EXISTING parent row, so saving it
1132
1182
  * ADDS a subtype to a person/org/product that already exists instead of trying to create a
@@ -1205,8 +1255,9 @@ export class BaseEntity {
1205
1255
  if (!loaded) {
1206
1256
  // Restore the fresh chain the failed load destroyed: re-seed the parent chain, then put
1207
1257
  // the ORIGINAL minted key back (Set routes to the root), so the record the caller holds
1208
- // is bit-for-bit the fresh record they built.
1258
+ // is bit-for-bit the fresh record they built. Re-seed nulls the parent's back-link.
1209
1259
  this._parentEntity.NewRecord();
1260
+ this.linkParentBackToThisChild();
1210
1261
  for (const pk of freshPkValues) {
1211
1262
  if (pk.value != null) {
1212
1263
  this._parentEntity.Set(pk.name, pk.value);
@@ -1705,15 +1756,7 @@ export class BaseEntity {
1705
1756
  this.replaceChildParentChain(childEntity);
1706
1757
  this._childEntity = childEntity;
1707
1758
  const dirtySnapshots = this.captureChainDirtyState();
1708
- if (this.PrimaryKey && this.PrimaryKey.HasValue) {
1709
- const loaded = await childEntity.InnerLoad(this.PrimaryKey);
1710
- if (!loaded) {
1711
- this.mirrorSharedKeysToChild(childEntity);
1712
- }
1713
- }
1714
- else {
1715
- this.mirrorSharedKeysToChild(childEntity);
1716
- }
1759
+ await this.loadOrMirrorChildRow(childEntity);
1717
1760
  this.restoreChainDirtyState(dirtySnapshots);
1718
1761
  // Recursively discover grandchildren if the child is also a parent type
1719
1762
  if (childEntity.EntityInfo.IsParentType) {
@@ -1733,15 +1776,7 @@ export class BaseEntity {
1733
1776
  const childEntity = await childProvider.GetEntityObject(resolvedName, this._contextCurrentUser);
1734
1777
  this.replaceChildParentChain(childEntity);
1735
1778
  const dirtySnapshots = this.captureChainDirtyState();
1736
- if (this.PrimaryKey && this.PrimaryKey.HasValue) {
1737
- const loaded = await childEntity.InnerLoad(this.PrimaryKey);
1738
- if (!loaded) {
1739
- this.mirrorSharedKeysToChild(childEntity);
1740
- }
1741
- }
1742
- else {
1743
- this.mirrorSharedKeysToChild(childEntity);
1744
- }
1779
+ await this.loadOrMirrorChildRow(childEntity);
1745
1780
  this.restoreChainDirtyState(dirtySnapshots);
1746
1781
  if (childEntity.EntityInfo.IsParentType) {
1747
1782
  await childEntity.EnsureISAChild();
@@ -1749,6 +1784,32 @@ export class BaseEntity {
1749
1784
  return childEntity;
1750
1785
  }
1751
1786
  }
1787
+ /**
1788
+ * A new parent's child is new too: there is no row to read, so copy the parent's keys.
1789
+ * A saved parent still reads (promotion's usual answer is "no row"), and that miss is not
1790
+ * an error — the same way a subtype-hint probe treats an empty read (MJ#4859).
1791
+ */
1792
+ async loadOrMirrorChildRow(childEntity) {
1793
+ if (this.IsSaved && this.PrimaryKey?.HasValue) {
1794
+ const loaded = await this.loadChildRowQuietly(childEntity);
1795
+ if (!loaded) {
1796
+ this.mirrorSharedKeysToChild(childEntity);
1797
+ }
1798
+ return;
1799
+ }
1800
+ this.mirrorSharedKeysToChild(childEntity);
1801
+ }
1802
+ /** Loads the child by the shared key. An empty result is an answer, not a logged error. */
1803
+ async loadChildRowQuietly(childEntity) {
1804
+ const probe = { RowReadFailed: false };
1805
+ childEntity._subtypeHintProbe = probe;
1806
+ try {
1807
+ return await childEntity.InnerLoad(this.PrimaryKey);
1808
+ }
1809
+ finally {
1810
+ childEntity._subtypeHintProbe = null;
1811
+ }
1812
+ }
1752
1813
  mirrorSharedKeysToChild(childEntity) {
1753
1814
  const parentPks = this.EntityInfo.PrimaryKeys;
1754
1815
  if (!parentPks || parentPks.length === 0)
@@ -2163,6 +2224,20 @@ export class BaseEntity {
2163
2224
  DeclareRelatedRecords(options) {
2164
2225
  return this.RegisterCompanion(new RelatedRecordCollection(this, options));
2165
2226
  }
2227
+ /**
2228
+ * Dynamically registers a database-sourced child collection companion on this entity instance.
2229
+ * Used by generic composite persistence engines (Record Cloning, Metadata Sync) when a relationship
2230
+ * has no static collection companion declared on the generated entity class.
2231
+ *
2232
+ * Refuses duplicate names or re-registering an already existing companion.
2233
+ *
2234
+ * @typeParam TChild - The child entity type.
2235
+ * @param options - The collection declaration options.
2236
+ * @returns The registered collection companion.
2237
+ */
2238
+ DeclareRelatedRecordsDynamic(options) {
2239
+ return this.DeclareRelatedRecords(options);
2240
+ }
2166
2241
  /**
2167
2242
  * Declares a 1:1 embedded peer on this entity, joined by an owner-held foreign key,
2168
2243
  * and registers it as a companion.
@@ -2624,10 +2699,15 @@ export class BaseEntity {
2624
2699
  childSaveOptions.GraphVisited = visited;
2625
2700
  const childDeleteOptions = Object.assign(new EntityDeleteOptions(), deleteOptions ?? {});
2626
2701
  childDeleteOptions.GraphVisited = visited;
2627
- // Snapshot dirty/saved bookkeeping so a rolled-back graph can be retried.
2628
- // Node Save() finalizes each participant as saved+clean; DB rollback does
2629
- // not undo that, and the next Save() would skip the peer and fail the FK.
2630
- const participants = this.captureGraphParticipants(plan);
2702
+ // A graph that holds a scope is a unit of work: what its records write is put back if it
2703
+ // rolls back, and what they delete is reset only once it commits (see UnitOfWork). Nested in
2704
+ // another unit — the companions of an IS-A parent deleted in its child's chain, say — it
2705
+ // joins that one. Without a scope each write stands as it runs, so a failure leaves the
2706
+ // records that already wrote as they are, which is what the database holds.
2707
+ const enclosing = this._unitOfWork;
2708
+ const records = plan.Nodes.map(node => node.Entity);
2709
+ const previousUnits = records.map(record => record._unitOfWork);
2710
+ let part = null;
2631
2711
  // Acquired INSIDE the try: a begin failure (pool exhausted, dead connection) is a failed
2632
2712
  // save, and Save()/Delete() report failure by returning false — an escaping throw here
2633
2713
  // would break that contract for exactly one path.
@@ -2637,6 +2717,12 @@ export class BaseEntity {
2637
2717
  provider?.SupportsEntityTransactions === true && provider.BeginEntityTransaction
2638
2718
  ? await provider.BeginEntityTransaction()
2639
2719
  : null;
2720
+ if (scope) {
2721
+ part = BaseEntity.beginUnitOfWork(enclosing, BaseEntity.captureChains(records));
2722
+ for (const record of records) {
2723
+ record._unitOfWork = part.unit;
2724
+ }
2725
+ }
2640
2726
  const result = await ExecuteEntitySavePlan(plan, {
2641
2727
  SaveOptions: childSaveOptions,
2642
2728
  DeleteOptions: childDeleteOptions,
@@ -2646,12 +2732,13 @@ export class BaseEntity {
2646
2732
  });
2647
2733
  if (!result.Success) {
2648
2734
  await scope?.Rollback();
2649
- this.revertGraphParticipants(participants);
2735
+ BaseEntity.settleRollback(part);
2650
2736
  this.registerGraphFailure(result.ErrorMessage, operation);
2651
2737
  this.RaiseEvent('graph_save', { Success: false, NodeCount: plan.NodeCount, Error: result.ErrorMessage });
2652
2738
  return false;
2653
2739
  }
2654
2740
  await scope?.Commit();
2741
+ BaseEntity.settleCommit(part);
2655
2742
  this.acceptCompanionChanges();
2656
2743
  this.RaiseEvent('graph_save', { Success: true, NodeCount: plan.NodeCount });
2657
2744
  return true;
@@ -2667,13 +2754,18 @@ export class BaseEntity {
2667
2754
  LogError(`BaseEntity.executeGraphLocal: rollback failed after graph error for ` +
2668
2755
  `${this.EntityInfo?.Name}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
2669
2756
  }
2670
- this.revertGraphParticipants(participants);
2757
+ BaseEntity.settleRollback(part);
2671
2758
  const detail = e instanceof Error ? e.message : String(e);
2672
2759
  LogError(`BaseEntity.executeGraphLocal failed for ${this.EntityInfo?.Name}: ${detail}`);
2673
2760
  this.registerGraphFailure(detail, operation);
2674
2761
  this.RaiseEvent('graph_save', { Success: false, NodeCount: plan.NodeCount, Error: detail });
2675
2762
  return false;
2676
2763
  }
2764
+ finally {
2765
+ records.forEach((record, index) => {
2766
+ record._unitOfWork = previousUnits[index];
2767
+ });
2768
+ }
2677
2769
  }
2678
2770
  /**
2679
2771
  * Persist this record as one already-planned graph node. Private: the public
@@ -2690,38 +2782,71 @@ export class BaseEntity {
2690
2782
  return this._innerDelete(options);
2691
2783
  }
2692
2784
  /**
2693
- * Captures saved/dirty baselines for every plan participant so a rolled-back
2694
- * graph can be retried without re-INSERTing a "saved" peer or skipping it.
2785
+ * Captures each record's IS-A chain, from its leaf up to its root, so a unit of work that rolls
2786
+ * back can put the chain back with {@link restoreChains}. A record in more than one chain is
2787
+ * captured once.
2695
2788
  */
2696
- captureGraphParticipants(plan) {
2697
- return plan.Nodes.map(node => ({
2698
- entity: node.Entity,
2699
- wasSaved: node.Entity.IsSaved,
2700
- oldValues: node.Entity.Fields.map(f => ({ name: f.Name, old: f.OldValue })),
2701
- }));
2789
+ static captureChains(entities) {
2790
+ const captured = new Set();
2791
+ const snapshots = [];
2792
+ for (const entity of entities) {
2793
+ for (let level = entity.LeafEntity; level && !captured.has(level); level = level._parentEntity) {
2794
+ captured.add(level);
2795
+ snapshots.push({
2796
+ entity: level,
2797
+ wasSaved: level._everSaved,
2798
+ wasLoaded: level._recordLoaded,
2799
+ resultHistory: [...level._resultHistory],
2800
+ fields: level.Fields.map(field => ({ field, state: field.GetState() })),
2801
+ });
2802
+ }
2803
+ }
2804
+ return snapshots;
2702
2805
  }
2703
2806
  /**
2704
- * Restores in-memory saved/dirty state after the database rolled the graph back.
2807
+ * Puts back, after a rollback, what the unit of work changed in memory. See {@link restoreSnapshot}.
2705
2808
  */
2706
- revertGraphParticipants(snapshots) {
2707
- for (const snap of snapshots) {
2708
- snap.entity.revertUncommittedGraphSave(snap);
2809
+ static restoreChains(snapshots) {
2810
+ for (const snapshot of snapshots) {
2811
+ snapshot.entity.restoreSnapshot(snapshot);
2709
2812
  }
2710
2813
  }
2711
2814
  /**
2712
- * After a graph node Save() the fields look clean and `_everSaved` is true.
2713
- * The DB rollback does not undo that. Restore the pre-attempt baseline so
2714
- * a retry still writes the peer and the owner FK still matches.
2815
+ * Puts this record back as a snapshot captured it, after the unit of work that finalized it
2816
+ * rolled back.
2817
+ *
2818
+ * A save's `finalizeSave()` rebuilds the record's fields from what its write returned, marks it
2819
+ * saved and empties its result history. The database undid the write, so the record gets back
2820
+ * its saved and loaded flags, its result history, and each field's value and tracking state. A
2821
+ * field edited since then keeps the edit, and is compared with its captured baseline again, since
2822
+ * that is what the database holds after the rollback. (A delete leaves nothing to put back: a
2823
+ * record deleted inside a unit of work isn't reset until the unit commits.)
2824
+ *
2825
+ * The history matters to the caller: a save records its failure only when nothing else did,
2826
+ * judged by the history's length when it started, which an emptied history would make wrong.
2827
+ *
2828
+ * A record still holding the captured field objects was never finalized, so nothing about it
2829
+ * changed and it is left alone.
2715
2830
  */
2716
- revertUncommittedGraphSave(snap) {
2717
- if (!snap.wasSaved) {
2718
- this._everSaved = false;
2719
- this._recordLoaded = false;
2831
+ restoreSnapshot(snapshot) {
2832
+ const finalized = snapshot.fields.some(({ field }) => this.GetFieldByName(field.Name) !== field);
2833
+ if (!finalized) {
2834
+ return;
2720
2835
  }
2721
- for (const captured of snap.oldValues) {
2722
- const field = this.GetFieldByName(captured.name);
2723
- if (field) {
2724
- field.RestoreOldValue(captured.old);
2836
+ this._everSaved = snapshot.wasSaved;
2837
+ this._recordLoaded = snapshot.wasLoaded;
2838
+ this._resultHistory = snapshot.resultHistory;
2839
+ this._compositeKey = null; // cached from the finalized key; rebuilt on the next read
2840
+ for (const { field: capturedField, state } of snapshot.fields) {
2841
+ const field = this.GetFieldByName(capturedField.Name);
2842
+ if (!field) {
2843
+ continue;
2844
+ }
2845
+ if (field.Dirty) {
2846
+ field.RestoreOldValue(state.OldValue);
2847
+ }
2848
+ else {
2849
+ field.RestoreState(state);
2725
2850
  }
2726
2851
  }
2727
2852
  }
@@ -2780,6 +2905,89 @@ export class BaseEntity {
2780
2905
  LogError(`Error rolling back entity transaction scope for ${this.EntityInfo?.Name}: ${rollbackError}`);
2781
2906
  }
2782
2907
  }
2908
+ /**
2909
+ * The failure path of a save: rolls back the transaction scope this entity holds, if any, then
2910
+ * settles this save's part in its unit of work (see {@link settleRollback}). `part` is null when
2911
+ * this save took none (see `_innerSave`), and then only the scope is rolled back.
2912
+ */
2913
+ async rollbackChainSave(part) {
2914
+ await this.rollbackEntityTransactionScope();
2915
+ BaseEntity.settleRollback(part);
2916
+ }
2917
+ /**
2918
+ * Starts a unit of work's part in a {@link UnitOfWork}: joins `enclosing` when the unit runs
2919
+ * inside one, and owns a new one when it doesn't. `snapshots` are the unit's records as they are
2920
+ * before it writes them. They join the unit's, so that the outermost unit can put them back.
2921
+ */
2922
+ static beginUnitOfWork(enclosing, snapshots) {
2923
+ const unit = enclosing ?? { Snapshots: [], DeletedRecords: [] };
2924
+ unit.Snapshots.push(...snapshots);
2925
+ return { unit, owns: !enclosing, snapshots, deletedMark: unit.DeletedRecords.length };
2926
+ }
2927
+ /**
2928
+ * Runs `work` on `record` as part of `unit` (see {@link UnitOfWork}), and gives the record back
2929
+ * the unit it was in before, however `work` ends. Used for an IS-A parent's delete, which runs
2930
+ * inside its child's chain, along with the graph of its companions if it has any.
2931
+ */
2932
+ static async inUnitOfWork(record, unit, work) {
2933
+ const previous = record._unitOfWork;
2934
+ record._unitOfWork = unit;
2935
+ try {
2936
+ return await work(record);
2937
+ }
2938
+ finally {
2939
+ record._unitOfWork = previous;
2940
+ }
2941
+ }
2942
+ /**
2943
+ * After a unit of work rolls back: puts its records back as they were before it wrote them, and
2944
+ * drops the deletes the rollback undid, so that nothing resets their records. The owner does this
2945
+ * for everything the unit and the units nested in it wrote. A nested part does it for its own
2946
+ * records only; when the owner then rolls back too, restoring them again changes nothing.
2947
+ */
2948
+ static settleRollback(part) {
2949
+ if (!part) {
2950
+ return;
2951
+ }
2952
+ BaseEntity.restoreChains(part.owns ? part.unit.Snapshots : part.snapshots);
2953
+ part.unit.DeletedRecords.length = part.owns ? 0 : part.deletedMark;
2954
+ }
2955
+ /**
2956
+ * After a unit of work commits: its owner resets the records it deleted. A nested part leaves
2957
+ * them to the owner, since the enclosing unit can still roll back.
2958
+ */
2959
+ static settleCommit(part) {
2960
+ if (part?.owns) {
2961
+ BaseEntity.resetDeletedRecords(part.unit.DeletedRecords);
2962
+ }
2963
+ }
2964
+ /**
2965
+ * Resets the records a unit of work deleted, once the unit has committed, as each record's own
2966
+ * `Delete()` does when no unit of work is involved. See {@link UnitOfWork}.
2967
+ *
2968
+ * Last-deleted first, skipping a record that no longer reads as saved. `NewRecord()` on a record
2969
+ * also resets every IS-A level above it, and relinks them. A chain's initiator records its delete
2970
+ * after its parents do, so it's reset first, and its parents with it. Resetting a parent again
2971
+ * after that would unlink it from its child and give it a key the child doesn't share.
2972
+ *
2973
+ * The unit has committed, so a reset that throws is logged and the rest still run: the deletes
2974
+ * stand whatever happens here.
2975
+ */
2976
+ static resetDeletedRecords(records) {
2977
+ for (let index = records.length - 1; index >= 0; index--) {
2978
+ const record = records[index];
2979
+ if (!record.IsSaved) {
2980
+ continue;
2981
+ }
2982
+ try {
2983
+ record.NewRecord();
2984
+ }
2985
+ catch (e) {
2986
+ LogError(`BaseEntity.resetDeletedRecords: resetting a deleted ${record.EntityInfo?.Name} record failed: ` +
2987
+ `${e instanceof Error ? e.message : String(e)}`);
2988
+ }
2989
+ }
2990
+ }
2783
2991
  /**
2784
2992
  * Returns this provider to be used for a given instance of a BaseEntity derived subclass. If the provider is not set, the BaseEntity.Provider is returned.
2785
2993
  */
@@ -3809,6 +4017,8 @@ export class BaseEntity {
3809
4017
  // when setting keys.
3810
4018
  if (this._parentEntity) {
3811
4019
  this._parentEntity.NewRecord();
4020
+ // The parent's NewRecord() just nulled its back-link. Put this child back.
4021
+ this.linkParentBackToThisChild();
3812
4022
  for (const pk of this.EntityInfo.PrimaryKeys) {
3813
4023
  const parentValue = this._parentEntity.Get(pk.Name);
3814
4024
  if (parentValue != null) {
@@ -3917,6 +4127,42 @@ export class BaseEntity {
3917
4127
  ClearRestoreContext() {
3918
4128
  this._restoreContext = null;
3919
4129
  }
4130
+ /**
4131
+ * Returns the active clone context for the next save, if any.
4132
+ *
4133
+ * Read by the data provider when generating the RecordChange SQL: when
4134
+ * non-null, the resulting RecordChange row is written with
4135
+ * `Source='Clone'` and `ChangeContext` populated with structured JSON provenance.
4136
+ * Returns null for ordinary saves.
4137
+ */
4138
+ get CloneContext() {
4139
+ return this._cloneContext;
4140
+ }
4141
+ /**
4142
+ * Marks the next Save() as part of a record clone operation.
4143
+ *
4144
+ * The provider will write a new RecordChange entry with `Source='Clone'`
4145
+ * and `ChangeContext` populated with structured JSON provenance.
4146
+ *
4147
+ * The context persists on the entity until either (a) overwritten by a
4148
+ * subsequent SetCloneContext() call or (b) explicitly cleared via
4149
+ * ClearCloneContext(). It is NOT auto-cleared inside Save() because
4150
+ * TransactionGroup execution is deferred.
4151
+ *
4152
+ * @param context Structured clone context. Throws if missing required fields.
4153
+ */
4154
+ SetCloneContext(context) {
4155
+ if (!context || !context.CloneLogID || !context.SourceRecordID || !context.RootEntityName) {
4156
+ throw new Error('BaseEntity.SetCloneContext: context is required with CloneLogID, SourceRecordID, and RootEntityName');
4157
+ }
4158
+ this._cloneContext = context;
4159
+ }
4160
+ /**
4161
+ * Clears any pending clone context. Safe to call when no context is set.
4162
+ */
4163
+ ClearCloneContext() {
4164
+ this._cloneContext = null;
4165
+ }
3920
4166
  /**
3921
4167
  * Saves the current state of the object to the database. Uses the active provider to handle the actual saving of the record.
3922
4168
  * If the record is new, it will be created, if it already exists, it will be updated.
@@ -4022,6 +4268,10 @@ export class BaseEntity {
4022
4268
  const currentResultCount = this.ResultHistory.length;
4023
4269
  const newResult = new BaseEntityResult();
4024
4270
  newResult.StartedAt = new Date();
4271
+ // The unit of work this save runs inside, if any (see UnitOfWork), and this save's own part
4272
+ // in one, taken only when this entity starts an IS-A chain save whose writes a failure undoes.
4273
+ const enclosing = this._unitOfWork;
4274
+ let part = null;
4025
4275
  try {
4026
4276
  const initialDirtyState = this.Dirty; // save this because parent entity save cycle, if any, will clear their dirty flags
4027
4277
  const _options = options ? options : new EntitySaveOptions();
@@ -4039,7 +4289,20 @@ export class BaseEntity {
4039
4289
  // save — this joins it as a savepoint rather than starting a second physical
4040
4290
  // transaction. Before 6.2 this path called BeginISATransaction(), which was blind to
4041
4291
  // any existing transaction and produced torn writes; see EntityTransactionScope.
4042
- await this.beginEntityTransactionScope(isISAInitiator);
4292
+ const scopeOpened = await this.beginEntityTransactionScope(isISAInitiator);
4293
+ // Each level of the chain is finalized as saved and clean when its own write returns,
4294
+ // before the chain commits. A failure that undoes those writes has to put the chain
4295
+ // back in memory too, so the chain save is a unit of work, captured before the parents
4296
+ // save. The writes are undone when this save holds a scope, and on a provider without
4297
+ // entity transactions, which doesn't talk to a database directly: there each parent's
4298
+ // save is recorded in memory and the leaf's one write carries the chain
4299
+ // (GraphQLDataProvider). Anywhere else a level may really have written (in a
4300
+ // TransactionGroup, a level outside the group does), and marking it unsaved would make
4301
+ // the retry insert it twice.
4302
+ if (isISAInitiator && !this.TransactionGroup &&
4303
+ (scopeOpened || this.ProviderToUse?.SupportsEntityTransactions !== true)) {
4304
+ part = BaseEntity.beginUnitOfWork(enclosing, BaseEntity.captureChains([this]));
4305
+ }
4043
4306
  // Save parent chain first (root → branch → immediate parent)
4044
4307
  // Parent calls Save() recursively which handles its own parents, permissions, validation
4045
4308
  if (this._parentEntity) {
@@ -4054,8 +4317,9 @@ export class BaseEntity {
4054
4317
  parentSaveOptions.ISAActiveChildEntityName = this.EntityInfo.Name;
4055
4318
  const parentResult = await this._parentEntity.Save(parentSaveOptions); // we know parent entity exists hre
4056
4319
  if (!parentResult) {
4057
- // Parent save failed — rollback if we started the transaction
4058
- await this.rollbackEntityTransactionScope();
4320
+ // Parent save failed — roll back if we started the transaction, and put back
4321
+ // the levels above it that had already saved
4322
+ await this.rollbackChainSave(part);
4059
4323
  // RECORD the failure on THIS entity's ResultHistory before returning. Without
4060
4324
  // this the caller gets `false` with LatestResult === null and an empty
4061
4325
  // ResultHistory, because every result was written to the PARENT object — which
@@ -4194,9 +4458,10 @@ export class BaseEntity {
4194
4458
  // this scope exists to prevent.
4195
4459
  if (result) {
4196
4460
  await this.commitEntityTransactionScope();
4461
+ BaseEntity.settleCommit(part);
4197
4462
  }
4198
4463
  else {
4199
- await this.rollbackEntityTransactionScope();
4464
+ await this.rollbackChainSave(part);
4200
4465
  }
4201
4466
  return result;
4202
4467
  }
@@ -4244,12 +4509,15 @@ export class BaseEntity {
4244
4509
  // commit, so later "successful" saves were silently non-durable. Committing an
4245
4510
  // empty scope writes nothing; it only releases the transaction.
4246
4511
  await this.commitEntityTransactionScope();
4512
+ BaseEntity.settleCommit(part);
4247
4513
  return true; // nothing to save since we're not dirty
4248
4514
  }
4249
4515
  }
4250
4516
  catch (e) {
4251
- // Roll back the scope this entity opened, if any. No-op when it holds none.
4252
- await this.rollbackEntityTransactionScope();
4517
+ // Roll back the scope this entity opened, if any (a no-op when it holds none), and put
4518
+ // back what the chain's saves changed in memory, this entity included when the commit
4519
+ // is what threw. Before the result below, which reads IsSaved and the old values.
4520
+ await this.rollbackChainSave(part);
4253
4521
  if (currentResultCount === this.ResultHistory.length) {
4254
4522
  // this means that NO new results were added to the history anywhere
4255
4523
  // so we need to add a new result to the history here
@@ -5039,6 +5307,12 @@ export class BaseEntity {
5039
5307
  return this.deleteGraph(plan, options);
5040
5308
  }
5041
5309
  }
5310
+ // IS-A parent chain deletes bypass the debounce, as parent chain saves do: the leaf's
5311
+ // call back up the chain would otherwise wait on the pending delete that handed the
5312
+ // delete to the leaf, and Delete() would never return (MJ#4850).
5313
+ if (options?.IsParentEntityDelete) {
5314
+ return this._innerDelete(options);
5315
+ }
5042
5316
  // If a delete is already in progress, return its promise.
5043
5317
  if (this._pendingDelete$) {
5044
5318
  return firstValueFrom(this._pendingDelete$);
@@ -5063,6 +5337,13 @@ export class BaseEntity {
5063
5337
  const currentResultCount = this.ResultHistory.length;
5064
5338
  const newResult = new BaseEntityResult();
5065
5339
  newResult.StartedAt = new Date();
5340
+ // The unit of work this delete runs inside, if any (see UnitOfWork), and this delete's own
5341
+ // part in one, taken when it opens a scope for an IS-A chain.
5342
+ const enclosing = this._unitOfWork;
5343
+ let part = null;
5344
+ // Set once this record's own row is deleted. From then on its history holds the provider's
5345
+ // entry for that delete, which doesn't report a failure later in the chain.
5346
+ let ownRowDeleted = false;
5066
5347
  try {
5067
5348
  const _options = options ? options : new EntityDeleteOptions();
5068
5349
  // IS-A leaf delegation: if this entity has a child entity and we are NOT
@@ -5109,7 +5390,15 @@ export class BaseEntity {
5109
5390
  // Open (or join) a transaction scope for the parent chain — see the matching
5110
5391
  // comment in _InnerSave and EntityTransactionScope for why this is provider-
5111
5392
  // arbitrated rather than IS-A-specific.
5112
- await this.beginEntityTransactionScope(isISAInitiator);
5393
+ const scopeOpened = await this.beginEntityTransactionScope(isISAInitiator);
5394
+ // A chain delete that holds a scope is a unit of work. Each parent is deleted inside
5395
+ // it and isn't reset when its delete returns: the unit resets the chain once it
5396
+ // commits, so a failure that rolls it back leaves every level as it was. Without a
5397
+ // scope each delete stands as it runs and resets its record at once, as before.
5398
+ if (scopeOpened) {
5399
+ part = BaseEntity.beginUnitOfWork(enclosing, BaseEntity.captureChains([this]));
5400
+ }
5401
+ const unit = part?.unit ?? enclosing;
5113
5402
  this.CheckPermissions(EntityPermissionType.Delete, true); // this will throw an error and exit out if we don't have permission
5114
5403
  // Raise delete_started event before the actual delete operation begins
5115
5404
  this.RaiseEvent('delete_started', null);
@@ -5124,6 +5413,7 @@ export class BaseEntity {
5124
5413
  });
5125
5414
  // Delete OWN row first (FK constraint: child must be deleted before parent)
5126
5415
  if (await this.ProviderToUse.Delete(this, _options, this.ActiveUser)) {
5416
+ ownRowDeleted = true;
5127
5417
  // IS-A: after own delete succeeds, cascade to parent chain
5128
5418
  if (hasParentChain) {
5129
5419
  // For overlapping subtypes, check if other children still reference
@@ -5135,38 +5425,40 @@ export class BaseEntity {
5135
5425
  parentDeleteOptions.SkipEntityActions = _options.SkipEntityActions;
5136
5426
  parentDeleteOptions.ReplayOnly = _options.ReplayOnly;
5137
5427
  parentDeleteOptions.IsParentEntityDelete = true;
5138
- const parentResult = await this._parentEntity.Delete(parentDeleteOptions);
5428
+ // The parent's delete belongs to this chain's unit of work, so it doesn't
5429
+ // reset the parent when it returns: the unit does, once it commits.
5430
+ const parentResult = await BaseEntity.inUnitOfWork(this._parentEntity, unit, parent => parent.Delete(parentDeleteOptions));
5139
5431
  if (!parentResult) {
5140
5432
  // Parent delete failed — rollback if we started the transaction
5141
5433
  await this.rollbackEntityTransactionScope();
5434
+ BaseEntity.settleRollback(part);
5142
5435
  // RECORD the failure on THIS entity's ResultHistory before returning —
5143
- // symmetric with the parent-SAVE-failure path in _InnerSave. Without this
5144
- // the caller gets `false` with LatestResult === null and an empty
5145
- // ResultHistory, because every result was written to the PARENT object,
5146
- // which callers have no reference to (`_parentEntity` is private). Note
5147
- // THIS entity's own row was already deleted successfully above; it is the
5148
- // parent-chain delete that failed and rolled the transaction back.
5149
- if (currentResultCount === this.ResultHistory.length) {
5150
- const parentLatest = this._parentEntity.LatestResult;
5151
- const parentErrors = parentLatest?.Errors ?? [];
5152
- // A failed parent commonly reports its detail ONLY in Errors, so fall
5153
- // back to the error text rather than a message that says nothing.
5154
- const detail = parentLatest?.Message ||
5155
- parentErrors.map(e => e?.Message ?? String(e)).filter(Boolean).join('; ') ||
5156
- 'no error detail was reported by the parent';
5157
- newResult.Success = false;
5158
- newResult.Type = 'delete';
5159
- newResult.Message =
5160
- `Failed to delete parent entity '${this._parentEntity.EntityInfo?.Name}': ${detail}`;
5161
- // Surface the parent's field-level errors so the caller can act on them.
5162
- newResult.Errors = parentErrors;
5163
- // When `detail` was built from `parentErrors` (no parent Message), `Message` already renders
5164
- // them — say so, or CompleteMessage repeats every one.
5165
- newResult.MessageIncludesErrors = !parentLatest?.Message && parentErrors.length > 0;
5166
- newResult.OriginalValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.OldValue }; });
5167
- newResult.EndedAt = new Date();
5168
- this.RegisterResultHistoryEntry(newResult);
5169
- }
5436
+ // symmetric with the parent-SAVE-failure path in _InnerSave. Every result
5437
+ // for the parent's failure was written to the PARENT object, which callers
5438
+ // have no reference to (`_parentEntity` is private). THIS entity's own row
5439
+ // was already deleted successfully above, and the only entry its history
5440
+ // gained is the provider's for that delete, which the rollback just undid.
5441
+ // So the failure is recorded whatever that entry says: skipping it when
5442
+ // the history had grown left the caller holding the provider's entry.
5443
+ const parentLatest = this._parentEntity.LatestResult;
5444
+ const parentErrors = parentLatest?.Errors ?? [];
5445
+ // A failed parent commonly reports its detail ONLY in Errors, so fall
5446
+ // back to the error text rather than a message that says nothing.
5447
+ const detail = parentLatest?.Message ||
5448
+ parentErrors.map(e => e?.Message ?? String(e)).filter(Boolean).join('; ') ||
5449
+ 'no error detail was reported by the parent';
5450
+ newResult.Success = false;
5451
+ newResult.Type = 'delete';
5452
+ newResult.Message =
5453
+ `Failed to delete parent entity '${this._parentEntity.EntityInfo?.Name}': ${detail}`;
5454
+ // Surface the parent's field-level errors so the caller can act on them.
5455
+ newResult.Errors = parentErrors;
5456
+ // When `detail` was built from `parentErrors` (no parent Message), `Message` already renders
5457
+ // them — say so, or CompleteMessage repeats every one.
5458
+ newResult.MessageIncludesErrors = !parentLatest?.Message && parentErrors.length > 0;
5459
+ newResult.OriginalValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.OldValue }; });
5460
+ newResult.EndedAt = new Date();
5461
+ this.RegisterResultHistoryEntry(newResult);
5170
5462
  return false;
5171
5463
  }
5172
5464
  }
@@ -5177,8 +5469,16 @@ export class BaseEntity {
5177
5469
  // NOT part of a transaction - raise event immediately
5178
5470
  // record deleted correctly
5179
5471
  this.RaiseEvent('delete', { OldValues: oldVals });
5180
- // wipe out the current data to flush out the DIRTY flags by calling NewRecord()
5181
- this.NewRecord(); // will trigger a new record event here too
5472
+ if (unit) {
5473
+ // Deleted inside a unit of work, which resets the record once it commits:
5474
+ // now, when this delete owns the unit and has just committed it.
5475
+ unit.DeletedRecords.push(this);
5476
+ BaseEntity.settleCommit(part);
5477
+ }
5478
+ else {
5479
+ // wipe out the current data to flush out the DIRTY flags by calling NewRecord()
5480
+ this.NewRecord(); // will trigger a new record event here too
5481
+ }
5182
5482
  }
5183
5483
  else {
5184
5484
  // part of a transaction, wait for the transaction to submit successfully and then
@@ -5221,6 +5521,7 @@ export class BaseEntity {
5221
5521
  // every subsequent "committed" write on this provider silently never commits.
5222
5522
  // (Also: don't wipe out the entity like we do when the Delete() worked.)
5223
5523
  await this.rollbackEntityTransactionScope();
5524
+ BaseEntity.settleRollback(part);
5224
5525
  return false;
5225
5526
  }
5226
5527
  }
@@ -5228,9 +5529,11 @@ export class BaseEntity {
5228
5529
  catch (e) {
5229
5530
  // Roll back the scope this entity opened, if any. No-op when it holds none.
5230
5531
  await this.rollbackEntityTransactionScope();
5231
- if (currentResultCount === this.ResultHistory.length) {
5232
- // this means that NO new results were added to the history anywhere
5233
- // so we need to add a new result to the history here
5532
+ BaseEntity.settleRollback(part);
5533
+ // Record the failure when nothing else did. Once this record's own row was deleted, its
5534
+ // history holds the provider's entry for that delete, which doesn't report this failure
5535
+ // (a failed commit, most often), so the failure is recorded then too.
5536
+ if (ownRowDeleted || currentResultCount === this.ResultHistory.length) {
5234
5537
  newResult.Success = false;
5235
5538
  newResult.Type = 'delete';
5236
5539
  newResult.Message = e.message || null;