import { type Container, InjectionToken, type PostProcessor } from './di' import { type Ctor, ctxMeta, metadataOf, TRANSACTIONAL } from './metadata' /** Runs a unit of work: begin → `fn` → commit, or rollback if `fn` throws. */ export interface TransactionManager { /** * Run `fn` in a transaction: commit its result, or roll back if it throws. * * @typeParam T - The value `fn` produces. * @param fn - The unit of work to run inside the transaction. * @returns A promise for `fn`'s result once the transaction commits. */ run(fn: () => T | Promise): Promise } /** Bind your database's transaction manager here (`{ provide: TRANSACTION_MANAGER, useValue }`). */ export const TRANSACTION_MANAGER = new InjectionToken( 'TransactionManager', ) /** The default when none is bound: just runs `fn` (no real transaction). */ const NOOP_MANAGER: TransactionManager = { run: async (fn) => fn() } /** * Method decorator: run this method inside a transaction from the bound * `TransactionManager` (commit on success, roll back on error) — the result * becomes a `Promise`, so callers must await it. With no manager bound there is * no transaction to run, so the method executes as-is (a synchronous method * stays synchronous). `@repository` applies this to every method of a class. * * @param _value - The decorated method (unused; metadata is keyed by name). * @param context - The standard method-decorator context; its `name` marks the method. */ export function transactional( _value: unknown, context: ClassMethodDecoratorContext, ): void { const meta = ctxMeta(context) const set = (meta[TRANSACTIONAL] as Set | undefined) ?? new Set() set.add(context.name) meta[TRANSACTIONAL] = set } /** * A post-processor that wraps `@transactional` methods in the container's * `TransactionManager`. Registered automatically by `createApp`. * * @param container - The container used to resolve the bound `TransactionManager`. * @returns A `PostProcessor` that wraps each instance's `@transactional` methods. */ export function transactionalProcessor(container: Container): PostProcessor { return (instance, token: Ctor) => { const methods = metadataOf(token)?.[TRANSACTIONAL] as | Set | undefined if (!methods || methods.size === 0) return instance return new Proxy(instance, { get(target, prop) { const value = Reflect.get(target, prop, target) if (typeof value !== 'function') return value const fn = value as (...args: unknown[]) => unknown if (typeof prop === 'string' && methods.has(prop)) { return (...args: unknown[]) => { const manager = container.resolveOptional( TRANSACTION_MANAGER, NOOP_MANAGER, ) // No real manager → nothing to run in; call through so a synchronous // method stays synchronous (this is what keeps @repository from // making every DAO call async when transactions aren't configured). if (manager === NOOP_MANAGER) return fn.apply(target, args) return manager.run(() => fn.apply(target, args)) } } return fn.bind(target) }, }) } }