UNPKG

@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
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 };