Gate Ordering
Gate Ordering
Server functions that write to the database often need to check preconditions first: does the user have credits? Is the resource in the right state? Is the plan limit not exceeded?
Where you place these checks matters. Putting a mutable state check outside withUserTransaction creates a TOCTOU (time-of-check/time-of-use) race: the state can change between the check and the write.
The rule
Read-only gates go before withUserTransaction.
Mutable state gates go inside withUserTransaction with a fresh read on the transaction handle.
| Gate type | Examples | Where |
|---|---|---|
| Read-only | Backup freshness, S3 bucket existence, feature flag, credential existence, plan tier lookup | Before withUserTransaction |
| Mutable state | Resource status, uniqueness constraint, count vs limit | Inside withUserTransaction, fresh read |
Read-only gates don't need atomicity. They can be slow (network calls, S3 checks) and should not hold the transaction open while they run.
Mutable state gates must be inside the transaction with a fresh read , the state you checked before opening the transaction may already be stale by the time the write runs. Note withUserTransaction is a real Postgres transaction at read-committed isolation, not a mutex , see src/lib/db/user-db.ts's header comment: it still doesn't fully close every TOCTOU gap (two concurrent requests from the same user can both read a pre-write count before either writes), so a fresh read inside the transaction reduces but doesn't eliminate the race for check-then-write paths.
Wrong: mutable status check outside the transaction
// WRONG , race between check and write
const project = await db.query.projects.findFirst({ where: eq(projects.id, id) });
if (project.status === 'deploying') return err('DEPLOYMENT_IN_PROGRESS', '...');
return withUserTransaction(user.id, async tx => {
// project.status may have changed , stale read!
await tx.update(projects).set({ serverId: targetId }).where(eq(projects.id, id));
});
Right: read-only gates outside, mutable gates inside
// Read-only gate , slow, doesn't need atomicity, fine before the transaction
const backupOk = await checkBackupFreshness(db, projectId);
if (!backupOk) return err('BACKUP_REQUIRED', '...');
return withUserTransaction(user.id, async tx => {
// Fresh read inside the transaction
const project = await tx.query.projects.findFirst({ where: eq(projects.id, id) });
if (project.status === 'deploying') return err('DEPLOYMENT_IN_PROGRESS', '...');
await tx.update(projects).set({ serverId: targetId }).where(eq(projects.id, id));
});
Plan limits follow the same rule
Fetch the subscription tier (read-only) before the transaction. Enforce the count limit (mutable , another request could insert concurrently) inside the transaction:
// Before the transaction , read-only, can be slow
const sub = await db.query.subscriptions.findFirst({ where: eq(subscriptions.userId, user.id) });
const plan = (sub?.plan ?? 'free') as PlanKey;
return withUserTransaction(user.id, async (tx, userId) => {
const count = (await listNotes(tx, userId)).length; // fresh count inside the transaction
const { allowed, used, limit } = checkEntitlement(plan, 'maxNotes', count);
if (!allowed) return err(ERROR_CODES.PLAN_LIMIT_EXCEEDED, `Limit reached: ${used}/${limit}.`);
// ... write ...
});
Rate limits
checkUserRateLimit reads user_events rows , this is a read inside withUserTransaction, using the transaction handle. Both the check and the logUserEvent call that feeds it belong inside the same transaction so they're atomic:
return withUserTransaction(user.id, async (tx, userId) => {
if (await checkUserRateLimit(tx, userId, 'item.created', { windowMs: 60_000, max: 20 }))
return err(ERROR_CODES.RATE_LIMITED, 'Too many requests');
// ... write ...
await logUserEvent(tx, userId, 'item.created', { id });
});