From 5f92ea1616725e8f5b4b453888bffe5c17e6d9de Mon Sep 17 00:00:00 2001 From: End Nightshade Date: Tue, 17 Feb 2026 10:07:13 -0700 Subject: [PATCH] fix: move refinery stock check inside transaction with row lock The out-of-stock check was before the transaction, so concurrent requests could race past it. Now locks the shop_items row with FOR UPDATE inside the transaction before checking count. --- backend/src/routes/shop.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/backend/src/routes/shop.ts b/backend/src/routes/shop.ts index 400c638..a02fe81 100644 --- a/backend/src/routes/shop.ts +++ b/backend/src/routes/shop.ts @@ -612,15 +612,17 @@ shop.post('/items/:id/upgrade-probability', async ({ params, headers }) => { const item = items[0] - if (item.count <= 0) { - return { error: 'Item is out of stock' } - } - try { const result = await db.transaction(async (tx) => { // Lock the user row to serialize spend operations and prevent race conditions await tx.execute(sql`SELECT 1 FROM users WHERE id = ${user.id} FOR UPDATE`) + // Lock the item row and check stock atomically + const stockCheck = await tx.execute(sql`SELECT count FROM shop_items WHERE id = ${itemId} FOR UPDATE`) + if (!stockCheck.rows[0] || (stockCheck.rows[0] as { count: number }).count <= 0) { + throw { type: 'out_of_stock' } + } + const boostResult = await tx .select({ boostPercent: sql`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)` @@ -697,6 +699,9 @@ shop.post('/items/:id/upgrade-probability', async ({ params, headers }) => { return result } catch (e) { const err = e as { type?: string; balance?: number; cost?: number } + if (err.type === 'out_of_stock') { + return { error: 'Item is out of stock' } + } if (err.type === 'max_probability') { return { error: 'Already at maximum probability' } }