@lepresk/after-commit
v0.1.0
Published
Run side effects only after your database transaction commits, using AsyncLocalStorage. Register after-commit hooks anywhere in the call stack without threading callbacks through every service. Framework and ORM agnostic.
Downloads
31
Maintainers
Readme
@lepresk/after-commit
Run side effects only after your database transaction commits, using
AsyncLocalStorage.
Sending an email, enqueuing a job, or publishing an event from inside a
transaction is a bug: the transaction can still roll back after the side effect
has already left the building. The usual fix is to thread an onCommit
callback array through every service and repository. This library removes that
plumbing. Open a context around the transaction, and any code running inside it
can register an after-commit hook, no matter how deep in the call stack.
Framework and ORM agnostic. Zero dependencies.
Install
pnpm add @lepresk/after-commitUsage
Open a context around the unit of work. Hooks registered inside it run, in order, only after the callback resolves:
import { runWithAfterCommitContext, registerAfterCommitHook } from '@lepresk/after-commit';
await runWithAfterCommitContext(async () => {
await db.transaction(async (tx) => {
const order = await createOrder(tx, input);
// Registered here, deep in the domain layer, but does not fire yet.
registerAfterCommitHook(() => sendOrderConfirmationEmail(order));
await chargePayment(tx, order);
});
});
// The transaction has committed. Only now does the email hook run.If the callback throws, the transaction rolls back and the hooks are discarded:
await runWithAfterCommitContext(async () => {
registerAfterCommitHook(() => sendEmail()); // never runs
throw new Error('validation failed');
});Registering a hook with no active context runs it immediately, so non-transactional call sites keep working without special casing:
// No surrounding runWithAfterCommitContext: the hook runs now.
registerAfterCommitHook(() => publishEvent());Why AsyncLocalStorage
The context is stored in an AsyncLocalStorage, so registerAfterCommitHook
finds it automatically across await boundaries and nested function calls. Your
domain services never receive, hold, or pass an onCommit array. That keeps the
transactional concern at the boundary where it belongs, instead of leaking into
every signature.
API
runWithAfterCommitContext<T>(callback: () => Promise<T>): Promise<T>
Runs callback inside a fresh context and returns its result. On success, hooks
run in registration order. If callback rejects, hooks are discarded and the
error is rethrown. If a hook rejects, the remaining hooks do not run and the
rejection propagates. Contexts nest: an inner context's hooks are isolated from
the outer one.
registerAfterCommitHook(hook: () => void | Promise<void>): void
Defers hook until the active context succeeds. With no active context, the
hook runs immediately and a rejection is logged rather than left unhandled.
hasActiveAfterCommitContext(): boolean
Returns whether a context is currently active. Useful to assert that a code path is running inside a transaction.
setAfterCommitLogger(logger: { error(error: unknown, message: string): void }): void
Overrides the logger used when a no-context hook rejects. Defaults to
console.error. In NestJS, pass a Logger instance to route these into your
logging pipeline:
import { Logger } from '@nestjs/common';
import { setAfterCommitLogger } from '@lepresk/after-commit';
const logger = new Logger('AfterCommit');
setAfterCommitLogger({ error: (err, message) => logger.error(message, err) });Requirements
- Node.js
>=18.18(AsyncLocalStorage)
Development
pnpm install
pnpm typecheck
pnpm lint
pnpm test:coverage
pnpm build