@andrew_l/mongo-transaction
Version:
Manages side effects in MongoDB transactions, rollback on failure and preventing duplicates on retries.
392 lines (381 loc) • 14.8 kB
TypeScript
import { Awaitable, Fn, AnyFunction, RetryOnErrorConfig } from '@andrew_l/toolkit';
import { ClientSession, ClientSessionOptions } from 'mongodb';
type OnMongoSessionCommittedResult<T> = {
/**
* Executes the provided function upon transaction commit.
*
* Returns `T` if the transaction is committed and the function completes successfully.
*
* Returns `undefined` if the transaction is explicitly aborted or ends without committing.
*
* Rejects if the function throws an error.
*/
promise: Promise<T | undefined>;
cancel: () => void;
};
/**
* Executes the provided function upon transaction commit.
*
* Returns `T` if the transaction is committed and the function completes successfully.
*
* Returns `false` if the transaction ends without committing.
*
* Rejects if the function throws an error.
*
* @example
* const { promise } = onTransactionCommitted(async () => {
* console.info('Transaction committed successfully!');
* return Math.random(); // Random value generated after commit
* });
*
* promise.then(result => {
* if (result !== false) {
* console.info('Handler result:', result); // e.g., Handler result: 0.07576196837476501
* }
* });
*
* @group Hooks
*/
declare function onMongoSessionCommitted<T>(fn: () => Awaitable<T>): OnMongoSessionCommittedResult<T>;
declare function onMongoSessionCommitted<T>(session: ClientSession, fn: () => Awaitable<T>): OnMongoSessionCommittedResult<T>;
/**
* Returns the current transaction session if executed within `withMongoTransaction()` otherwise returns `null`
*
* @example
* async function createAlert() {
* const session = useMongoSession();
*
* await db.alerts.insertOne(
* { title: 'Order Created' },
* { session: session ?? undefined }
* );
* }
*
* @group Hooks
*/
declare function useMongoSession(): ClientSession | null;
interface TransactionEffect {
/**
* Specifies when the transaction effect should run:
*
* `pre` - execute immediately
*
* `post` - execute before transaction commit
*
* @default: "pre"
*/
flush: 'pre' | 'post';
/**
* Setup effect function. You can return a cleanup callback to be used as a rollback.
*/
setup: EffectCallback;
/**
* Cleanup function.
*/
cleanup?: EffectCleanup;
/**
* Useful for debugging execution logs.
*/
name?: string;
dependencies?: readonly any[];
}
type EffectCallback = () => Awaitable<EffectCleanup | void>;
type EffectCleanup = () => Awaitable<void>;
type OnCommittedCallback = () => Awaitable<void>;
type OnRollbackCallback = () => Awaitable<void>;
type UseTransactionEffectOptions = Partial<Pick<TransactionEffect, 'name' | 'flush' | 'dependencies'>>;
/**
* Executes a transactional effect with cleanup on error or rollback.
*
* Ensures the `callback` function is executed only once per transaction, even during retries.
* On errors or dependency changes, the cleanup logic is invoked before re-execution to maintain consistency.
*
* @param setup A function defining the transactional effect. It is guaranteed to run once per transaction
* and may be re-executed after cleanup if dependencies change.
*
* @example
* const confirmOrder = withMongoTransaction({
* connection: () => mongoose.connection.getClient(),
* async fn(session) {
* // Register an alert as a transactional effect
* await useTransactionEffect(async () => {
* const alertId = await alertService.create({
* title: `Order Confirmed: ${orderId}`,
* });
*
* // Define cleanup logic to remove the alert on rollback
* return () => alertService.removeById(alertId);
* });
*
* // Simulate order processing (e.g., database updates)
* await db
* .collection('orders')
* .updateOne({ orderId }, { $set: { status: 'confirmed' } }, { session });
*
* // Simulate an error to test rollback
* throw new Error('Simulated transaction failure');
* },
* });
*
* @group Hooks
*/
declare function useTransactionEffect(setup: TransactionEffect['setup'], options?: UseTransactionEffectOptions): Promise<void>;
/**
* Registers a callback to be executed upon transaction commitment, with support
* for dependency-based updates.
*
* This function is used within a transaction scope to perform specific actions
* when a transaction is committed. If dependencies are provided, the callback
* is re-registered only if the dependencies have changed. Otherwise, the
* callback is registered unconditionally.
*
* @param {OnCommittedCallback} callback - The function to be executed upon
* transaction commitment.
* @param {readonly any[]} [dependencies=[]] - An optional array of dependencies
* to determine if the callback should be re-registered. If the dependencies
* differ from the previously registered ones, the callback is updated.
* @returns {Fn} A cleanup function to cancel event listener.
*
* @example
* // Basic usage without dependencies
* onCommitted(() => {
* console.log('Transaction committed!');
* });
*
* @example
* // Using dependencies
* count++;
* onCommitted(() => {
* console.log(`Commit #${count}`);
* }, [count]);
*
* @example
* // Cancel by request
* const cancel = onCommitted(() => {
* console.log('This will run only once!');
* });
*
* if (orderReceived) {
* cancel(); // Prevents onCommitted from running
* }
*
* @group Hooks
*/
declare function onCommitted(callback: OnCommittedCallback, dependencies?: readonly any[]): Fn;
/**
* Registers a callback to be executed upon transaction rollback, with support
* for dependency-based updates.
*
* This function is used within a transaction scope to perform specific actions
* when a transaction is rolled back. If dependencies are provided, the callback
* is re-registered only if the dependencies have changed. Otherwise, the
* callback is registered unconditionally.
*
* @param {OnRollbackCallback} callback - The function to be executed upon
* transaction rollback.
* @param {readonly any[]} [dependencies=[]] - An optional array of dependencies
* to determine if the callback should be re-registered. If the dependencies
* differ from the previously registered ones, the callback is updated.
* @returns {Fn} A cleanup function to cancel event listener.
*
* @example
* // Basic usage without dependencies
* onRollback(() => {
* console.log('Transaction rolled back!');
* });
*
* @example
* // Using dependencies
* count++;
* onRollback(() => {
* console.log(`Rollback detected, flag is ${flag}`);
* }, [count]);
*
* @example
* // Cancel by request
* const cancel = onRollback(() => {
* console.log('This will run only once on rollback!');
* });
*
* if (orderReceived) {
* cancel(); // Prevents onRollback from running
* }
*
* @group Hooks
*/
declare function onRollback(callback: OnRollbackCallback, dependencies?: readonly any[]): Fn;
interface MongoClientLike {
startSession(options: Record<string, any>): ClientSessionLike;
}
interface ClientSessionLike {
withTransaction(fn: AnyFunction): Promise<any>;
endSession(): Promise<void>;
}
type ConnectionValue = MongoClientLike | (() => Awaitable<MongoClientLike>);
type Callback<T, K = any, Args extends Array<any> = any[]> = (this: K, session: ClientSession, ...args: Args) => Awaitable<T>;
interface WithMongoTransactionOptions<T, K = any, Args extends Array<any> = any[]> {
/**
* Mongodb connection getter
*/
connection: ConnectionValue;
/**
* Transaction session options
*
* @default: {
* defaultTransactionOptions: {
* readPreference: 'primary',
* readConcern: { level: 'local' },
* writeConcern: { w: 'majority' },
* }
* }
*/
sessionOptions?: ClientSessionOptions;
/**
* Configures a timeoutMS expiry for the entire withTransactionCallback.
*
* @remarks
* - The remaining timeout will not be applied to callback operations that do not use the ClientSession.
* - Overriding timeoutMS for operations executed using the explicit session inside the provided callback will result in a client-side error.
*/
timeoutMS?: number;
/**
* Transaction function that will be executed
*
* ⚠️ Possible several times!
*/
fn: Callback<T, K, Args>;
}
type WithMongoTransactionWrapped<T, K = any, Args extends Array<any> = any[]> = (this: K, ...args: Args) => Promise<T>;
/**
* Runs a provided callback within a transaction, retrying either the commitTransaction operation or entire transaction as needed (and when the error permits) to better ensure that the transaction can complete successfully.
*
* Passes the session as the function's first argument or via `useMongoSession()` hook
*
* @example
* const executeTransaction = withMongoTransaction({
* connection: () => mongoose.connection.getClient(),
* async fn() {
* const session = useMongoSession();
* const orders = mongoose.connection.collection('orders');
*
* const { modifiedCount } = await orders.updateMany(
* { status: 'pending' },
* { $set: { status: 'confirmed' } },
* { session },
* );
* },
* });
*
* @group Main
*/
declare function withMongoTransaction<T, K = any, Args extends Array<any> = any[]>(options: WithMongoTransactionOptions<T, K, Args>): WithMongoTransactionWrapped<T, K, Args>;
/**
* Runs a provided callback within a transaction, retrying either the commitTransaction operation or entire transaction as needed (and when the error permits) to better ensure that the transaction can complete successfully.
*
* Passes the session as the function's first argument or via `useMongoSession()` hook
*
* @example
* const executeTransaction = withMongoTransaction(mongoose.connection.getClient(), async () => {
* const session = useMongoSession();
* const orders = mongoose.connection.collection('orders');
*
* const { modifiedCount } = await orders.updateMany(
* { status: 'pending' },
* { $set: { status: 'confirmed' } },
* { session },
* );
* });
*/
declare function withMongoTransaction<T, K = any, Args extends Array<any> = any[]>(connection: ConnectionValue, fn: Callback<T, K, Args>, options?: Omit<WithMongoTransactionOptions<any>, 'fn' | 'connection'>): WithMongoTransactionWrapped<T, K, Args>;
interface WithTransactionOptions extends Partial<RetryOnErrorConfig> {
}
/**
* Wraps a function with transaction context, enabling retry logic and transactional effects.
*
* The wrapped function may be executed multiple times (up to `maxRetriesNumber`) to ensure
* all side effects complete successfully. If the retries are exhausted without success,
* registered cleanup functions will be executed to undo any applied effects.
*
* This utility is useful for managing transactional side effects, such as
* updates to external systems, and ensures proper cleanup in case of failure.
*
* Additionally, this enables hooks like `useTransactionEffect()`, which allows
* defining effects with automatic rollback mechanisms.
*
* @param fn - The target function to wrap with transaction handling.
* @param [options] - Configuration options for the transaction handling.
* @param [options.beforeRetryCallback] - An optional callback to execute before each retry attempt.
* @param [options.shouldRetryBasedOnError] - A predicate to determine if a retry should occur based on the thrown error. Defaults to always retry.
* @param [options.maxRetriesNumber=5] - The maximum number of retries before failing the transaction. Defaults to 5.
* @param [options.delayFactor=0] - A multiplier for the delay between retries. Default is 0 (no exponential backoff).
* @param [options.delayMaxMs=1000] - The maximum delay between retries, in milliseconds. Defaults to 1000 ms.
* @param [options.delayMinMs=100] - The minimum delay between retries, in milliseconds. Defaults to 100 ms.
*
* @example
* const confirmOrder = withTransaction(async (orderId) => {
* // Register Alert
* await useTransactionEffect(async () => {
* const alertId = await alertService.create({
* title: 'New Order: ' + orderId,
* });
*
* return () => alertService.removeById(alertId); // Cleanup in case of failure
* });
*
* // Update Statistics
* await useTransactionEffect(async () => {
* await statService.increment('orders_amount', 1);
*
* return () => statService.decrement('orders_amount', 1); // Cleanup in case of failure
* });
*
* // Simulate failure to trigger rollback
* throw new Error('Cancel transaction.');
* });
*
* @group Main
*/
declare function withTransaction<T, K = any, Args extends Array<any> = any[]>(fn: (this: K, ...args: Args) => T, { beforeRetryCallback, shouldRetryBasedOnError, maxAttempts, maxRetriesNumber, delayFactor, delayMaxMs, delayMinMs, }?: WithTransactionOptions): (this: K, ...args: Args) => Promise<Awaited<T>>;
interface TransactionControlled<T, K = any, Args extends Array<any> = any[]> {
run: (this: K, ...args: Args) => Promise<void>;
commit: () => Promise<void>;
rollback: () => Promise<void>;
result: Readonly<T | undefined>;
error: Readonly<Error | undefined>;
active: boolean;
}
/**
* Wraps a function and returns a `TransactionControlled` interface, allowing manual control
* over transaction commit and rollback operations.
*
* This provides finer-grained control over the transaction lifecycle, enabling users to
* explicitly commit or rollback a transaction based on custom logic. It's especially useful
* in scenarios where transactional state or conditions need to be externally determined.
*
* @example
* const t = withTransactionControlled(async (userId) => {
* await useTransactionEffect(async () => {
* await db.users.updateById(userId, { premium: true });
*
* return () => db.users.updateById(userId, { premium: false })
* });
*
* const user = await db.users.findById(userId);
*
* return user;
* });
*
*
* await t.run();
*
* // Remove premium when no subscriptions
* if (t.result.activeSubscriptions > 0) {
* await t.commit();
* } else {
* await t.rollback();
* }
*
* @group Main
*/
declare function withTransactionControlled<T, K = any, Args extends Array<any> = any[]>(fn: (this: K, ...args: Args) => T): TransactionControlled<Awaited<T>, K, Args>;
export { type OnMongoSessionCommittedResult, type TransactionControlled, type UseTransactionEffectOptions, type WithMongoTransactionOptions, type WithTransactionOptions, onCommitted, onMongoSessionCommitted, onRollback, useMongoSession, useTransactionEffect, withMongoTransaction, withTransaction, withTransactionControlled };