@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.
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +180 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts +28 -0
- package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts +76 -0
- package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts.map +1 -1
- package/dist/generic/baseEntity.d.ts +201 -11
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +392 -89
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts +13 -7
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +18 -9
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityConfiguration.d.ts +1 -0
- package/dist/generic/entityConfiguration.d.ts.map +1 -1
- package/dist/generic/entityConfiguration.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +28 -1
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +37 -0
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/interfaces.d.ts +12 -3
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/providerBase.d.ts +82 -1
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +154 -19
- package/dist/generic/providerBase.js.map +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2628
|
-
//
|
|
2629
|
-
//
|
|
2630
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2694
|
-
*
|
|
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
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
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
|
-
*
|
|
2807
|
+
* Puts back, after a rollback, what the unit of work changed in memory. See {@link restoreSnapshot}.
|
|
2705
2808
|
*/
|
|
2706
|
-
|
|
2707
|
-
for (const
|
|
2708
|
-
|
|
2809
|
+
static restoreChains(snapshots) {
|
|
2810
|
+
for (const snapshot of snapshots) {
|
|
2811
|
+
snapshot.entity.restoreSnapshot(snapshot);
|
|
2709
2812
|
}
|
|
2710
2813
|
}
|
|
2711
2814
|
/**
|
|
2712
|
-
*
|
|
2713
|
-
*
|
|
2714
|
-
*
|
|
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
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2831
|
+
restoreSnapshot(snapshot) {
|
|
2832
|
+
const finalized = snapshot.fields.some(({ field }) => this.GetFieldByName(field.Name) !== field);
|
|
2833
|
+
if (!finalized) {
|
|
2834
|
+
return;
|
|
2720
2835
|
}
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
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 —
|
|
4058
|
-
|
|
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.
|
|
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
|
|
4252
|
-
|
|
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
|
-
|
|
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.
|
|
5144
|
-
// the
|
|
5145
|
-
//
|
|
5146
|
-
//
|
|
5147
|
-
//
|
|
5148
|
-
//
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
|
|
5154
|
-
|
|
5155
|
-
|
|
5156
|
-
|
|
5157
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
|
|
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
|
-
|
|
5181
|
-
|
|
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
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
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;
|