@memberjunction/core 5.51.0 → 6.1.0-edge.1
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/LICENSE +7 -0
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +13 -2
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEngineRegistry.d.ts +14 -0
- package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
- package/dist/generic/baseEngineRegistry.js +32 -0
- package/dist/generic/baseEngineRegistry.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +329 -26
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +788 -79
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts +54 -17
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +133 -52
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityCompanion.d.ts +218 -0
- package/dist/generic/entityCompanion.d.ts.map +1 -0
- package/dist/generic/entityCompanion.js +170 -0
- package/dist/generic/entityCompanion.js.map +1 -0
- package/dist/generic/entityInfo.d.ts +146 -0
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +188 -0
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/entitySavePlan.d.ts +199 -0
- package/dist/generic/entitySavePlan.d.ts.map +1 -0
- package/dist/generic/entitySavePlan.js +213 -0
- package/dist/generic/entitySavePlan.js.map +1 -0
- package/dist/generic/entityTransactionScope.d.ts +125 -0
- package/dist/generic/entityTransactionScope.d.ts.map +1 -0
- package/dist/generic/entityTransactionScope.js +115 -0
- package/dist/generic/entityTransactionScope.js.map +1 -0
- package/dist/generic/interfaces.d.ts +93 -35
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js +27 -0
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/providerBase.d.ts +13 -0
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +64 -5
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/relatedRecordBatchLoader.d.ts +39 -0
- package/dist/generic/relatedRecordBatchLoader.d.ts.map +1 -0
- package/dist/generic/relatedRecordBatchLoader.js +154 -0
- package/dist/generic/relatedRecordBatchLoader.js.map +1 -0
- package/dist/generic/relatedRecordCollection.d.ts +578 -0
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -0
- package/dist/generic/relatedRecordCollection.js +1004 -0
- package/dist/generic/relatedRecordCollection.js.map +1 -0
- package/dist/generic/saveEntityGraphOperation.d.ts +148 -0
- package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -0
- package/dist/generic/saveEntityGraphOperation.js +157 -0
- package/dist/generic/saveEntityGraphOperation.js.map +1 -0
- package/dist/generic/securityInfo.d.ts +99 -1
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +88 -6
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/generic/telemetryManager.d.ts +21 -1
- package/dist/generic/telemetryManager.d.ts.map +1 -1
- package/dist/generic/telemetryManager.js +21 -6
- package/dist/generic/telemetryManager.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +31 -0
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js.map +1 -1
- package/package.json +13 -13
- package/readme.md +159 -1
- package/dist/generic/runReport.d.ts +0 -25
- package/dist/generic/runReport.d.ts.map +0 -1
- package/dist/generic/runReport.js +0 -38
- package/dist/generic/runReport.js.map +0 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview `EntitySavePlan` — the ordered unit of work produced by an entity and its
|
|
3
|
+
* companions, and the local executor that runs it inside a single provider transaction.
|
|
4
|
+
*
|
|
5
|
+
* ## Why a plan rather than direct recursion
|
|
6
|
+
*
|
|
7
|
+
* Making the work explicit before executing any of it buys three things that matter:
|
|
8
|
+
*
|
|
9
|
+
* 1. **Validation can see the whole graph.** Cross-child invariants ("debits must equal credits")
|
|
10
|
+
* have to be checked against the complete set — including pending removals — *before* the first
|
|
11
|
+
* row is written. A plan is the natural place to assert that.
|
|
12
|
+
* 2. **One transaction decision.** The plan is built, then executed. The executor opens exactly one
|
|
13
|
+
* scope for the whole graph instead of each level guessing whether it should start one.
|
|
14
|
+
* 3. **The remote path becomes trivial.** A plan is data. The client serializes the graph, the
|
|
15
|
+
* server rebuilds it and runs the *same* executor. There is one cascade implementation, placed
|
|
16
|
+
* in one of two locations — never two implementations to keep in sync.
|
|
17
|
+
*
|
|
18
|
+
* ## Platform guarantees are preserved by construction
|
|
19
|
+
*
|
|
20
|
+
* Every node is persisted by calling that record's own `BaseEntity.Save()` / `.Delete()`. Nothing
|
|
21
|
+
* is written with direct SQL. Record Changes, entity actions, field validation, subclass `Save`
|
|
22
|
+
* overrides, `PreSave` data hooks, `save_started` / `save` / `delete` events and cache invalidation
|
|
23
|
+
* therefore all fire per node exactly as they do for a standalone save — with no graph-specific
|
|
24
|
+
* plumbing and no risk of the graph path quietly skipping a guarantee the single-record path has.
|
|
25
|
+
*
|
|
26
|
+
* @module @memberjunction/core
|
|
27
|
+
*/
|
|
28
|
+
import type { BaseEntity } from './baseEntity.js';
|
|
29
|
+
import type { EntitySaveOptions, EntityDeleteOptions } from './interfaces.js';
|
|
30
|
+
/**
|
|
31
|
+
* What a plan node does to its record.
|
|
32
|
+
*/
|
|
33
|
+
export type EntitySavePlanOperation = 'Save' | 'Delete';
|
|
34
|
+
/**
|
|
35
|
+
* A single unit of work within an {@link EntitySavePlan}.
|
|
36
|
+
*/
|
|
37
|
+
export type EntitySavePlanNode = {
|
|
38
|
+
/** The record to operate on. */
|
|
39
|
+
Entity: BaseEntity;
|
|
40
|
+
/** Whether this node saves or deletes its record. */
|
|
41
|
+
Operation: EntitySavePlanOperation;
|
|
42
|
+
/**
|
|
43
|
+
* Human-readable origin of this node, used in error messages so a failure names the collection
|
|
44
|
+
* it came from rather than just an entity name (e.g. `Lines[3]`).
|
|
45
|
+
*/
|
|
46
|
+
Label: string;
|
|
47
|
+
/**
|
|
48
|
+
* Applied to the record immediately before the node executes.
|
|
49
|
+
*
|
|
50
|
+
* This is how a child collection stamps the parent's freshly-assigned primary key onto each
|
|
51
|
+
* child's foreign key: the parent node runs first, so by the time this callback fires the key
|
|
52
|
+
* exists. Deferring it to execution time — rather than setting it when the plan is built — is
|
|
53
|
+
* what makes creating a parent and its children in one call work.
|
|
54
|
+
*/
|
|
55
|
+
Prepare?: () => void;
|
|
56
|
+
/**
|
|
57
|
+
* When true, this node executes the record's **own** save/delete only, without letting that
|
|
58
|
+
* record build and run a graph of its own.
|
|
59
|
+
*
|
|
60
|
+
* Set on the root node — the record whose `Save()` produced this plan. Without it the root
|
|
61
|
+
* would re-enter graph planning from inside its own graph execution, recursing forever, and
|
|
62
|
+
* would deadlock on its own in-flight save debounce.
|
|
63
|
+
*
|
|
64
|
+
* Deliberately **not** set on child nodes: a child that declares companions of its own must
|
|
65
|
+
* build and run its own sub-graph, which is what makes nesting (payment → line → allocation)
|
|
66
|
+
* work.
|
|
67
|
+
*/
|
|
68
|
+
SelfOnly?: boolean;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* The outcome of executing one plan node.
|
|
72
|
+
*/
|
|
73
|
+
export type EntitySavePlanNodeResult = {
|
|
74
|
+
/** The node that ran. */
|
|
75
|
+
Node: EntitySavePlanNode;
|
|
76
|
+
/** Whether the record's own Save()/Delete() reported success. */
|
|
77
|
+
Success: boolean;
|
|
78
|
+
/** The failure detail, when `Success` is false. */
|
|
79
|
+
ErrorMessage?: string;
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* The outcome of executing a whole plan.
|
|
83
|
+
*/
|
|
84
|
+
export type EntitySavePlanResult = {
|
|
85
|
+
/** True only when every node succeeded and the transaction committed. */
|
|
86
|
+
Success: boolean;
|
|
87
|
+
/** Per-node outcomes, in execution order. */
|
|
88
|
+
NodeResults: EntitySavePlanNodeResult[];
|
|
89
|
+
/** The first failure's message, hoisted for convenience. */
|
|
90
|
+
ErrorMessage?: string;
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* An ordered set of record operations that must succeed or fail together.
|
|
94
|
+
*
|
|
95
|
+
* Built by `BaseEntity.BuildSavePlan()` / `BuildDeletePlan()`, which seed the root node and then let
|
|
96
|
+
* each companion contribute via `EntityCompanion.ContributeSaveWork()` /
|
|
97
|
+
* `ContributeDeleteWork()`.
|
|
98
|
+
*
|
|
99
|
+
* @remarks
|
|
100
|
+
* Ordering is explicit and positional — nodes execute in the order they were added. Companions that
|
|
101
|
+
* need their work to run before the parent (deletions of children, for instance) add it before the
|
|
102
|
+
* parent node exists, which is why `ContributeDeleteWork` is called first on the delete path and
|
|
103
|
+
* `ContributeSaveWork` last on the save path.
|
|
104
|
+
*/
|
|
105
|
+
export declare class EntitySavePlan {
|
|
106
|
+
private nodes;
|
|
107
|
+
/**
|
|
108
|
+
* The record the plan is rooted at — the entity whose `Save()` / `Delete()` was called.
|
|
109
|
+
*/
|
|
110
|
+
readonly Root: BaseEntity;
|
|
111
|
+
/**
|
|
112
|
+
* @param root - The record this plan is rooted at.
|
|
113
|
+
*/
|
|
114
|
+
constructor(root: BaseEntity);
|
|
115
|
+
/**
|
|
116
|
+
* The nodes in execution order.
|
|
117
|
+
*/
|
|
118
|
+
get Nodes(): readonly EntitySavePlanNode[];
|
|
119
|
+
/**
|
|
120
|
+
* How many operations this plan will perform.
|
|
121
|
+
*
|
|
122
|
+
* A count of 1 means the plan is just the root record, and `BaseEntity` takes its ordinary
|
|
123
|
+
* single-record path — no plan execution, no transaction scope, byte-for-byte the behavior
|
|
124
|
+
* that existed before companions. This is what keeps the overwhelmingly common case free of
|
|
125
|
+
* any new cost or risk.
|
|
126
|
+
*/
|
|
127
|
+
get NodeCount(): number;
|
|
128
|
+
/**
|
|
129
|
+
* Appends a node to the plan.
|
|
130
|
+
*
|
|
131
|
+
* @param node - The unit of work to append.
|
|
132
|
+
* @returns This plan, for chaining.
|
|
133
|
+
*/
|
|
134
|
+
Add(node: EntitySavePlanNode): EntitySavePlan;
|
|
135
|
+
/**
|
|
136
|
+
* Convenience wrapper over {@link Add} for a save node.
|
|
137
|
+
*
|
|
138
|
+
* @param entity - The record to save.
|
|
139
|
+
* @param label - Origin label used in error messages.
|
|
140
|
+
* @param prepare - Optional callback applied immediately before the node executes.
|
|
141
|
+
* @returns This plan, for chaining.
|
|
142
|
+
*/
|
|
143
|
+
AddSave(entity: BaseEntity, label: string, prepare?: () => void, selfOnly?: boolean): EntitySavePlan;
|
|
144
|
+
/**
|
|
145
|
+
* Convenience wrapper over {@link Add} for a delete node.
|
|
146
|
+
*
|
|
147
|
+
* @param entity - The record to delete.
|
|
148
|
+
* @param label - Origin label used in error messages.
|
|
149
|
+
* @returns This plan, for chaining.
|
|
150
|
+
*/
|
|
151
|
+
AddDelete(entity: BaseEntity, label: string): EntitySavePlan;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Per-node option sets used when executing a plan.
|
|
155
|
+
*
|
|
156
|
+
* The root node needs its own variants carrying the `IsGraphNodeSave` / `IsGraphNodeDelete` flag,
|
|
157
|
+
* which prevents it from re-entering graph planning and bypasses its in-flight save debounce.
|
|
158
|
+
* `BaseEntity` constructs all four, because it already holds the option classes as values —
|
|
159
|
+
* building them here would force a runtime import of `interfaces.ts` and close an import cycle for
|
|
160
|
+
* no benefit.
|
|
161
|
+
*/
|
|
162
|
+
export type EntitySavePlanExecuteOptions = {
|
|
163
|
+
/** Options for non-root save nodes. */
|
|
164
|
+
SaveOptions?: EntitySaveOptions;
|
|
165
|
+
/** Options for the root save node — must carry `IsGraphNodeSave: true`. */
|
|
166
|
+
RootSaveOptions?: EntitySaveOptions;
|
|
167
|
+
/** Options for non-root delete nodes. */
|
|
168
|
+
DeleteOptions?: EntityDeleteOptions;
|
|
169
|
+
/** Options for the root delete node — must carry `IsGraphNodeDelete: true`. */
|
|
170
|
+
RootDeleteOptions?: EntityDeleteOptions;
|
|
171
|
+
/**
|
|
172
|
+
* Keys of the records already being persisted higher up in this unit of work — the cycle guard.
|
|
173
|
+
*
|
|
174
|
+
* A child node runs the child's own `Save()`, which builds and executes the child's own plan.
|
|
175
|
+
* That is what makes nesting work (a payment's line's allocations all land in one transaction),
|
|
176
|
+
* but on a **self-referential** collection it is also what makes a cycle fatal: declare
|
|
177
|
+
* `SubAgents` on `MJ: AI Agents` via `ParentID`, then wire `a.SubAgents.Add(b)` and
|
|
178
|
+
* `b.SubAgents.Add(a)`, and the recursion only ends when the call stack does.
|
|
179
|
+
*
|
|
180
|
+
* The set is threaded through the options rather than held in a module-scoped variable
|
|
181
|
+
* deliberately. A process-global would be shared by every concurrent save in the process, so
|
|
182
|
+
* two unrelated requests saving the *same* record at the same time would report a cycle that
|
|
183
|
+
* does not exist. Carried on the options, its lifetime is exactly one unit of work.
|
|
184
|
+
*/
|
|
185
|
+
Visited?: Set<string>;
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* Executes a plan's nodes in order, stopping at the first failure.
|
|
189
|
+
*
|
|
190
|
+
* This function does **not** manage the transaction — the caller owns that, because the caller is
|
|
191
|
+
* the only one that knows whether the graph is the outermost unit of work or nested inside a larger
|
|
192
|
+
* one. See `BaseEntity.executeGraphLocal`.
|
|
193
|
+
*
|
|
194
|
+
* @param plan - The plan to execute.
|
|
195
|
+
* @param options - Per-node option sets.
|
|
196
|
+
* @returns The per-node outcomes and an overall success flag.
|
|
197
|
+
*/
|
|
198
|
+
export declare function ExecuteEntitySavePlan(plan: EntitySavePlan, options?: EntitySavePlanExecuteOptions): Promise<EntitySavePlanResult>;
|
|
199
|
+
//# sourceMappingURL=entitySavePlan.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entitySavePlan.d.ts","sourceRoot":"","sources":["../../src/generic/entitySavePlan.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,KAAK,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAG3E;;GAEG;AACH,MAAM,MAAM,uBAAuB,GAAG,MAAM,GAAG,QAAQ,CAAC;AAExD;;GAEG;AACH,MAAM,MAAM,kBAAkB,GAAG;IAC7B,gCAAgC;IAChC,MAAM,EAAE,UAAU,CAAC;IACnB,qDAAqD;IACrD,SAAS,EAAE,uBAAuB,CAAC;IACnC;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACtB,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,wBAAwB,GAAG;IACnC,yBAAyB;IACzB,IAAI,EAAE,kBAAkB,CAAC;IACzB,iEAAiE;IACjE,OAAO,EAAE,OAAO,CAAC;IACjB,mDAAmD;IACnD,YAAY,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,oBAAoB,GAAG;IAC/B,yEAAyE;IACzE,OAAO,EAAE,OAAO,CAAC;IACjB,6CAA6C;IAC7C,WAAW,EAAE,wBAAwB,EAAE,CAAC;IACxC,4DAA4D;IAC5D,YAAY,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,qBAAa,cAAc;IACvB,OAAO,CAAC,KAAK,CAA4B;IAEzC;;OAEG;IACH,SAAgB,IAAI,EAAE,UAAU,CAAC;IAEjC;;OAEG;gBACS,IAAI,EAAE,UAAU;IAI5B;;OAEG;IACH,IAAW,KAAK,IAAI,SAAS,kBAAkB,EAAE,CAEhD;IAED;;;;;;;OAOG;IACH,IAAW,SAAS,IAAI,MAAM,CAE7B;IAED;;;;;OAKG;IACI,GAAG,CAAC,IAAI,EAAE,kBAAkB,GAAG,cAAc;IAKpD;;;;;;;OAOG;IACI,OAAO,CAAC,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,IAAI,EAAE,QAAQ,UAAQ,GAAG,cAAc;IAIzG;;;;;;OAMG;IACI,SAAS,CAAC,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,cAAc;CAGtE;AAED;;;;;;;;GAQG;AACH,MAAM,MAAM,4BAA4B,GAAG;IACvC,uCAAuC;IACvC,WAAW,CAAC,EAAE,iBAAiB,CAAC;IAChC,2EAA2E;IAC3E,eAAe,CAAC,EAAE,iBAAiB,CAAC;IACpC,yCAAyC;IACzC,aAAa,CAAC,EAAE,mBAAmB,CAAC;IACpC,+EAA+E;IAC/E,iBAAiB,CAAC,EAAE,mBAAmB,CAAC;IACxC;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACzB,CAAC;AAkBF;;;;;;;;;;GAUG;AACH,wBAAsB,qBAAqB,CACvC,IAAI,EAAE,cAAc,EACpB,OAAO,GAAE,4BAAiC,GAC3C,OAAO,CAAC,oBAAoB,CAAC,CAsD/B"}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview `EntitySavePlan` — the ordered unit of work produced by an entity and its
|
|
3
|
+
* companions, and the local executor that runs it inside a single provider transaction.
|
|
4
|
+
*
|
|
5
|
+
* ## Why a plan rather than direct recursion
|
|
6
|
+
*
|
|
7
|
+
* Making the work explicit before executing any of it buys three things that matter:
|
|
8
|
+
*
|
|
9
|
+
* 1. **Validation can see the whole graph.** Cross-child invariants ("debits must equal credits")
|
|
10
|
+
* have to be checked against the complete set — including pending removals — *before* the first
|
|
11
|
+
* row is written. A plan is the natural place to assert that.
|
|
12
|
+
* 2. **One transaction decision.** The plan is built, then executed. The executor opens exactly one
|
|
13
|
+
* scope for the whole graph instead of each level guessing whether it should start one.
|
|
14
|
+
* 3. **The remote path becomes trivial.** A plan is data. The client serializes the graph, the
|
|
15
|
+
* server rebuilds it and runs the *same* executor. There is one cascade implementation, placed
|
|
16
|
+
* in one of two locations — never two implementations to keep in sync.
|
|
17
|
+
*
|
|
18
|
+
* ## Platform guarantees are preserved by construction
|
|
19
|
+
*
|
|
20
|
+
* Every node is persisted by calling that record's own `BaseEntity.Save()` / `.Delete()`. Nothing
|
|
21
|
+
* is written with direct SQL. Record Changes, entity actions, field validation, subclass `Save`
|
|
22
|
+
* overrides, `PreSave` data hooks, `save_started` / `save` / `delete` events and cache invalidation
|
|
23
|
+
* therefore all fire per node exactly as they do for a standalone save — with no graph-specific
|
|
24
|
+
* plumbing and no risk of the graph path quietly skipping a guarantee the single-record path has.
|
|
25
|
+
*
|
|
26
|
+
* @module @memberjunction/core
|
|
27
|
+
*/
|
|
28
|
+
import { LogError } from './logging.js';
|
|
29
|
+
/**
|
|
30
|
+
* An ordered set of record operations that must succeed or fail together.
|
|
31
|
+
*
|
|
32
|
+
* Built by `BaseEntity.BuildSavePlan()` / `BuildDeletePlan()`, which seed the root node and then let
|
|
33
|
+
* each companion contribute via `EntityCompanion.ContributeSaveWork()` /
|
|
34
|
+
* `ContributeDeleteWork()`.
|
|
35
|
+
*
|
|
36
|
+
* @remarks
|
|
37
|
+
* Ordering is explicit and positional — nodes execute in the order they were added. Companions that
|
|
38
|
+
* need their work to run before the parent (deletions of children, for instance) add it before the
|
|
39
|
+
* parent node exists, which is why `ContributeDeleteWork` is called first on the delete path and
|
|
40
|
+
* `ContributeSaveWork` last on the save path.
|
|
41
|
+
*/
|
|
42
|
+
export class EntitySavePlan {
|
|
43
|
+
/**
|
|
44
|
+
* @param root - The record this plan is rooted at.
|
|
45
|
+
*/
|
|
46
|
+
constructor(root) {
|
|
47
|
+
this.nodes = [];
|
|
48
|
+
this.Root = root;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The nodes in execution order.
|
|
52
|
+
*/
|
|
53
|
+
get Nodes() {
|
|
54
|
+
return this.nodes;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* How many operations this plan will perform.
|
|
58
|
+
*
|
|
59
|
+
* A count of 1 means the plan is just the root record, and `BaseEntity` takes its ordinary
|
|
60
|
+
* single-record path — no plan execution, no transaction scope, byte-for-byte the behavior
|
|
61
|
+
* that existed before companions. This is what keeps the overwhelmingly common case free of
|
|
62
|
+
* any new cost or risk.
|
|
63
|
+
*/
|
|
64
|
+
get NodeCount() {
|
|
65
|
+
return this.nodes.length;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Appends a node to the plan.
|
|
69
|
+
*
|
|
70
|
+
* @param node - The unit of work to append.
|
|
71
|
+
* @returns This plan, for chaining.
|
|
72
|
+
*/
|
|
73
|
+
Add(node) {
|
|
74
|
+
this.nodes.push(node);
|
|
75
|
+
return this;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Convenience wrapper over {@link Add} for a save node.
|
|
79
|
+
*
|
|
80
|
+
* @param entity - The record to save.
|
|
81
|
+
* @param label - Origin label used in error messages.
|
|
82
|
+
* @param prepare - Optional callback applied immediately before the node executes.
|
|
83
|
+
* @returns This plan, for chaining.
|
|
84
|
+
*/
|
|
85
|
+
AddSave(entity, label, prepare, selfOnly = false) {
|
|
86
|
+
return this.Add({ Entity: entity, Operation: 'Save', Label: label, Prepare: prepare, SelfOnly: selfOnly });
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Convenience wrapper over {@link Add} for a delete node.
|
|
90
|
+
*
|
|
91
|
+
* @param entity - The record to delete.
|
|
92
|
+
* @param label - Origin label used in error messages.
|
|
93
|
+
* @returns This plan, for chaining.
|
|
94
|
+
*/
|
|
95
|
+
AddDelete(entity, label) {
|
|
96
|
+
return this.Add({ Entity: entity, Operation: 'Delete', Label: label });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Stable identity for cycle detection: the entity plus its primary key.
|
|
101
|
+
*
|
|
102
|
+
* Object identity is not enough — the same row can be represented by two different `BaseEntity`
|
|
103
|
+
* instances within one graph, which is precisely the shape a cycle takes after a round trip.
|
|
104
|
+
*
|
|
105
|
+
* @param entity - The record to key.
|
|
106
|
+
* @returns The key, or `null` for a record with no primary-key value yet (a brand-new record cannot
|
|
107
|
+
* be its own ancestor, so it needs no guard).
|
|
108
|
+
*/
|
|
109
|
+
function GraphNodeKey(entity) {
|
|
110
|
+
const entityName = entity.EntityInfo?.Name;
|
|
111
|
+
const key = entity.PrimaryKey?.ToString();
|
|
112
|
+
return entityName && key ? `${entityName}|${key}` : null;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Executes a plan's nodes in order, stopping at the first failure.
|
|
116
|
+
*
|
|
117
|
+
* This function does **not** manage the transaction — the caller owns that, because the caller is
|
|
118
|
+
* the only one that knows whether the graph is the outermost unit of work or nested inside a larger
|
|
119
|
+
* one. See `BaseEntity.executeGraphLocal`.
|
|
120
|
+
*
|
|
121
|
+
* @param plan - The plan to execute.
|
|
122
|
+
* @param options - Per-node option sets.
|
|
123
|
+
* @returns The per-node outcomes and an overall success flag.
|
|
124
|
+
*/
|
|
125
|
+
export async function ExecuteEntitySavePlan(plan, options = {}) {
|
|
126
|
+
const nodeResults = [];
|
|
127
|
+
const visited = options.Visited ?? new Set();
|
|
128
|
+
// The root is an ancestor of everything this plan will run, so it goes in before the loop.
|
|
129
|
+
// Its own node is `SelfOnly` and therefore exempt from the check below — it cannot recurse.
|
|
130
|
+
const rootKey = GraphNodeKey(plan.Root);
|
|
131
|
+
const rootWasAlreadyVisited = rootKey !== null && visited.has(rootKey);
|
|
132
|
+
if (rootKey && !rootWasAlreadyVisited) {
|
|
133
|
+
visited.add(rootKey);
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
for (const node of plan.Nodes) {
|
|
137
|
+
// A child node re-entering a record already in progress above it is a cycle. Detect it
|
|
138
|
+
// here rather than letting the recursion run until the stack overflows, which surfaces
|
|
139
|
+
// as an unattributable crash rather than a fixable message.
|
|
140
|
+
if (!node.SelfOnly) {
|
|
141
|
+
const key = GraphNodeKey(node.Entity);
|
|
142
|
+
if (key && visited.has(key)) {
|
|
143
|
+
const message = `Cycle detected in the entity graph at ${node.Label} (${key}): this record is already ` +
|
|
144
|
+
`being saved higher up in the same unit of work. A self-referential related-record ` +
|
|
145
|
+
`collection cannot contain one of its own ancestors.`;
|
|
146
|
+
LogError(message);
|
|
147
|
+
return { Success: false, NodeResults: nodeResults, ErrorMessage: message };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Late-bind anything that depends on values produced by earlier nodes — most importantly
|
|
151
|
+
// a child's foreign key, which cannot exist until the parent row has been inserted.
|
|
152
|
+
if (node.Prepare) {
|
|
153
|
+
node.Prepare();
|
|
154
|
+
}
|
|
155
|
+
const outcome = await executePlanNode(node, { ...options, Visited: visited });
|
|
156
|
+
nodeResults.push(outcome);
|
|
157
|
+
if (!outcome.Success) {
|
|
158
|
+
// Stop immediately. The caller rolls the transaction back, so continuing would only
|
|
159
|
+
// pile up work that is about to be undone — and would let a later, more confusing
|
|
160
|
+
// failure mask the real one.
|
|
161
|
+
return { Success: false, NodeResults: nodeResults, ErrorMessage: outcome.ErrorMessage };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return { Success: true, NodeResults: nodeResults };
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
// Leave the set exactly as it was found, so sibling branches of the same graph are not
|
|
168
|
+
// poisoned by an ancestor this branch happened to add.
|
|
169
|
+
if (rootKey && !rootWasAlreadyVisited) {
|
|
170
|
+
visited.delete(rootKey);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Runs a single plan node and normalizes its outcome.
|
|
176
|
+
*
|
|
177
|
+
* `BaseEntity.Save()` / `.Delete()` signal logical failure by returning `false` rather than
|
|
178
|
+
* throwing, so both shapes have to be handled: a `false` return and a genuine exception.
|
|
179
|
+
*
|
|
180
|
+
* @param node - The node to run.
|
|
181
|
+
* @param saveOptions - Options for a save node.
|
|
182
|
+
* @param deleteOptions - Options for a delete node.
|
|
183
|
+
* @returns The node outcome.
|
|
184
|
+
*/
|
|
185
|
+
async function executePlanNode(node, options) {
|
|
186
|
+
try {
|
|
187
|
+
// The root node runs "self only": it must not re-enter graph planning from inside the graph
|
|
188
|
+
// it is already executing, and it must bypass its own in-flight save debounce. Child nodes
|
|
189
|
+
// get the ordinary options, so a child with companions of its own runs its own sub-graph.
|
|
190
|
+
const ok = node.Operation === 'Save'
|
|
191
|
+
? await node.Entity.Save(node.SelfOnly ? options.RootSaveOptions : options.SaveOptions)
|
|
192
|
+
: await node.Entity.Delete(node.SelfOnly ? options.RootDeleteOptions : options.DeleteOptions);
|
|
193
|
+
if (ok) {
|
|
194
|
+
return { Node: node, Success: true };
|
|
195
|
+
}
|
|
196
|
+
const detail = node.Entity.LatestResult?.CompleteMessage ?? 'unknown error';
|
|
197
|
+
return {
|
|
198
|
+
Node: node,
|
|
199
|
+
Success: false,
|
|
200
|
+
ErrorMessage: `${node.Operation} failed for ${node.Label} (${node.Entity.EntityInfo?.Name}): ${detail}`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
catch (e) {
|
|
204
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
205
|
+
LogError(`EntitySavePlan node threw for ${node.Label}: ${detail}`);
|
|
206
|
+
return {
|
|
207
|
+
Node: node,
|
|
208
|
+
Success: false,
|
|
209
|
+
ErrorMessage: `${node.Operation} threw for ${node.Label} (${node.Entity.EntityInfo?.Name}): ${detail}`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
//# sourceMappingURL=entitySavePlan.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entitySavePlan.js","sourceRoot":"","sources":["../../src/generic/entitySavePlan.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAIH,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAoErC;;;;;;;;;;;;GAYG;AACH,MAAM,OAAO,cAAc;IAQvB;;OAEG;IACH,YAAY,IAAgB;QAVpB,UAAK,GAAyB,EAAE,CAAC;QAWrC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACrB,CAAC;IAED;;OAEG;IACH,IAAW,KAAK;QACZ,OAAO,IAAI,CAAC,KAAK,CAAC;IACtB,CAAC;IAED;;;;;;;OAOG;IACH,IAAW,SAAS;QAChB,OAAO,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC;IAC7B,CAAC;IAED;;;;;OAKG;IACI,GAAG,CAAC,IAAwB;QAC/B,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACtB,OAAO,IAAI,CAAC;IAChB,CAAC;IAED;;;;;;;OAOG;IACI,OAAO,CAAC,MAAkB,EAAE,KAAa,EAAE,OAAoB,EAAE,QAAQ,GAAG,KAAK;QACpF,OAAO,IAAI,CAAC,GAAG,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,CAAC,CAAC;IAC/G,CAAC;IAED;;;;;;OAMG;IACI,SAAS,CAAC,MAAkB,EAAE,KAAa;QAC9C,OAAO,IAAI,CAAC,GAAG,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;IAC3E,CAAC;CACJ;AAqCD;;;;;;;;;GASG;AACH,SAAS,YAAY,CAAC,MAAkB;IACpC,MAAM,UAAU,GAAG,MAAM,CAAC,UAAU,EAAE,IAAI,CAAC;IAC3C,MAAM,GAAG,GAAG,MAAM,CAAC,UAAU,EAAE,QAAQ,EAAE,CAAC;IAC1C,OAAO,UAAU,IAAI,GAAG,CAAC,CAAC,CAAC,GAAG,UAAU,IAAI,GAAG,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;AAC7D,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB,CACvC,IAAoB,EACpB,UAAwC,EAAE;IAE1C,MAAM,WAAW,GAA+B,EAAE,CAAC;IACnD,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,IAAI,GAAG,EAAU,CAAC;IAErD,2FAA2F;IAC3F,4FAA4F;IAC5F,MAAM,OAAO,GAAG,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACxC,MAAM,qBAAqB,GAAG,OAAO,KAAK,IAAI,IAAI,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IACvE,IAAI,OAAO,IAAI,CAAC,qBAAqB,EAAE,CAAC;QACpC,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IACzB,CAAC;IAED,IAAI,CAAC;QACD,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;YAC5B,uFAAuF;YACvF,uFAAuF;YACvF,4DAA4D;YAC5D,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACjB,MAAM,GAAG,GAAG,YAAY,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;gBACtC,IAAI,GAAG,IAAI,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;oBAC1B,MAAM,OAAO,GACT,yCAAyC,IAAI,CAAC,KAAK,KAAK,GAAG,4BAA4B;wBACvF,oFAAoF;wBACpF,qDAAqD,CAAC;oBAC1D,QAAQ,CAAC,OAAO,CAAC,CAAC;oBAClB,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,YAAY,EAAE,OAAO,EAAE,CAAC;gBAC/E,CAAC;YACL,CAAC;YAED,yFAAyF;YACzF,oFAAoF;YACpF,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;gBACf,IAAI,CAAC,OAAO,EAAE,CAAC;YACnB,CAAC;YAED,MAAM,OAAO,GAAG,MAAM,eAAe,CAAC,IAAI,EAAE,EAAE,GAAG,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,CAAC;YAC9E,WAAW,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;YAE1B,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;gBACnB,oFAAoF;gBACpF,kFAAkF;gBAClF,6BAA6B;gBAC7B,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,YAAY,EAAE,OAAO,CAAC,YAAY,EAAE,CAAC;YAC5F,CAAC;QACL,CAAC;QAED,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,WAAW,EAAE,CAAC;IACvD,CAAC;YAAS,CAAC;QACP,uFAAuF;QACvF,uDAAuD;QACvD,IAAI,OAAO,IAAI,CAAC,qBAAqB,EAAE,CAAC;YACpC,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;QAC5B,CAAC;IACL,CAAC;AACL,CAAC;AAED;;;;;;;;;;GAUG;AACH,KAAK,UAAU,eAAe,CAC1B,IAAwB,EACxB,OAAqC;IAErC,IAAI,CAAC;QACD,4FAA4F;QAC5F,2FAA2F;QAC3F,0FAA0F;QAC1F,MAAM,EAAE,GACJ,IAAI,CAAC,SAAS,KAAK,MAAM;YACrB,CAAC,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,OAAO,CAAC,WAAW,CAAC;YACvF,CAAC,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC;QAEtG,IAAI,EAAE,EAAE,CAAC;YACL,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QACzC,CAAC;QAED,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,YAAY,EAAE,eAAe,IAAI,eAAe,CAAC;QAC5E,OAAO;YACH,IAAI,EAAE,IAAI;YACV,OAAO,EAAE,KAAK;YACd,YAAY,EAAE,GAAG,IAAI,CAAC,SAAS,eAAe,IAAI,CAAC,KAAK,KAAK,IAAI,CAAC,MAAM,CAAC,UAAU,EAAE,IAAI,MAAM,MAAM,EAAE;SAC1G,CAAC;IACN,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACT,MAAM,MAAM,GAAG,CAAC,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QAC1D,QAAQ,CAAC,iCAAiC,IAAI,CAAC,KAAK,KAAK,MAAM,EAAE,CAAC,CAAC;QACnE,OAAO;YACH,IAAI,EAAE,IAAI;YACV,OAAO,EAAE,KAAK;YACd,YAAY,EAAE,GAAG,IAAI,CAAC,SAAS,cAAc,IAAI,CAAC,KAAK,KAAK,IAAI,CAAC,MAAM,CAAC,UAAU,EAAE,IAAI,MAAM,MAAM,EAAE;SACzG,CAAC;IACN,CAAC;AACL,CAAC"}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Provider-arbitrated transaction scopes for multi-record entity operations.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* Before this abstraction MemberJunction had **two mutually-blind transaction mechanisms**:
|
|
7
|
+
*
|
|
8
|
+
* 1. `DatabaseProviderBase.BeginTransaction()` / `CommitTransaction()` / `RollbackTransaction()` —
|
|
9
|
+
* a properly re-entrant ambient manager (depth counter, `SAVE TRANSACTION` savepoints at depth
|
|
10
|
+
* ≥ 2, serialization against an in-flight outermost begin). Application composite-save code
|
|
11
|
+
* (order headers + lines, journal entries + lines, payments + allocations) calls this directly.
|
|
12
|
+
* 2. `BeginISATransaction()` / `CommitISATransaction()` / `RollbackISATransaction()` — an
|
|
13
|
+
* IS-A-specific trio that opened a brand-new physical transaction on the pool with **no depth
|
|
14
|
+
* awareness at all**.
|
|
15
|
+
*
|
|
16
|
+
* Because neither knew about the other, an entity that hit both paths — an IS-A entity saved
|
|
17
|
+
* inside an application transaction, or a composite whose child is an IS-A leaf — wrote into *two
|
|
18
|
+
* independent physical transactions on the same connection pool*. Rolling one back left the other
|
|
19
|
+
* committed. A torn write, with no error raised.
|
|
20
|
+
*
|
|
21
|
+
* ## The fix: one arbiter, zero coupling between participants
|
|
22
|
+
*
|
|
23
|
+
* Every participant now asks for the same thing and never asks who else is in a transaction:
|
|
24
|
+
*
|
|
25
|
+
* ```typescript
|
|
26
|
+
* const scope = await provider.BeginEntityTransaction();
|
|
27
|
+
* try {
|
|
28
|
+
* // ...work...
|
|
29
|
+
* await scope.Commit();
|
|
30
|
+
* } catch (e) {
|
|
31
|
+
* await scope.Rollback();
|
|
32
|
+
* throw e;
|
|
33
|
+
* }
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* The **provider** decides whether that is a physical `BEGIN` or a join to a transaction already in
|
|
37
|
+
* flight. This deliberately *reduces* coupling: IS-A orchestration, composite graph saves and
|
|
38
|
+
* hand-written application code each remain ignorant of one another, and correctness no longer
|
|
39
|
+
* depends on that ignorance being harmless.
|
|
40
|
+
*
|
|
41
|
+
* ## Tier behaviour
|
|
42
|
+
*
|
|
43
|
+
* - **Server** (`DatabaseProviderBase` and subclasses): `SupportsEntityTransactions === true`;
|
|
44
|
+
* scopes map onto the depth-counted ambient transaction.
|
|
45
|
+
* - **Client** (`GraphQLDataProvider`): `SupportsEntityTransactions === false`; there is no local
|
|
46
|
+
* transaction to join. Callers that need atomicity across several records route the whole unit of
|
|
47
|
+
* work to the server instead — see `MJ.SaveEntityGraph` and
|
|
48
|
+
* `guides/TRANSACTIONS_AND_BATCHING_GUIDE.md`.
|
|
49
|
+
*
|
|
50
|
+
* ## Concurrency
|
|
51
|
+
*
|
|
52
|
+
* The underlying ambient transaction is a field on the **provider instance**, not a global. MJServer
|
|
53
|
+
* builds per-request providers (`createPerRequestProviders` in `packages/MJServer/src/context.ts`),
|
|
54
|
+
* so an ambient transaction is effectively request-scoped and concurrent requests cannot interleave
|
|
55
|
+
* within one. Long-lived single-provider processes (CLI tools, workers) should not run concurrent
|
|
56
|
+
* transactional work on one provider instance — that constraint predates this abstraction and is
|
|
57
|
+
* unchanged by it.
|
|
58
|
+
*
|
|
59
|
+
* @module @memberjunction/core
|
|
60
|
+
*/
|
|
61
|
+
/**
|
|
62
|
+
* A handle to a unit of work that will either commit or roll back as a whole.
|
|
63
|
+
*
|
|
64
|
+
* Obtained from {@link IMetadataProvider.BeginEntityTransaction}. The scope is **settle-once**:
|
|
65
|
+
* the first call to {@link EntityTransactionScope.Commit} or
|
|
66
|
+
* {@link EntityTransactionScope.Rollback} settles it and every later call is a no-op. That makes
|
|
67
|
+
* the standard `try { work; Commit() } catch { Rollback() }` shape safe even when the work itself
|
|
68
|
+
* already rolled back on the way out.
|
|
69
|
+
*
|
|
70
|
+
* @remarks
|
|
71
|
+
* A scope does **not** necessarily correspond to a physical database transaction. When one is
|
|
72
|
+
* already in flight on the provider, the scope joins it — committing the inner scope releases a
|
|
73
|
+
* savepoint rather than committing the outer transaction. Inspect {@link IsNested} to tell the two
|
|
74
|
+
* apart; correctness never requires you to.
|
|
75
|
+
*/
|
|
76
|
+
export interface EntityTransactionScope {
|
|
77
|
+
/**
|
|
78
|
+
* True when this scope joined a transaction that was already in flight on the provider, rather
|
|
79
|
+
* than starting a new physical one.
|
|
80
|
+
*
|
|
81
|
+
* Informational only — for logging and diagnostics. Commit/rollback behave correctly either
|
|
82
|
+
* way, and callers must not branch on this to decide whether to settle the scope.
|
|
83
|
+
*/
|
|
84
|
+
readonly IsNested: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Settles the scope successfully. On the outermost scope this commits the physical transaction;
|
|
87
|
+
* on a nested scope it releases the savepoint. No-op if the scope is already settled.
|
|
88
|
+
*/
|
|
89
|
+
Commit(): Promise<void>;
|
|
90
|
+
/**
|
|
91
|
+
* Settles the scope by undoing its work. On the outermost scope this rolls the physical
|
|
92
|
+
* transaction back; on a nested scope it rolls back to the savepoint. No-op if the scope is
|
|
93
|
+
* already settled.
|
|
94
|
+
*/
|
|
95
|
+
Rollback(): Promise<void>;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Runs `work` inside a provider-arbitrated transaction scope, committing on success and rolling
|
|
99
|
+
* back on any thrown error.
|
|
100
|
+
*
|
|
101
|
+
* This is the preferred entry point — it makes the settle-once contract impossible to get wrong and
|
|
102
|
+
* keeps the `try`/`catch` boilerplate in one place.
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* ```typescript
|
|
106
|
+
* const total = await RunInEntityTransaction(this.ProviderToUse, async () => {
|
|
107
|
+
* await header.Save();
|
|
108
|
+
* for (const line of lines) await line.Save();
|
|
109
|
+
* return lines.length;
|
|
110
|
+
* });
|
|
111
|
+
* ```
|
|
112
|
+
*
|
|
113
|
+
* @param provider - The provider to obtain the scope from. When it does not support entity
|
|
114
|
+
* transactions the work still runs, just without transactional guarantees —
|
|
115
|
+
* callers needing atomicity must check `SupportsEntityTransactions` first and
|
|
116
|
+
* route the unit of work to the server instead.
|
|
117
|
+
* @param work - The work to perform inside the scope.
|
|
118
|
+
* @returns Whatever `work` returns.
|
|
119
|
+
* @throws Re-throws whatever `work` throws, after rolling the scope back.
|
|
120
|
+
*/
|
|
121
|
+
export declare function RunInEntityTransaction<T>(provider: {
|
|
122
|
+
SupportsEntityTransactions?: boolean;
|
|
123
|
+
BeginEntityTransaction?(): Promise<EntityTransactionScope>;
|
|
124
|
+
} | null | undefined, work: () => Promise<T>): Promise<T>;
|
|
125
|
+
//# sourceMappingURL=entityTransactionScope.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entityTransactionScope.d.ts","sourceRoot":"","sources":["../../src/generic/entityTransactionScope.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2DG;AAIH;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,sBAAsB;IACnC;;;;;;OAMG;IACH,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAE3B;;;OAGG;IACH,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAExB;;;;OAIG;IACH,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7B;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAsB,sBAAsB,CAAC,CAAC,EAC1C,QAAQ,EAAE;IACN,0BAA0B,CAAC,EAAE,OAAO,CAAC;IACrC,sBAAsB,CAAC,IAAI,OAAO,CAAC,sBAAsB,CAAC,CAAC;CAC9D,GAAG,IAAI,GAAG,SAAS,EACpB,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACvB,OAAO,CAAC,CAAC,CAAC,CA6BZ"}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Provider-arbitrated transaction scopes for multi-record entity operations.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* Before this abstraction MemberJunction had **two mutually-blind transaction mechanisms**:
|
|
7
|
+
*
|
|
8
|
+
* 1. `DatabaseProviderBase.BeginTransaction()` / `CommitTransaction()` / `RollbackTransaction()` —
|
|
9
|
+
* a properly re-entrant ambient manager (depth counter, `SAVE TRANSACTION` savepoints at depth
|
|
10
|
+
* ≥ 2, serialization against an in-flight outermost begin). Application composite-save code
|
|
11
|
+
* (order headers + lines, journal entries + lines, payments + allocations) calls this directly.
|
|
12
|
+
* 2. `BeginISATransaction()` / `CommitISATransaction()` / `RollbackISATransaction()` — an
|
|
13
|
+
* IS-A-specific trio that opened a brand-new physical transaction on the pool with **no depth
|
|
14
|
+
* awareness at all**.
|
|
15
|
+
*
|
|
16
|
+
* Because neither knew about the other, an entity that hit both paths — an IS-A entity saved
|
|
17
|
+
* inside an application transaction, or a composite whose child is an IS-A leaf — wrote into *two
|
|
18
|
+
* independent physical transactions on the same connection pool*. Rolling one back left the other
|
|
19
|
+
* committed. A torn write, with no error raised.
|
|
20
|
+
*
|
|
21
|
+
* ## The fix: one arbiter, zero coupling between participants
|
|
22
|
+
*
|
|
23
|
+
* Every participant now asks for the same thing and never asks who else is in a transaction:
|
|
24
|
+
*
|
|
25
|
+
* ```typescript
|
|
26
|
+
* const scope = await provider.BeginEntityTransaction();
|
|
27
|
+
* try {
|
|
28
|
+
* // ...work...
|
|
29
|
+
* await scope.Commit();
|
|
30
|
+
* } catch (e) {
|
|
31
|
+
* await scope.Rollback();
|
|
32
|
+
* throw e;
|
|
33
|
+
* }
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* The **provider** decides whether that is a physical `BEGIN` or a join to a transaction already in
|
|
37
|
+
* flight. This deliberately *reduces* coupling: IS-A orchestration, composite graph saves and
|
|
38
|
+
* hand-written application code each remain ignorant of one another, and correctness no longer
|
|
39
|
+
* depends on that ignorance being harmless.
|
|
40
|
+
*
|
|
41
|
+
* ## Tier behaviour
|
|
42
|
+
*
|
|
43
|
+
* - **Server** (`DatabaseProviderBase` and subclasses): `SupportsEntityTransactions === true`;
|
|
44
|
+
* scopes map onto the depth-counted ambient transaction.
|
|
45
|
+
* - **Client** (`GraphQLDataProvider`): `SupportsEntityTransactions === false`; there is no local
|
|
46
|
+
* transaction to join. Callers that need atomicity across several records route the whole unit of
|
|
47
|
+
* work to the server instead — see `MJ.SaveEntityGraph` and
|
|
48
|
+
* `guides/TRANSACTIONS_AND_BATCHING_GUIDE.md`.
|
|
49
|
+
*
|
|
50
|
+
* ## Concurrency
|
|
51
|
+
*
|
|
52
|
+
* The underlying ambient transaction is a field on the **provider instance**, not a global. MJServer
|
|
53
|
+
* builds per-request providers (`createPerRequestProviders` in `packages/MJServer/src/context.ts`),
|
|
54
|
+
* so an ambient transaction is effectively request-scoped and concurrent requests cannot interleave
|
|
55
|
+
* within one. Long-lived single-provider processes (CLI tools, workers) should not run concurrent
|
|
56
|
+
* transactional work on one provider instance — that constraint predates this abstraction and is
|
|
57
|
+
* unchanged by it.
|
|
58
|
+
*
|
|
59
|
+
* @module @memberjunction/core
|
|
60
|
+
*/
|
|
61
|
+
import { LogError } from './logging.js';
|
|
62
|
+
/**
|
|
63
|
+
* Runs `work` inside a provider-arbitrated transaction scope, committing on success and rolling
|
|
64
|
+
* back on any thrown error.
|
|
65
|
+
*
|
|
66
|
+
* This is the preferred entry point — it makes the settle-once contract impossible to get wrong and
|
|
67
|
+
* keeps the `try`/`catch` boilerplate in one place.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```typescript
|
|
71
|
+
* const total = await RunInEntityTransaction(this.ProviderToUse, async () => {
|
|
72
|
+
* await header.Save();
|
|
73
|
+
* for (const line of lines) await line.Save();
|
|
74
|
+
* return lines.length;
|
|
75
|
+
* });
|
|
76
|
+
* ```
|
|
77
|
+
*
|
|
78
|
+
* @param provider - The provider to obtain the scope from. When it does not support entity
|
|
79
|
+
* transactions the work still runs, just without transactional guarantees —
|
|
80
|
+
* callers needing atomicity must check `SupportsEntityTransactions` first and
|
|
81
|
+
* route the unit of work to the server instead.
|
|
82
|
+
* @param work - The work to perform inside the scope.
|
|
83
|
+
* @returns Whatever `work` returns.
|
|
84
|
+
* @throws Re-throws whatever `work` throws, after rolling the scope back.
|
|
85
|
+
*/
|
|
86
|
+
export async function RunInEntityTransaction(provider, work) {
|
|
87
|
+
const canTransact = provider?.SupportsEntityTransactions === true && !!provider.BeginEntityTransaction;
|
|
88
|
+
if (!canTransact) {
|
|
89
|
+
// No local transaction available (client provider, or a provider that does not implement
|
|
90
|
+
// the capability). Run the work as-is — the caller is responsible for having decided that
|
|
91
|
+
// non-atomic execution is acceptable here, or for routing elsewhere.
|
|
92
|
+
return work();
|
|
93
|
+
}
|
|
94
|
+
const scope = await provider.BeginEntityTransaction();
|
|
95
|
+
try {
|
|
96
|
+
const result = await work();
|
|
97
|
+
await scope.Commit();
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
// Rollback failures are logged and swallowed so the CALLER'S error survives: this is the
|
|
102
|
+
// failure path, and a doomed transaction (savepoint rollback refused, connection gone)
|
|
103
|
+
// throwing here would replace the error that explains what actually went wrong with a
|
|
104
|
+
// secondary one that explains less. Mirrors BaseEntity's own scope helper.
|
|
105
|
+
try {
|
|
106
|
+
await scope.Rollback();
|
|
107
|
+
}
|
|
108
|
+
catch (rollbackError) {
|
|
109
|
+
LogError(`RunInEntityTransaction: rollback failed after the work threw — reporting the original error. ` +
|
|
110
|
+
`Rollback failure: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
|
|
111
|
+
}
|
|
112
|
+
throw e;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=entityTransactionScope.js.map
|