fix: bulletproof registry number allocation using actual DB entries as source of truth

The old allocateSequenceNumber blindly incremented a counter in
RegistrySequence, which drifted out of sync when entries were deleted
or moved between companies — producing wrong numbers (e.g., #6 for
the first entry of a company).

New approach:
- Uses pg_advisory_xact_lock inside a Prisma interactive transaction
  to serialize concurrent allocations
- Always queries the actual MAX sequence from KeyValueStore entries
  (the source of truth) before allocating the next number
- Takes MAX(actual entries, counter) + 1 so the counter can never
  produce a stale/duplicate number
- Upserts the counter to the new value for consistency
- Also adds recalculateSequence to DELETE handler so the counter
  stays in sync after entry deletions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-11 21:11:52 +02:00
parent 8e56aa7b89
commit dbed7105b7
2 changed files with 57 additions and 11 deletions
+6
View File
@@ -431,6 +431,12 @@ export async function DELETE(req: NextRequest) {
await deleteEntryFromDB(id);
// Recalculate counter so next allocation reads the correct max
await recalculateSequence(
existing.company as CompanyId,
existing.direction as RegistryDirection,
);
await logAuditEvent({
entryId: id,
entryNumber: existing.number,
@@ -218,7 +218,12 @@ export function generateRegistryNumber(
* Allocate the next sequence number atomically via PostgreSQL.
* Format: BTG-2026-IN-00001
*
* Uses INSERT ... ON CONFLICT ... UPDATE RETURNING for race-condition safety.
* Uses an interactive Prisma transaction with pg_advisory_xact_lock to
* serialize concurrent allocations. Always reads the **actual max
* sequence from existing entries** (the source of truth) so the counter
* never drifts — even after entry deletions, company reassignments, or
* any other mutation that could leave the RegistrySequence counter stale.
*
* Must be called from server-side only (API routes).
*/
export async function allocateSequenceNumber(
@@ -233,17 +238,52 @@ export async function allocateSequenceNumber(
const typeCode = DIRECTION_TYPE_CODE[direction];
const yr = year ?? new Date().getFullYear();
const result = await prisma.$queryRaw<Array<{ lastSeq: number }>>`
const seq = await prisma.$transaction(async (tx) => {
// Advisory lock scoped to this transaction — serializes concurrent
// allocations for the same company/year/type combination.
const lockKey = `registratura:${companyPrefix}-${yr}-${typeCode}`;
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${lockKey}))`;
// 1. Find actual max sequence from entries stored in KeyValueStore.
// This is the SOURCE OF TRUTH — entries might have been deleted,
// moved between companies, etc., making the RegistrySequence
// counter stale.
const numberLike = `%"number":"${companyPrefix}-${yr}-${typeCode}-%"%`;
const regexPat = `${companyPrefix}-${yr}-${typeCode}-(\\d{5})`;
const maxRows = await tx.$queryRaw<Array<{ maxSeq: number | null }>>`
SELECT MAX(
CAST(SUBSTRING(value::text FROM ${regexPat}) AS INTEGER)
) AS "maxSeq"
FROM "KeyValueStore"
WHERE namespace = 'registratura'
AND key LIKE 'entry:%'
AND value::text LIKE ${numberLike}
`;
const actualMax = maxRows[0]?.maxSeq ?? 0;
// 2. Also read the current counter as a safety net (should never be
// higher than actualMax if everything is consistent, but we take
// the MAX of both just in case).
const counterRows = await tx.$queryRaw<Array<{ lastSeq: number }>>`
SELECT "lastSeq" FROM "RegistrySequence"
WHERE company = ${companyPrefix} AND year = ${yr} AND type = ${typeCode}
`;
const counterVal = counterRows[0]?.lastSeq ?? 0;
// 3. Next sequence = max(actual entries, counter) + 1
const nextSeq = Math.max(actualMax, counterVal) + 1;
// 4. Upsert the counter to the new value
await tx.$executeRaw`
INSERT INTO "RegistrySequence" (id, company, year, type, "lastSeq", "createdAt", "updatedAt")
VALUES (gen_random_uuid()::text, ${companyPrefix}, ${yr}, ${typeCode}, 1, NOW(), NOW())
VALUES (gen_random_uuid()::text, ${companyPrefix}, ${yr}, ${typeCode}, ${nextSeq}, NOW(), NOW())
ON CONFLICT (company, year, type)
DO UPDATE SET "lastSeq" = "RegistrySequence"."lastSeq" + 1, "updatedAt" = NOW()
RETURNING "lastSeq"
DO UPDATE SET "lastSeq" = ${nextSeq}, "updatedAt" = NOW()
`;
const row = result[0];
if (!row) throw new Error("Failed to allocate sequence number");
const seq = row.lastSeq;
return nextSeq;
});
const padded = String(seq).padStart(5, "0");
return {