From b4804390c4fe5f7d281b718f525aa2c9364b262b Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 17:19:14 +0200 Subject: [PATCH] fix(protokoll): IDs ohne Zweierschritte vergeben (#032) Spalten-Default (BIGSERIAL) und Trigger protokoll_verketten zogen je eine Nummer aus der Sequenz, jede Zeile verbrauchte zwei IDs. Die Nummer aus dem Default darf der Trigger aber nicht einfach uebernehmen, denn sie wird vor der Advisory-Sperre gezogen: Eine parallele Transaktion mit groesserer ID koennte zuerst verketten, und die Kette liesse sich in ID-Reihenfolge nicht mehr pruefen. Deshalb zieht nur noch der Trigger unter der Sperre; der Default ist ein Platzhalter 0 (Prisma: @default(0)), damit create ohne ID funktioniert. Die Sequenz bleibt der Spalte zugeordnet. Bestehende Zeilen werden nicht umnummeriert, weil die ID in die Hash-Eingabe eingeht. Tests: drei Eintraege nacheinander ergeben n, n+1, n+2; eine mitgegebene ID wird ueberschrieben; 20 parallele Eintraege belegen genau 20 aufeinanderfolgende IDs bei intakter Kette. Co-Authored-By: Claude Opus 5.5 --- .../migration.sql | 15 ++++++++++++ prisma/schema.prisma | 4 +++- tests/datenbankregeln.test.ts | 24 ++++++++++++++++++- tests/protokoll-ansicht.test.ts | 2 +- 4 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20261012173000_protokoll_id_lueckenlos/migration.sql diff --git a/prisma/migrations/20261012173000_protokoll_id_lueckenlos/migration.sql b/prisma/migrations/20261012173000_protokoll_id_lueckenlos/migration.sql new file mode 100644 index 0000000..5fe034d --- /dev/null +++ b/prisma/migrations/20261012173000_protokoll_id_lueckenlos/migration.sql @@ -0,0 +1,15 @@ +-- #032: Protokoll-IDs ohne Zweierschritte. +-- +-- Bisher zog der Spalten-Default (BIGSERIAL) eine Nummer aus der Sequenz und der Trigger +-- `protokoll_verketten` gleich noch eine – jede Zeile verbrauchte zwei IDs. Die ID muss aber +-- im Trigger unter der Advisory-Sperre vergeben werden: Eine vorher gezogene Nummer koennte +-- kleiner sein als die einer parallelen Transaktion, die zuerst verkettet, und die Kette liesse +-- sich in ID-Reihenfolge nicht mehr pruefen. Deshalb verzichtet der Default auf die Sequenz. +-- +-- 0 ist nur ein Platzhalter, damit `prisma.protokoll.create` ohne ID auskommt; der Trigger +-- ueberschreibt jede ID (auch eine mitgegebene). Die Sequenz bleibt der Spalte zugeordnet +-- (OWNED BY), deshalb findet `pg_get_serial_sequence` sie im Trigger weiterhin. +-- +-- Bestehende Zeilen bleiben, wie sie sind: Die ID ist Teil der Hash-Eingabe, Umnummerieren +-- wuerde die Kette brechen. Die alten Luecken bleiben also stehen. +ALTER TABLE "Protokoll" ALTER COLUMN "id" SET DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 07af2e3..f7f103e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -483,7 +483,9 @@ model Vorgang { /// Revisionssicheres Protokoll (#005). Append-only per Trigger; `hash` verkettet jede Zeile /// mit ihrer Vorgaengerin und wird in der Datenbank berechnet. model Protokoll { - id BigInt @id @default(autoincrement()) + /// Vergibt der Trigger `protokoll_verketten` unter der Advisory-Sperre aus der Sequenz; der + /// Default 0 ist nur ein Platzhalter, damit `create` ohne ID auskommt (#032). + id BigInt @id @default(0) zeitpunkt DateTime @default(now()) akteurId String? /// Name zum Zeitpunkt der Aktion – bleibt lesbar, auch wenn das Konto spaeter umbenannt wird. diff --git a/tests/datenbankregeln.test.ts b/tests/datenbankregeln.test.ts index 6e3b69b..a78b077 100644 --- a/tests/datenbankregeln.test.ts +++ b/tests/datenbankregeln.test.ts @@ -236,8 +236,30 @@ describe("Protokoll", () => { expect(pruefung?.ergebnis).toBe(zweiter.id); }); + it("vergibt IDs ohne Zweierschritte (#032)", async () => { + // Frueher zogen Spalten-Default und Trigger je eine Nummer aus der Sequenz. + const [a, b, c] = [await eintragen("eins"), await eintragen("zwei"), await eintragen("drei")]; + expect(b.id).toBe(a.id + 1n); + expect(c.id).toBe(a.id + 2n); + const [pruefung] = await prisma.$queryRaw< + { ergebnis: bigint | null }[] + >`SELECT protokoll_kette_pruefen() AS ergebnis`; + expect(pruefung?.ergebnis).toBeNull(); + }); + + it("ueberschreibt eine mitgegebene ID unter der Sperre (#032)", async () => { + const erster = await eintragen("eins"); + const zweiter = await prisma.protokoll.create({ + data: { id: 999n, akteurName: "Test", aktion: "zwei", ergebnis: "ERFOLG" }, + }); + expect(zweiter.id).toBe(erster.id + 1n); + expect(zweiter.vorgaengerHash).toBe(erster.hash); + }); + it("bleibt bei gleichzeitigem Schreiben eine lueckenlose Kette", async () => { - await Promise.all(Array.from({ length: 20 }, (_, i) => eintragen(`parallel-${i}`))); + const zeilen = await Promise.all(Array.from({ length: 20 }, (_, i) => eintragen(`parallel-${i}`))); + const ids = zeilen.map((z) => z.id).sort((x, y) => (x < y ? -1 : 1)); + expect(ids.at(-1)! - ids[0]!).toBe(19n); const [pruefung] = await prisma.$queryRaw< { ergebnis: bigint | null }[] >`SELECT protokoll_kette_pruefen() AS ergebnis`; diff --git a/tests/protokoll-ansicht.test.ts b/tests/protokoll-ansicht.test.ts index 9c2c649..653adae 100644 --- a/tests/protokoll-ansicht.test.ts +++ b/tests/protokoll-ansicht.test.ts @@ -75,7 +75,7 @@ beforeEach(async () => { describe("protokollSeite", () => { it("blaettert ueber die ID, neueste zuerst, ohne Luecken und Doppelte", async () => { - // IDs sind nicht lueckenlos (der Trigger zieht die Sequenz selbst) – nur die Ordnung zaehlt. + // IDs koennen Luecken haben (Rollbacks, alte Zeilen vor #032) – nur die Ordnung zaehlt. const ids: string[] = []; for (let i = 0; i < SEITENGROESSE + 7; i += 1) ids.unshift(String((await eintragen({ aktion: "host.angelegt" })).id));