@zudojs/transactions 1.0.0 → 1.1.0
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/README.md +9 -0
- package/dist/manager/manager.commit.js +9 -0
- package/dist/manager/manager.core.d.ts +7 -1
- package/dist/manager/manager.core.js +70 -13
- package/dist/manager/manager.propagation.js +4 -2
- package/dist/transaction/transaction.core.js +5 -1
- package/dist/transaction/transaction.internal.d.ts +20 -0
- package/dist/transaction/transaction.internal.js +16 -0
- package/dist/transaction/transaction.participant.js +20 -2
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -103,6 +103,15 @@ the transaction.
|
|
|
103
103
|
- Committing a transaction that is not active throws rather than silently
|
|
104
104
|
doing nothing. Only an already-committed transaction is a no-op.
|
|
105
105
|
- A `nested` transaction rolls back to its savepoint, never to the connection.
|
|
106
|
+
Savepoints are always created on the connection, including when the
|
|
107
|
+
enclosing scope is itself a savepoint or a participant.
|
|
108
|
+
- `begin()` and `run()` both honour `timeout`; completing a transaction through
|
|
109
|
+
`manager.commit()` / `manager.rollback()` releases its timer and registry entry.
|
|
110
|
+
- Failures thrown by `afterCommit` callbacks never undo the commit; they are
|
|
111
|
+
reported to `hooks.onError` as an `AggregateError`.
|
|
112
|
+
- `retry` replays only attempts that opened their own transaction. An attempt
|
|
113
|
+
that joined an enclosing transaction has marked it rollback-only and is not
|
|
114
|
+
replayed.
|
|
106
115
|
|
|
107
116
|
## Use Cases
|
|
108
117
|
|
|
@@ -110,6 +110,15 @@ export async function commitTransaction(transaction, adapter, hooks, emit = noop
|
|
|
110
110
|
throw new TransactionCommitError(transaction.id, error);
|
|
111
111
|
}
|
|
112
112
|
emit(TRANSACTION_EVENTS.COMMITTED, transaction);
|
|
113
|
+
// afterCommit callbacks that threw did not undo the commit, but they
|
|
114
|
+
// used to fail silently. Report them without changing the outcome.
|
|
115
|
+
const callbackErrors = internals(transaction)._drainCallbackErrors();
|
|
116
|
+
if (callbackErrors.length > 0 && hooks?.onError) {
|
|
117
|
+
await hooks.onError({
|
|
118
|
+
transaction,
|
|
119
|
+
error: new AggregateError(callbackErrors, "after-commit callback failures"),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
113
122
|
if (hooks?.afterCommit)
|
|
114
123
|
await hooks.afterCommit({ transaction });
|
|
115
124
|
}
|
|
@@ -41,7 +41,13 @@ export declare function createTransactionManager(options: TransactionManagerOpti
|
|
|
41
41
|
* participant marks the enclosing transaction rollback-only instead.
|
|
42
42
|
*/
|
|
43
43
|
run<T>(callback: (transaction: Transaction) => Promise<T>, opts?: TransactionOptions): Promise<T>;
|
|
44
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Commit a transaction. Participants and committed transactions are no-ops.
|
|
46
|
+
*
|
|
47
|
+
* Completing a transaction opened with `begin()` also releases its
|
|
48
|
+
* timeout timer and registry entry; both used to be released only by
|
|
49
|
+
* `run()`, so hand-managed transactions stayed in the registry forever.
|
|
50
|
+
*/
|
|
45
51
|
commit(transaction: Transaction): Promise<void>;
|
|
46
52
|
/** Roll back a transaction, or mark the joined transaction rollback-only. */
|
|
47
53
|
rollback(transaction: Transaction, reason?: unknown): Promise<void>;
|
|
@@ -5,11 +5,16 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { getDefaultContext } from "../context/context.core.js";
|
|
7
7
|
import { internals } from "../transaction/transaction.internal.js";
|
|
8
|
+
import { isTerminal } from "../transaction/transactionStateMachine.js";
|
|
8
9
|
import { TransactionRollbackError } from "../transactionErrors/transactionError.types.js";
|
|
9
10
|
import { commitTransaction, rollbackTransaction } from "./manager.commit.js";
|
|
10
11
|
import { resolvePropagation, suspendsTransaction, } from "./manager.propagation.js";
|
|
11
12
|
import { withRetry } from "./manager.retry.js";
|
|
12
13
|
import { createEmitter, TRANSACTION_EVENTS } from "./manager.events.js";
|
|
14
|
+
/** Whether a transaction can no longer change state. */
|
|
15
|
+
function isFinished(transaction) {
|
|
16
|
+
return isTerminal(transaction.state);
|
|
17
|
+
}
|
|
13
18
|
/**
|
|
14
19
|
* Create a transaction manager.
|
|
15
20
|
*/
|
|
@@ -17,17 +22,41 @@ export function createTransactionManager(options) {
|
|
|
17
22
|
const { adapter, hooks, registry } = options;
|
|
18
23
|
const context = options.context ?? getDefaultContext();
|
|
19
24
|
const emit = createEmitter(options.onEvent);
|
|
20
|
-
/**
|
|
25
|
+
/** Timers armed for owned transactions, cleared when they complete. */
|
|
26
|
+
const timers = new Map();
|
|
27
|
+
/** Whether the manager completes this handle (root or savepoint). */
|
|
28
|
+
function owns(transaction) {
|
|
29
|
+
return transaction.kind === "root" || transaction.kind === "savepoint";
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Arms the timeout for an owned transaction.
|
|
33
|
+
*
|
|
34
|
+
* `begin()` used to validate `timeout` against the adapter's capabilities
|
|
35
|
+
* and then ignore it — only `run()` armed a timer — so a transaction
|
|
36
|
+
* opened by hand never timed out.
|
|
37
|
+
*/
|
|
21
38
|
function armTimeout(transaction) {
|
|
22
39
|
const timeout = transaction.options.timeout;
|
|
23
40
|
if (!timeout || timeout <= 0)
|
|
24
|
-
return
|
|
41
|
+
return;
|
|
25
42
|
const timer = setTimeout(() => {
|
|
43
|
+
timers.delete(transaction.id);
|
|
26
44
|
internals(transaction)._markTimedOut();
|
|
27
45
|
transaction.markRollbackOnly("timeout");
|
|
28
46
|
emit(TRANSACTION_EVENTS.TIMED_OUT, transaction);
|
|
29
47
|
}, timeout);
|
|
30
|
-
|
|
48
|
+
timers.set(transaction.id, timer);
|
|
49
|
+
}
|
|
50
|
+
/** Clears the timer and registry entry of a completed owned transaction. */
|
|
51
|
+
function release(transaction) {
|
|
52
|
+
if (!owns(transaction))
|
|
53
|
+
return;
|
|
54
|
+
const timer = timers.get(transaction.id);
|
|
55
|
+
if (timer !== undefined) {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
timers.delete(transaction.id);
|
|
58
|
+
}
|
|
59
|
+
registry?.unregister(transaction.id);
|
|
31
60
|
}
|
|
32
61
|
return {
|
|
33
62
|
/**
|
|
@@ -49,8 +78,9 @@ export function createTransactionManager(options) {
|
|
|
49
78
|
hooks,
|
|
50
79
|
emit,
|
|
51
80
|
});
|
|
52
|
-
if (transaction
|
|
81
|
+
if (owns(transaction)) {
|
|
53
82
|
registry?.register(transaction);
|
|
83
|
+
armTimeout(transaction);
|
|
54
84
|
}
|
|
55
85
|
return transaction;
|
|
56
86
|
},
|
|
@@ -62,10 +92,21 @@ export function createTransactionManager(options) {
|
|
|
62
92
|
* participant marks the enclosing transaction rollback-only instead.
|
|
63
93
|
*/
|
|
64
94
|
async run(callback, opts) {
|
|
65
|
-
|
|
95
|
+
// A failed attempt that only JOINED an enclosing transaction has not
|
|
96
|
+
// been rolled back — it marked the enclosing transaction rollback-only
|
|
97
|
+
// — so replaying it would repeat its side effects inside a transaction
|
|
98
|
+
// that can no longer commit. Retry only attempts this call owned.
|
|
99
|
+
let joined = false;
|
|
100
|
+
const retry = opts?.retry;
|
|
101
|
+
const retryOptions = retry === undefined
|
|
102
|
+
? undefined
|
|
103
|
+
: {
|
|
104
|
+
...retry,
|
|
105
|
+
shouldRetry: (error, attempt) => !joined && (retry.shouldRetry?.(error, attempt) ?? true),
|
|
106
|
+
};
|
|
107
|
+
return withRetry(retryOptions, async () => {
|
|
66
108
|
const transaction = await this.begin(opts);
|
|
67
|
-
|
|
68
|
-
const disposeTimeout = owned ? armTimeout(transaction) : () => { };
|
|
109
|
+
joined = transaction.kind === "participant";
|
|
69
110
|
const body = async () => {
|
|
70
111
|
try {
|
|
71
112
|
const result = await callback(transaction);
|
|
@@ -87,9 +128,7 @@ export function createTransactionManager(options) {
|
|
|
87
128
|
throw error;
|
|
88
129
|
}
|
|
89
130
|
finally {
|
|
90
|
-
|
|
91
|
-
if (owned)
|
|
92
|
-
registry?.unregister(transaction.id);
|
|
131
|
+
release(transaction);
|
|
93
132
|
}
|
|
94
133
|
};
|
|
95
134
|
return transaction.kind === "none"
|
|
@@ -97,13 +136,31 @@ export function createTransactionManager(options) {
|
|
|
97
136
|
: context.run(transaction, body);
|
|
98
137
|
});
|
|
99
138
|
},
|
|
100
|
-
/**
|
|
139
|
+
/**
|
|
140
|
+
* Commit a transaction. Participants and committed transactions are no-ops.
|
|
141
|
+
*
|
|
142
|
+
* Completing a transaction opened with `begin()` also releases its
|
|
143
|
+
* timeout timer and registry entry; both used to be released only by
|
|
144
|
+
* `run()`, so hand-managed transactions stayed in the registry forever.
|
|
145
|
+
*/
|
|
101
146
|
async commit(transaction) {
|
|
102
|
-
|
|
147
|
+
try {
|
|
148
|
+
await commitTransaction(transaction, adapter, hooks, emit);
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
if (isFinished(transaction))
|
|
152
|
+
release(transaction);
|
|
153
|
+
}
|
|
103
154
|
},
|
|
104
155
|
/** Roll back a transaction, or mark the joined transaction rollback-only. */
|
|
105
156
|
async rollback(transaction, reason) {
|
|
106
|
-
|
|
157
|
+
try {
|
|
158
|
+
await rollbackTransaction(transaction, adapter, reason, hooks, emit);
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
if (isFinished(transaction))
|
|
162
|
+
release(transaction);
|
|
163
|
+
}
|
|
107
164
|
},
|
|
108
165
|
/** The transaction in scope for the current async execution, if any. */
|
|
109
166
|
getCurrent() {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { createTransaction } from "../transaction/transaction.core.js";
|
|
11
11
|
import { createNonTransactional, createParticipant, } from "../transaction/transaction.participant.js";
|
|
12
|
-
import { internals } from "../transaction/transaction.internal.js";
|
|
12
|
+
import { connectionHandle, internals, } from "../transaction/transaction.internal.js";
|
|
13
13
|
import { SavepointError, TransactionCapabilityError, TransactionPropagationError, } from "../transactionErrors/transactionError.types.js";
|
|
14
14
|
import { assertAdapterSupports } from "./manager.capabilities.js";
|
|
15
15
|
import { noopEmitter, TRANSACTION_EVENTS } from "./manager.events.js";
|
|
@@ -60,7 +60,9 @@ async function beginSavepoint(parent, context) {
|
|
|
60
60
|
if (hooks?.beforeBegin)
|
|
61
61
|
await hooks.beforeBegin({ transaction: child });
|
|
62
62
|
const savepoint = `sp_${child.id}`;
|
|
63
|
-
|
|
63
|
+
// Always the connection: a nested run inside another nested run must
|
|
64
|
+
// create its savepoint on the connection, not on the outer savepoint.
|
|
65
|
+
const parentHandle = connectionHandle(parent);
|
|
64
66
|
try {
|
|
65
67
|
await adapter.createSavepoint(parentHandle, savepoint);
|
|
66
68
|
internals(child)._setHandle({ parent: parentHandle, savepoint });
|
|
@@ -38,6 +38,7 @@ export function createTransaction(options = {}, parentId, kind = "root") {
|
|
|
38
38
|
let timedOut = false;
|
|
39
39
|
const afterCommitCallbacks = [];
|
|
40
40
|
const afterRollbackCallbacks = [];
|
|
41
|
+
let callbackErrors = [];
|
|
41
42
|
let handle;
|
|
42
43
|
const transition = createTransitionFunction(() => state, (next) => {
|
|
43
44
|
state = next;
|
|
@@ -91,7 +92,9 @@ export function createTransaction(options = {}, parentId, kind = "root") {
|
|
|
91
92
|
transition("committing");
|
|
92
93
|
transition("committed");
|
|
93
94
|
afterRollbackCallbacks.length = 0;
|
|
94
|
-
|
|
95
|
+
// The commit stands whatever the callbacks do; their failures are
|
|
96
|
+
// kept for the manager to report instead of being dropped.
|
|
97
|
+
callbackErrors = await runCallbacks(afterCommitCallbacks);
|
|
95
98
|
},
|
|
96
99
|
async rollback(reason) {
|
|
97
100
|
if (state === "rolled_back" || state === "failed")
|
|
@@ -137,6 +140,7 @@ export function createTransaction(options = {}, parentId, kind = "root") {
|
|
|
137
140
|
timedOut = true;
|
|
138
141
|
},
|
|
139
142
|
_getRollbackOnlyReason: () => rollbackOnlyReason,
|
|
143
|
+
_drainCallbackErrors: () => callbackErrors.splice(0),
|
|
140
144
|
});
|
|
141
145
|
}
|
|
142
146
|
//# sourceMappingURL=transaction.core.js.map
|
|
@@ -31,6 +31,14 @@ export interface TransactionInternals {
|
|
|
31
31
|
_markTimedOut(): void;
|
|
32
32
|
/** The reason supplied to `markRollbackOnly`, if any. */
|
|
33
33
|
_getRollbackOnlyReason(): unknown;
|
|
34
|
+
/**
|
|
35
|
+
* Take the failures collected from `afterCommit` callbacks.
|
|
36
|
+
*
|
|
37
|
+
* The transaction stays committed when a callback throws — nothing can
|
|
38
|
+
* undo the adapter commit — but the failures used to vanish without a
|
|
39
|
+
* trace. The manager drains them and reports them to `hooks.onError`.
|
|
40
|
+
*/
|
|
41
|
+
_drainCallbackErrors(): unknown[];
|
|
34
42
|
}
|
|
35
43
|
/**
|
|
36
44
|
* Access the manager-only surface of a transaction.
|
|
@@ -55,6 +63,18 @@ export interface SavepointHandle {
|
|
|
55
63
|
/** The savepoint name created on that handle. */
|
|
56
64
|
readonly savepoint: string;
|
|
57
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the adapter connection a transaction runs on.
|
|
68
|
+
*
|
|
69
|
+
* A savepoint's handle names its parent connection; a participant's handle
|
|
70
|
+
* is the joined transaction's. A savepoint opened inside another savepoint
|
|
71
|
+
* used to be created against the outer *savepoint handle* rather than the
|
|
72
|
+
* connection, which no adapter can act on.
|
|
73
|
+
*
|
|
74
|
+
* @param transaction - Any transaction created by this package.
|
|
75
|
+
* @returns The connection-level adapter handle.
|
|
76
|
+
*/
|
|
77
|
+
export declare function connectionHandle(transaction: Transaction): unknown;
|
|
58
78
|
/**
|
|
59
79
|
* Narrow an adapter handle to a savepoint handle.
|
|
60
80
|
*
|
|
@@ -48,6 +48,22 @@ export function attachInternals(transaction, operations) {
|
|
|
48
48
|
});
|
|
49
49
|
return transaction;
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the adapter connection a transaction runs on.
|
|
53
|
+
*
|
|
54
|
+
* A savepoint's handle names its parent connection; a participant's handle
|
|
55
|
+
* is the joined transaction's. A savepoint opened inside another savepoint
|
|
56
|
+
* used to be created against the outer *savepoint handle* rather than the
|
|
57
|
+
* connection, which no adapter can act on.
|
|
58
|
+
*
|
|
59
|
+
* @param transaction - Any transaction created by this package.
|
|
60
|
+
* @returns The connection-level adapter handle.
|
|
61
|
+
*/
|
|
62
|
+
export function connectionHandle(transaction) {
|
|
63
|
+
const handle = internals(transaction)._getHandle();
|
|
64
|
+
const savepoint = asSavepointHandle(handle);
|
|
65
|
+
return savepoint ? savepoint.parent : handle;
|
|
66
|
+
}
|
|
51
67
|
/**
|
|
52
68
|
* Narrow an adapter handle to a savepoint handle.
|
|
53
69
|
*
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* @module transaction/transaction.participant
|
|
11
11
|
*/
|
|
12
12
|
import { createTransaction } from "./transaction.core.js";
|
|
13
|
-
import { internals } from "./transaction.internal.js";
|
|
13
|
+
import { attachInternals, internals } from "./transaction.internal.js";
|
|
14
14
|
/**
|
|
15
15
|
* Create a handle that joins an in-progress transaction.
|
|
16
16
|
*
|
|
@@ -18,7 +18,7 @@ import { internals } from "./transaction.internal.js";
|
|
|
18
18
|
* @returns A participant handle delegating to `parent`.
|
|
19
19
|
*/
|
|
20
20
|
export function createParticipant(parent) {
|
|
21
|
-
|
|
21
|
+
const participant = {
|
|
22
22
|
get id() {
|
|
23
23
|
return parent.id;
|
|
24
24
|
},
|
|
@@ -61,7 +61,25 @@ export function createParticipant(parent) {
|
|
|
61
61
|
afterRollback(callback) {
|
|
62
62
|
parent.afterRollback(callback);
|
|
63
63
|
},
|
|
64
|
+
};
|
|
65
|
+
// A participant owns nothing, but the manager still has to reach the
|
|
66
|
+
// adapter handle of the transaction it joined: a `nested` run inside a
|
|
67
|
+
// participant scope used to throw "Transaction was not created by
|
|
68
|
+
// @zudojs/transactions" because the frozen participant carried no
|
|
69
|
+
// internals at all. Reads delegate to the joined transaction; writes
|
|
70
|
+
// are refused, since only the owner may drive its state.
|
|
71
|
+
const refuse = () => {
|
|
72
|
+
throw new TypeError("A participant does not own the transaction it joined and cannot modify it.");
|
|
73
|
+
};
|
|
74
|
+
attachInternals(participant, {
|
|
75
|
+
_setHandle: refuse,
|
|
76
|
+
_getHandle: () => internals(parent)._getHandle(),
|
|
77
|
+
_transition: refuse,
|
|
78
|
+
_markTimedOut: refuse,
|
|
79
|
+
_getRollbackOnlyReason: () => internals(parent)._getRollbackOnlyReason(),
|
|
80
|
+
_drainCallbackErrors: () => [],
|
|
64
81
|
});
|
|
82
|
+
return Object.freeze(participant);
|
|
65
83
|
}
|
|
66
84
|
/**
|
|
67
85
|
* Create a handle for a deliberately non-transactional scope.
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/transactions",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Transaction lifecycle and coordination with state machine, AsyncLocalStorage context propagation, savepoints, hooks, and adapter abstraction.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Oluwayemi Oyinlola",
|
|
8
|
+
"url": "https://github.com/oyinlola-tech"
|
|
9
|
+
},
|
|
6
10
|
"type": "module",
|
|
7
11
|
"main": "./dist/index.js",
|
|
8
12
|
"module": "./dist/index.js",
|
|
@@ -21,7 +25,7 @@
|
|
|
21
25
|
"!dist/.tsbuildinfo"
|
|
22
26
|
],
|
|
23
27
|
"dependencies": {
|
|
24
|
-
"@zudojs/errors": "1.0.
|
|
28
|
+
"@zudojs/errors": "1.0.1"
|
|
25
29
|
},
|
|
26
30
|
"engines": {
|
|
27
31
|
"node": ">=24.0.0"
|