@andrew_l/mongo-transaction 0.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright (c) 2024 Andrew L. <andrew.io.dev@gmail.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # Mongo Transaction Toolkit <!-- omit in toc -->
2
+
3
+ ![license](https://img.shields.io/npm/l/%40andrew_l%2Fmongo-transaction) <!-- omit in toc -->
4
+ ![npm version](https://img.shields.io/npm/v/%40andrew_l%2Fmongo-transaction) <!-- omit in toc -->
5
+ ![npm bundle size](https://img.shields.io/bundlephobia/minzip/%40andrew_l%2Fmongo-transaction) <!-- omit in toc -->
6
+
7
+ This package solves a common issue with MongoDB's `session.withTransaction`, where the provided function might be executed multiple times due to retries. This can create challenges for managing side effects that need to be rolled back consistently during transaction retries or failures.
8
+
9
+ [Documentation](https://men232.github.io/toolkit/reference/@andrew_l/mongo-transaction/)
10
+
11
+ <!-- install placeholder -->
12
+
13
+ ## ✨ Features
14
+
15
+ - **Transactional Effects:** Easily register actions to be rolled back if a transaction fails.
16
+ - **Retry-Safe Operations:** Avoid duplicating side effects during transaction retries.
17
+ - **Cleanup Support:** Ensure that all registered effects are undone if the transaction is canceled.
18
+
19
+ ## ⚠️ Cautions
20
+
21
+ To ensure smooth usage, please keep the following in mind:
22
+
23
+ - **Error Handling:** If your effects throw an error, the transaction will roll back.
24
+ - **Calls:** Always `await` the promises returned by `useTransactionEffect()`.
25
+ - **Placement:** Do not use `useTransactionEffect()` inside nested blocks like conditionals or loops.
26
+ - **Mongoose:** Ensure the connection is established before using the client: `withMongoTransaction(() => mongoose.connection.getClient())`.
27
+
28
+ ## 🚀 Example: Automatic Rollback of Side Effects
29
+
30
+ This example demonstrates how to use the transaction context to automatically manage side effects and roll back actions if an error occurs during execution.
31
+
32
+ ```js
33
+ const confirmOrder = withTransaction(async orderId => {
34
+ // Register Alert
35
+ await useTransactionEffect(async () => {
36
+ const alertId = await alertService.create({
37
+ title: 'New Order: ' + orderId,
38
+ });
39
+
40
+ return () => alertService.removeById(alertId);
41
+ });
42
+
43
+ // Update Statistics
44
+ await useTransactionEffect(async () => {
45
+ await statService.increment('orders_amount', 1);
46
+
47
+ return () => statService.decrement('orders_amount', 1);
48
+ });
49
+
50
+ throw new Error('Oops.');
51
+ });
52
+ ```
53
+
54
+ ## 🚀 Example: Usage MongoDB
55
+
56
+ Below is an example demonstrating how to use `withMongoTransaction` to manage side effects like creating and removing alerts during a transaction. If the transaction fails, the created alert is automatically removed. Additionally, the logic ensures duplicate alerts are not created during MongoDB's retry mechanism.
57
+
58
+ ```js
59
+ import mongoose from 'mongoose';
60
+ import {
61
+ useTransactionEffect,
62
+ withMongoTransaction,
63
+ } from '@andrew_l/mongo-transaction';
64
+
65
+ const confirmOrder = withMongoTransaction({
66
+ connection: () => mongoose.connection.getClient(),
67
+ async fn(session) {
68
+ // Register an alert as a transactional effect
69
+ await useTransactionEffect(async () => {
70
+ const alertId = await alertService.create({
71
+ title: `Order Confirmed: ${orderId}`,
72
+ });
73
+
74
+ // Define cleanup logic to remove the alert on rollback
75
+ return () => alertService.removeById(alertId);
76
+ });
77
+
78
+ // Simulate order processing (e.g., database updates)
79
+ await db
80
+ .collection('orders')
81
+ .updateOne({ orderId }, { $set: { status: 'confirmed' } }, { session });
82
+
83
+ // Simulate an error to test rollback
84
+ throw new Error('Simulated transaction failure');
85
+ },
86
+ });
87
+
88
+ confirmOrder('673b907dddd8ae43262aec0d').catch(console.error);
89
+ ```
90
+
91
+ ## 🤔 Why Use This Package?
92
+
93
+ 1. **Safe Retries:** MongoDB retries can cause duplicate actions if not handled properly. This package ensures all side effects are idempotent and reversible.
94
+ 2. **Streamlined Rollbacks:** Simplifies managing complex operations by integrating rollback mechanisms into your transaction workflow.
95
+ 3. **Ease of Use:** API design mimics React's hooks, making it intuitive for developers familiar with React patterns.
package/dist/index.cjs ADDED
@@ -0,0 +1,472 @@
1
+ 'use strict';
2
+
3
+ const toolkit = require('@andrew_l/toolkit');
4
+ const context = require('@andrew_l/context');
5
+ const mongodb = require('mongodb');
6
+
7
+ var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
8
+ const [injectMongoSession, provideMongoSession] = context.createContext("withMongoTransaction");
9
+ function useMongoSession() {
10
+ return context.hasInjectionContext() ? injectMongoSession(null) : null;
11
+ }
12
+
13
+ function onMongoSessionCommitted(...args) {
14
+ let session;
15
+ let fn;
16
+ if (args.length === 2) {
17
+ [session, fn] = args;
18
+ } else {
19
+ session = injectMongoSession();
20
+ fn = args[0];
21
+ }
22
+ const q = toolkit.defer();
23
+ const onEnded = () => {
24
+ if (!session.transaction.isCommitted) {
25
+ return q.resolve(void 0);
26
+ }
27
+ try {
28
+ const result = fn();
29
+ if (toolkit.isPromise(result)) {
30
+ result.then((r) => q.resolve(r)).catch(q.reject);
31
+ } else {
32
+ q.resolve(result);
33
+ }
34
+ } catch (err) {
35
+ q.reject(err);
36
+ }
37
+ };
38
+ session.once("ended", onEnded);
39
+ const cancel = () => {
40
+ session.off("ended", onEnded);
41
+ };
42
+ return {
43
+ promise: q.promise,
44
+ cancel
45
+ };
46
+ }
47
+
48
+ const [injectTransactionScope, provideTransactionScope] = context.createContext([
49
+ "withTransaction",
50
+ "withTransactionControlled",
51
+ "withMongoTransaction"
52
+ ]);
53
+
54
+ class TransactionScope {
55
+ constructor(fn) {
56
+ this.fn = fn;
57
+ const scope = this;
58
+ this.run = function(...args) {
59
+ const self = this === scope ? void 0 : this;
60
+ return scope._run(self, ...args);
61
+ };
62
+ }
63
+ /**
64
+ * @internal
65
+ */
66
+ log = toolkit.logger(({ url: (typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)) }), "TransactionScope");
67
+ /**
68
+ * @internal
69
+ * Indicates currently ran scope
70
+ */
71
+ _active = false;
72
+ /**
73
+ * Last run error
74
+ */
75
+ error;
76
+ /**
77
+ * Last run result
78
+ */
79
+ result;
80
+ run;
81
+ /**
82
+ * @internal
83
+ */
84
+ hooks = {
85
+ committed: { byCursor: [], cursor: 0 },
86
+ effects: { byCursor: [], cursor: 0 },
87
+ rollbacks: { byCursor: [], cursor: 0 }
88
+ };
89
+ get active() {
90
+ return this._active;
91
+ }
92
+ /**
93
+ * @internal
94
+ */
95
+ async _run(self, ...args) {
96
+ toolkit.assert.ok(!this._active, "Cannot run while transaction active.");
97
+ this.reset();
98
+ this._active = true;
99
+ const [cbError, cbResult] = await context.withContext(() => {
100
+ provideTransactionScope(this);
101
+ return toolkit.catchError(this.fn.bind(self, ...args));
102
+ })();
103
+ if (cbError) {
104
+ this.error = cbError;
105
+ } else {
106
+ const applyError = await effectsApply(this, "post");
107
+ if (applyError) {
108
+ this.error = applyError;
109
+ }
110
+ this.result = cbResult;
111
+ }
112
+ this._active = false;
113
+ }
114
+ async commit() {
115
+ toolkit.assert.ok(!this._active, "Cannot commit while transaction active.");
116
+ toolkit.assert.ok(!this.error, this.error);
117
+ await toolkit.asyncForEach(
118
+ this.hooks.committed.byCursor,
119
+ (h) => toolkit.catchError(h.callback),
120
+ { concurrency: 4 }
121
+ );
122
+ this.reset();
123
+ this.clean();
124
+ }
125
+ async rollback() {
126
+ toolkit.assert.ok(!this._active, "Cannot rollback while transaction active.");
127
+ const error = await effectsCleanup(this);
128
+ if (error) {
129
+ return Promise.reject(error);
130
+ }
131
+ await toolkit.asyncForEach(
132
+ this.hooks.rollbacks.byCursor,
133
+ (h) => toolkit.catchError(h.callback),
134
+ { concurrency: 4 }
135
+ );
136
+ this.reset();
137
+ this.clean();
138
+ }
139
+ reset() {
140
+ toolkit.assert.ok(!this._active, "Cannot reset while transaction active.");
141
+ this.hooks.effects.cursor = 0;
142
+ this.hooks.committed.cursor = 0;
143
+ this.hooks.rollbacks.cursor = 0;
144
+ this.result = void 0;
145
+ this.error = void 0;
146
+ }
147
+ clean() {
148
+ toolkit.assert.ok(!this._active, "Cannot clean while transaction active.");
149
+ this.hooks.effects = { byCursor: [], cursor: 0 };
150
+ this.hooks.committed = { byCursor: [], cursor: 0 };
151
+ this.hooks.rollbacks = { byCursor: [], cursor: 0 };
152
+ }
153
+ }
154
+ function createTransactionScope(fn) {
155
+ return new TransactionScope(fn);
156
+ }
157
+ async function effectsApply(scope, reason = "no reason") {
158
+ let error;
159
+ const onComplete = (err) => void (error = err || error);
160
+ await toolkit.asyncForEach(
161
+ scope.hooks.effects.byCursor,
162
+ (effect) => applyEffect(scope, effect, reason).then(onComplete),
163
+ {
164
+ concurrency: 4
165
+ }
166
+ );
167
+ return error;
168
+ }
169
+ async function effectsCleanup(scope, reason = "no reason") {
170
+ let error;
171
+ const onComplete = (err) => void (error = err || error);
172
+ await toolkit.asyncForEach(
173
+ scope.hooks.effects.byCursor,
174
+ (effect) => cleanupEffect(scope, effect, reason).then(onComplete),
175
+ {
176
+ concurrency: 4
177
+ }
178
+ );
179
+ return error;
180
+ }
181
+ async function applyEffect(scope, effect, reason = "no reason") {
182
+ if (effect.cleanup) return;
183
+ scope.log.debug(
184
+ "Effect name = %s, flush = %s, apply by %s",
185
+ effect.name,
186
+ effect.flush,
187
+ reason
188
+ );
189
+ const [err, effectResult] = await toolkit.catchError(effect.setup);
190
+ if (err) {
191
+ !toolkit.env.isTest && scope.log.error(
192
+ "Effect name = %s, flush = %s apply error",
193
+ effect.name,
194
+ effect.flush,
195
+ err
196
+ );
197
+ return err;
198
+ }
199
+ effect.cleanup = toolkit.isFunction(effectResult) ? effectResult : toolkit.noop;
200
+ return;
201
+ }
202
+ async function cleanupEffect(scope, effect, reason = "no reason") {
203
+ if (!effect.cleanup) return;
204
+ scope.log.debug(
205
+ "Effect name = %s, flush = %s, cleanup by %s",
206
+ effect.name,
207
+ effect.flush,
208
+ reason
209
+ );
210
+ const [err] = await toolkit.catchError(effect.cleanup);
211
+ if (err) {
212
+ !toolkit.env.isTest && scope.log.error(
213
+ "Effect name = %s, flush = %s, cleanup error",
214
+ effect.name,
215
+ effect.flush,
216
+ err
217
+ );
218
+ return err;
219
+ }
220
+ effect.cleanup = void 0;
221
+ }
222
+
223
+ async function useTransactionEffect(setup, options = {}) {
224
+ const scope = injectTransactionScope();
225
+ const { cursor, byCursor } = scope.hooks.effects;
226
+ const effectConfig = byCursor[cursor] ?? {};
227
+ const flush = options?.flush || "pre";
228
+ const name = options?.name || `Effect #${cursor + 1}`;
229
+ const dependencies = options.dependencies ?? [];
230
+ const prevDependencies = effectConfig.dependencies;
231
+ byCursor[cursor] = {
232
+ ...effectConfig,
233
+ dependencies,
234
+ flush,
235
+ name,
236
+ setup
237
+ };
238
+ if (dependencies && prevDependencies) {
239
+ if (!toolkit.isEqual(dependencies, prevDependencies)) {
240
+ scope.log.debug(
241
+ "Effect name = %s, flush = %s, caused by dependencies",
242
+ name,
243
+ flush,
244
+ {
245
+ prevDependencies,
246
+ newDependencies: dependencies
247
+ }
248
+ );
249
+ await scheduleEffect(scope, cursor);
250
+ }
251
+ } else {
252
+ scope.log.debug(
253
+ "Effect name = %s, flush = %s, caused by missing dependencies",
254
+ name,
255
+ flush
256
+ );
257
+ await scheduleEffect(scope, cursor);
258
+ }
259
+ scope.hooks.effects.cursor++;
260
+ }
261
+ async function scheduleEffect(scope, cursor) {
262
+ const { byCursor } = scope.hooks.effects;
263
+ const effect = byCursor[cursor];
264
+ const cleanupErr = await cleanupEffect(scope, effect, "schedule pre");
265
+ if (cleanupErr) {
266
+ return Promise.reject(cleanupErr);
267
+ }
268
+ if (effect.flush === "pre") {
269
+ const err = await applyEffect(scope, effect, "schedule pre");
270
+ if (err) {
271
+ return Promise.reject(err);
272
+ }
273
+ }
274
+ }
275
+
276
+ function onCommitted(callback, dependencies) {
277
+ const scope = injectTransactionScope();
278
+ const { cursor, byCursor } = scope.hooks.committed;
279
+ const config = byCursor[cursor];
280
+ if (dependencies && config?.dependencies) {
281
+ if (!toolkit.isEqual(dependencies, config.dependencies)) {
282
+ scope.log.debug("OnCommitted caused by dependencies", {
283
+ prevDependencies: config.dependencies,
284
+ newDependencies: dependencies,
285
+ cursor
286
+ });
287
+ byCursor[cursor] = { callback, dependencies };
288
+ }
289
+ } else {
290
+ scope.log.debug("OnCommitted caused by missing dependencies", { cursor });
291
+ byCursor[cursor] = { callback, dependencies };
292
+ }
293
+ scope.hooks.committed.cursor++;
294
+ return () => {
295
+ byCursor[cursor].callback = toolkit.noop;
296
+ };
297
+ }
298
+
299
+ function onRollback(callback, dependencies) {
300
+ const scope = injectTransactionScope();
301
+ const { cursor, byCursor } = scope.hooks.rollbacks;
302
+ const config = byCursor[cursor];
303
+ if (dependencies && config?.dependencies) {
304
+ if (!toolkit.isEqual(dependencies, config.dependencies)) {
305
+ scope.log.debug("OnCommitted caused by dependencies", {
306
+ prevDependencies: config.dependencies,
307
+ newDependencies: dependencies,
308
+ cursor
309
+ });
310
+ byCursor[cursor] = { callback, dependencies };
311
+ }
312
+ } else {
313
+ scope.log.debug("OnCommitted caused by missing dependencies", { cursor });
314
+ byCursor[cursor] = { callback, dependencies };
315
+ }
316
+ scope.hooks.committed.cursor++;
317
+ return () => {
318
+ byCursor[cursor].callback = toolkit.noop;
319
+ };
320
+ }
321
+
322
+ function isTransactionAborted(transaction) {
323
+ return transaction?.state === "TRANSACTION_ABORTED";
324
+ }
325
+ function isTransactionCommittedEmpty(transaction) {
326
+ return transaction?.state === "TRANSACTION_COMMITTED_EMPTY";
327
+ }
328
+ function isMongoClientLike(value) {
329
+ return toolkit.has(value, ["startSession"]) && toolkit.isFunction(value.startSession);
330
+ }
331
+
332
+ const DEF_SESSION_OPTIONS = Object.freeze({
333
+ defaultTransactionOptions: {
334
+ readPreference: "primary",
335
+ readConcern: { level: "local" },
336
+ writeConcern: { w: "majority" }
337
+ }
338
+ });
339
+ function withMongoTransaction(connectionOrOptions, maybeFn, maybeOptions) {
340
+ const {
341
+ connection: connectionValue,
342
+ fn,
343
+ sessionOptions = {},
344
+ timeoutMS
345
+ } = prepareOptions(connectionOrOptions, maybeFn, maybeOptions);
346
+ return async function(...args) {
347
+ const connection = toolkit.isFunction(connectionValue) ? await connectionValue() : connectionValue;
348
+ const session = await connection.startSession(
349
+ sessionOptions
350
+ );
351
+ const scope = createTransactionScope(function(...args2) {
352
+ provideMongoSession(session);
353
+ return fn.call(this, session, ...args2);
354
+ });
355
+ const timeoutAt = timeoutMS ? Date.now() + timeoutMS : 0;
356
+ const timeoutError = new mongodb.MongoTransactionError(
357
+ "Transaction client-side timeout"
358
+ );
359
+ let [transactionError, transactionResult] = await toolkit.catchError(
360
+ () => session.withTransaction(async () => {
361
+ if (timeoutAt && timeoutAt < Date.now()) {
362
+ return Promise.reject(timeoutError);
363
+ }
364
+ await scope.run.apply(this, args);
365
+ if (scope.error) {
366
+ return Promise.reject(scope.error);
367
+ }
368
+ })
369
+ );
370
+ const { result } = scope;
371
+ await session.endSession().catch(toolkit.noop);
372
+ if (transactionResult === void 0 && isTransactionCommittedEmpty(session.transaction)) ; else if (isTransactionAborted(session.transaction) && transactionResult === void 0 && transactionError === void 0) {
373
+ transactionError = new mongodb.MongoTransactionError(
374
+ "Transaction is explicitly aborted"
375
+ );
376
+ }
377
+ if (transactionError) {
378
+ await scope.rollback();
379
+ return Promise.reject(transactionError);
380
+ }
381
+ await scope.commit();
382
+ return result;
383
+ };
384
+ }
385
+ function prepareOptions(connectionOrOptions, maybeFn, maybeOptions) {
386
+ let options;
387
+ if (toolkit.isFunction(connectionOrOptions) || isMongoClientLike(connectionOrOptions)) {
388
+ options = {
389
+ ...maybeOptions || {},
390
+ connection: connectionOrOptions,
391
+ fn: maybeFn
392
+ };
393
+ } else {
394
+ options = connectionOrOptions;
395
+ }
396
+ return toolkit.deepDefaults(options, {
397
+ sessionOptions: DEF_SESSION_OPTIONS
398
+ });
399
+ }
400
+
401
+ function withTransaction(fn, {
402
+ beforeRetryCallback,
403
+ shouldRetryBasedOnError = () => true,
404
+ maxRetriesNumber = 5,
405
+ delayFactor = 0,
406
+ delayMaxMs = 1e3,
407
+ delayMinMs = 100
408
+ } = {}) {
409
+ return async function(...args) {
410
+ const scope = createTransactionScope(fn);
411
+ await toolkit.retryOnError(
412
+ {
413
+ beforeRetryCallback,
414
+ shouldRetryBasedOnError,
415
+ maxRetriesNumber,
416
+ delayFactor,
417
+ delayMaxMs,
418
+ delayMinMs
419
+ },
420
+ async () => {
421
+ await scope.run.apply(this, args);
422
+ if (scope.error) {
423
+ return Promise.reject(scope.error);
424
+ }
425
+ }
426
+ )().catch(toolkit.noop);
427
+ const { error, result } = scope;
428
+ if (error) {
429
+ await scope.rollback();
430
+ return Promise.reject(error);
431
+ } else {
432
+ await scope.commit();
433
+ }
434
+ return result;
435
+ };
436
+ }
437
+
438
+ function withTransactionControlled(fn) {
439
+ const scope = createTransactionScope(fn);
440
+ const controlled = {
441
+ run(...args) {
442
+ const self = this === controlled ? void 0 : this;
443
+ return scope.run.apply(self, args);
444
+ },
445
+ commit() {
446
+ return scope.commit();
447
+ },
448
+ rollback() {
449
+ return scope.rollback();
450
+ },
451
+ get active() {
452
+ return scope.active;
453
+ },
454
+ get result() {
455
+ return scope.result;
456
+ },
457
+ get error() {
458
+ return scope.error;
459
+ }
460
+ };
461
+ return controlled;
462
+ }
463
+
464
+ exports.onCommitted = onCommitted;
465
+ exports.onMongoSessionCommitted = onMongoSessionCommitted;
466
+ exports.onRollback = onRollback;
467
+ exports.useMongoSession = useMongoSession;
468
+ exports.useTransactionEffect = useTransactionEffect;
469
+ exports.withMongoTransaction = withMongoTransaction;
470
+ exports.withTransaction = withTransaction;
471
+ exports.withTransactionControlled = withTransactionControlled;
472
+ //# sourceMappingURL=index.cjs.map