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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user