@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 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
- /** Commit a transaction. Participants and committed transactions are no-ops. */
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
- /** Arms the timeout, returning a disposer that always clears the timer. */
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
- return () => clearTimeout(timer);
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.kind === "root" || transaction.kind === "savepoint") {
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
- return withRetry(opts?.retry, async () => {
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
- const owned = transaction.kind === "root" || transaction.kind === "savepoint";
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
- disposeTimeout();
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
- /** Commit a transaction. Participants and committed transactions are no-ops. */
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
- return commitTransaction(transaction, adapter, hooks, emit);
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
- return rollbackTransaction(transaction, adapter, reason, hooks, emit);
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
- const parentHandle = internals(parent)._getHandle();
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
- await runCallbacks(afterCommitCallbacks);
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
- return Object.freeze({
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.0.0",
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.0"
28
+ "@zudojs/errors": "1.0.1"
25
29
  },
26
30
  "engines": {
27
31
  "node": ">=24.0.0"