prisma-atomic-lock
v1.0.0
Published
Framework-native pessimistic row-level locking (SELECT ... FOR UPDATE) for Prisma. Eliminates database race conditions such as double-spending and over-purchasing, with first-class helpers for NestJS and Next.js.
Maintainers
Readme
prisma-atomic-lock
Pessimistic row-level locking (SELECT ... FOR UPDATE) for Prisma, wired natively into NestJS and Next.js.
Race conditions in "check-then-write" flows — decrementing stock, debiting a wallet balance, redeeming a coupon — happen because two concurrent requests both read the same row before either writes back. prisma-atomic-lock closes that window by taking a real row-level lock inside an interactive Prisma transaction before your business logic runs, so concurrent requests for the same row are serialized by the database itself instead of racing in application code.
prisma-atomic-lock/nest— a@AtomicLock()method decorator for NestJS services.prisma-atomic-lock/next— awithAtomicLock()higher-order function for Next.js Server Actions and Route Handlers.- Dual package: ships both ESM and CommonJS builds with full type declarations, so it works with
importorrequireeither way. - Zero dependencies.
@nestjs/commonand@prisma/clientare peer dependencies only.
Requires a database that supports
SELECT ... FOR UPDATE— PostgreSQL, MySQL/MariaDB, or CockroachDB. SQLite and MongoDB do not support row-level locking and are not compatible with this library.
Install
npm install prisma-atomic-lock@prisma/client is always required. @nestjs/common is only required if you use the /nest subpath.
How it works
Both entry points do the same thing under the hood:
- Open an interactive
prisma.$transaction(async (tx) => { ... }). - Inside it, run
SELECT 1 FROM "<table>" WHERE "<idColumn>" = $1 FOR UPDATEusing the row id you provide. - Postgres/MySQL blocks any other transaction trying to lock the same row until this one commits or rolls back — so a second concurrent call for the same id simply waits its turn instead of reading stale data.
- Your business logic then runs inside that same transaction, using the transaction client (
tx), so its reads and writes are part of the locked, atomic unit of work.
Different ids are not blocked by each other — only concurrent operations on the same row serialize.
NestJS: @AtomicLock()
import { Injectable } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import { AtomicLock } from 'prisma-atomic-lock/nest';
@Injectable()
export class WalletService {
constructor(private readonly prisma: PrismaService) {}
@AtomicLock({ table: 'Account', keyParam: 'accountId' })
async debit(accountId: string, amount: number) {
const account = await this.prisma.account.findUniqueOrThrow({ where: { id: accountId } });
if (account.balance < amount) {
throw new Error('Insufficient balance');
}
return this.prisma.account.update({
where: { id: accountId },
data: { balance: { decrement: amount } },
});
}
}That's it — no other code changes. Calling walletService.debit(accountId, amount) now:
- Locks the
Accountrow matchingaccountIdviaFOR UPDATE. - Makes
this.prismaresolve to the active transaction client for the duration of the call, so everythis.prisma.*call insidedebit()participates in the same locked transaction. - Falls back to the original client automatically once the call finishes — even if
debit()throws.
Requirements
- The host class must have a property literally named
prismaholding your injectedPrismaClient(or aPrismaServicewrapping it). If it's missing,AtomicLockthrowsInternalServerErrorExceptionwhen the method is called. - The decorated method must declare a parameter with the exact name given in
keyParam.AtomicLockinspects the method's source at class-definition time to resolvekeyParamto a positional argument — if no parameter matches, it throwsInternalServerErrorExceptionimmediately (at decoration time, not at call time).
Options
interface AtomicLockOptions {
table: string; // table name used verbatim in the raw SQL query
keyParam: string; // name of the method parameter holding the row id to lock
idColumn?: string; // column matched against — defaults to "id"
transactionOptions?: Record<string, unknown>; // forwarded to prisma.$transaction(), e.g. { timeout, maxWait }
}Under heavy contention, many callers can end up queuing for the same row lock — Prisma's default interactive-transaction timeout is 5 seconds, which can be too short for high-contention rows (e.g. a flash sale). Raise it via transactionOptions:
@AtomicLock({ table: 'Item', keyParam: 'itemId', transactionOptions: { timeout: 15000, maxWait: 15000 } })
async purchase(itemId: string, quantity: number) { /* ... */ }Concurrency safety
AtomicLock is safe on the default singleton Nest provider scope under concurrent load — no Scope.REQUEST needed. Internally it does not mutate this.prisma as a plain instance field (which would race across concurrent calls on the same singleton instance); it uses AsyncLocalStorage to scope the active transaction client to each call's own async continuation, so concurrent calls — on the same row or different rows, through the same or different methods — never observe each other's transaction. This was verified under real concurrent load (30 simultaneous requests against 10 units of stock, exactly 10 succeeded, 0 oversold) — see examples/nestjs-demo.
Next.js: withAtomicLock()
// app/actions/purchase.ts
'use server';
import { prisma } from '@/lib/prisma';
import { withAtomicLock } from 'prisma-atomic-lock/next';
export const purchaseItem = withAtomicLock(
{ prisma, table: 'Item' },
async (tx, itemId: string, quantity: number) => {
const item = await tx.item.findUniqueOrThrow({ where: { id: itemId } });
if (item.stock < quantity) {
throw new Error('Not enough stock');
}
return tx.item.update({
where: { id: itemId },
data: { stock: { decrement: quantity } },
});
},
);// app/checkout/actions-consumer.tsx
import { purchaseItem } from '@/app/actions/purchase';
await purchaseItem(itemId, quantity);withAtomicLock returns a function whose signature matches your original action exactly (id first, then whatever else you defined) — the only difference is that your action callback receives the locked transaction client tx as its new first parameter, with id and the rest of the arguments shifted after it.
Works identically in Route Handlers:
// app/api/purchase/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { withAtomicLock } from 'prisma-atomic-lock/next';
const purchaseItem = withAtomicLock({ prisma, table: 'Item' }, async (tx, itemId: string, quantity: number) => {
/* ... */
});
export async function POST(req: Request) {
const { itemId, quantity } = await req.json();
const result = await purchaseItem(itemId, quantity);
return NextResponse.json(result);
}Options
interface AtomicLockOptions<TClient> {
prisma: TClient; // your PrismaClient instance (any client exposing $transaction)
table: string; // table name used verbatim in the raw SQL query
idColumn?: string; // column matched against — defaults to "id"
transactionOptions?: Record<string, unknown>; // forwarded to prisma.$transaction(), e.g. { timeout, maxWait }
}TClient is inferred from whatever you pass as prisma — this works with the default @prisma/client output as well as Prisma 6/7 custom-output generated clients, since the library doesn't import a concrete PrismaClient type internally.
As with /nest, raise transactionOptions.timeout for rows that see heavy concurrent contention — this was verified under real concurrent load (30 simultaneous requests against 10 units of stock, exactly 10 succeeded, 0 oversold) — see examples/nextjs-demo.
Why not just use SELECT ... FOR UPDATE inline everywhere?
You can — this library just removes the boilerplate and the easy-to-miss mistakes: forgetting to run the lock query before the read, forgetting to run everything through the same tx, or forgetting to restore state afterward. prisma-atomic-lock gives you one line to opt a method or action into "this row's operations are strictly serialized," while leaving everything else in your codebase untouched.
Examples
Full, runnable apps that exercise @AtomicLock/withAtomicLock against a real PostgreSQL database (Prisma 7 + driver adapters):
examples/nestjs-demo— a NestJS service with an/purchaseendpoint (stock) and a/debitendpoint (wallet balance).examples/nextjs-demo— the same two flows as Next.js Route Handlers.
Both were stress-tested by firing 30 concurrent HTTP requests at a row with only 10 units/dollars available: exactly 10 succeeded and 20 were correctly rejected, with the database left in a consistent final state, in both frameworks.
Compatibility
| Peer dependency | Supported versions |
| --- | --- |
| @prisma/client | ^4.0.0 \|\| ^5.0.0 \|\| ^6.0.0 \|\| ^7.0.0 |
| @nestjs/common | ^8.0.0 \|\| ^9.0.0 \|\| ^10.0.0 \|\| ^11.0.0 (only if using prisma-atomic-lock/nest) |
Database: PostgreSQL, MySQL/MariaDB, or CockroachDB (anything supporting SELECT ... FOR UPDATE).
License
MIT
