fix(ideas): make nextIdeaCode self-correcting against counter drift (#200)

Fixes a P2002 unique constraint crash on (user_id, code) when
idea_code_counter on the User is behind the actual codes in the ideas
table (e.g. after direct DB inserts during development).

After incrementing the counter the function now queries
MAX(CAST(SUBSTRING(code FROM 6) AS INTEGER)) via raw SQL and takes
max(counter, maxExisting + 1) as the next code. String MAX was not
safe above IDEA-999, hence the numeric cast. If the counter lagged it
is updated in-place to stay in sync.

No schema change, no migration, no changes outside idea-code-server.ts.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-14 10:46:47 +00:00 committed by GitHub
parent d84cdf664f
commit 7bb252c528
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -7,6 +7,11 @@
// Geen aparte $transaction nodig voor enkelvoudige update — de update is
// atomisch op één rij. Voor combineren met een idea.create wordt
// nextIdeaCode aangeroepen binnen de bredere $transaction van de caller.
//
// Self-correcting: na de increment wordt de numerieke MAX van bestaande codes
// opgevraagd via raw SQL (geen string-vergelijking — die faalt boven IDEA-999).
// Als de counter achterloopt (bijv. na directe DB-inserts tijdens development)
// wordt nextN = MAX+1 gebruikt en de counter direct bijgewerkt.
import { prisma } from '@/lib/prisma'
import { formatIdeaCode } from '@/lib/idea-code'
@ -17,10 +22,34 @@ export async function nextIdeaCode(
userId: string,
client: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<string> {
// Increment counter — acquires Postgres row lock, serializes concurrent calls.
const u = await client.user.update({
where: { id: userId },
data: { idea_code_counter: { increment: 1 } },
select: { idea_code_counter: true },
})
return formatIdeaCode(u.idea_code_counter)
// Numeric MAX guards against counter drift (e.g. ideas inserted directly in
// DB without updating the counter). String MAX mis-sorts "IDEA-1000" <
// "IDEA-999", so we cast to INTEGER in SQL.
const rows = await (client as Prisma.TransactionClient).$queryRaw<
[{ max_n: number | bigint | null }]
>`
SELECT MAX(CAST(SUBSTRING(code FROM 6) AS INTEGER)) AS max_n
FROM ideas
WHERE user_id = ${userId}
`
const maxExisting = rows[0].max_n !== null ? Number(rows[0].max_n) : 0
const nextN = Math.max(u.idea_code_counter, maxExisting + 1)
// Re-sync counter forward if it was behind the actual max.
if (nextN !== u.idea_code_counter) {
await client.user.update({
where: { id: userId },
data: { idea_code_counter: nextN },
select: { id: true },
})
}
return formatIdeaCode(nextN)
}