A credit counter that could go negative
NicheFinder gives every account five credits a day. Spend them on searches, and a service called creditService.ts decides whether you have any left. Until this week, that decision was two separate database calls: read the current balance, check it against zero, then write the balance minus one. Two calls that were not one transaction.
That gap is enough. A double-click, a retried request, anything that fires the same call twice inside that window reads the same starting balance twice, passes the same check twice, and writes two decrements from the same number. With one credit left, both requests see 1, both pass, and the balance lands on -1.
The fix collapses the check and the write into one round trip: a single
UPDATE user_credits SET credits = credits - 1
WHERE user_id = $1 AND credits >= 1
RETURNING credits
If no row comes back, there was nothing left to spend, and the request fails with "Insufficient credits." Postgres's own row lock does the concurrency work that the two-step version tried to do in application code and couldn't.
The task that asked for this fix, written earlier, described a different fix: a Supabase RPC function. By the time anyone picked the task up, that plan was already out of date - a separate migration had moved the project off Supabase onto a local Postgres instance behind Prisma. The rewrite target didn't exist anymore; the underlying problem still did. The atomic update turned out to need no new migration or stored procedure at all - Prisma's raw query already gives one atomic round trip against Postgres, so the fix landed smaller than the ticket that requested it.
Verifying it took a unit test built specifically to lose the race on purpose: two concurrent calls against a balance of one credit, checked for the outcome that matters - exactly one success, one "Insufficient credits," never two successes. The daily refill logic, which runs before the deduction check, was left untouched; only the check-and-decrement itself needed to become atomic.
Nothing here indicates the race was ever hit in production - the fix closes a gap in the logic, not a reported incident. What it shows is how quickly a ticket's plan can drift from the code it describes. The instructions were correct when they were written. They were stale within days.