/* ===================================================================== pruef-umstellung.mjs — Ein Tabellenumbau darf keine Spalte verlieren WARUM ES DIESE PRUEFUNG GIBT (21.09.2026) SQLite kann einen CHECK nicht aendern. Wer einen neuen Wert erlauben will -- eine neue Rolle, einen neuen Status --, muss die Tabelle neu bauen: CREATE, INSERT ... SELECT, DROP, RENAME. Und genau dort wurde in diesem Haus DREIMAL dieselbe Falle gestellt: 11.09.2026 eintraege: 24 Spalten hinein, 21 heraus 21.09.2026 aufgaben: 23 Spalten, 18 aufgezaehlt -> 5 verloren 21.09.2026 personen: 19 Spalten, 9 aufgezaehlt -> 10 verloren Jedes Mal, weil die Spaltenliste von HAND im Code stand -- einmal im CREATE, einmal im INSERT. Wer spaeter eine Spalte ergaenzt, traegt sie dort nicht nach; es gibt keinen Grund, warum er daran denken sollte. WAS DEN FEHLER SO GEFAEHRLICH MACHT: Er ist unsichtbar. Die Zeilenzahl stimmt vorher wie nachher, es gibt keine Fehlermeldung, und die Sicherung, die als Netz gedacht war, zaehlt Zeilen -- sie kann einen Spaltenverlust gar nicht sehen. Bei `personen` waeren unter anderem `bild` (Profilfotos) und `alter_bestaetigt_am` (Altersbestaetigung) weggefallen: Alle waeren still wieder unbestaetigt gewesen. AUF DEM ECHTEN SERVER ist keine dieser Umstellungen mehr offen -- sie sind laengst gelaufen. Die Gefahr trifft den Tag, an dem jemand eine Sicherung zurueckspielt oder eine neue Umgebung aus einem alten Stand aufsetzt. Genau dann, wenn man sich auf das Netz verlaesst. DIESE PRUEFUNG BAUT DESHALB DIE ALTE LAGE NACH: eine Datenbank mit altem CHECK und allen spaeteren Spalten, gefuellt. Danach laeuft die Umstellung, und es wird gezaehlt -- Spalten UND Inhalte. ===================================================================== */ import { mkdtempSync, rmSync, readdirSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { eigenerPort } from "./helfer-port.mjs"; let ok_ = 0, fehl = 0; const ok = (b, text) => { if (b) { ok_++; console.log(" ok " + text); } else { fehl++; console.log(" FEHL " + text); } }; const ordner = mkdtempSync(join(tmpdir(), "ws-umstellung-")); const alt = join(ordner, "alt.db"); process.env.WORKSPACE_DB = alt; const PORT = await eigenerPort(import.meta, "pruef-umstellung"); process.env.PORT = String(PORT); process.env.SITE_ACCESS_SECRET = "lokaler-test"; /* ======================================================================= 1. Die alte Lage nachbauen ======================================================================= */ console.log("\n=== 1. Eine Datenbank, wie sie VOR der Umstellung aussah ==="); /* Der CHECK kennt weder 'manager' noch die spaeteren Rollen -- aber die Spalten, die im Laufe der Zeit dazugekommen sind, sind da und GEFUELLT. Genau diese Mischung entsteht beim Zurueckspielen: Die ALTER-TABLE-Nachtraege laufen bei jedem Start, der Tabellenumbau nur einmal. */ const SPAETER = [ ["bild", "TEXT", "'abc123.jpg'"], ["ueber_mich", "TEXT", "'Ich streame seit 2024.'"], ["tiktok", "TEXT", "'@luna_live'"], ["instagram", "TEXT", "'luna.insta'"], ["youtube", "TEXT", "'@lunayt'"], ["twitch", "TEXT", "'luna_tv'"], ["chat_kachel", "TEXT", "'moos'"], ["code_kennung", "TEXT", "'LUNA-7'"], ["stufe", "TEXT", "'zwei'"], ["alter_bestaetigt_am", "TEXT", "'2026-09-01T10:00:00.000Z'"], ]; { const a = new DatabaseSync(alt); a.exec(` PRAGMA foreign_keys = ON; CREATE TABLE personen ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, rolle TEXT NOT NULL CHECK (rolle IN ('admin','scout','creator')), code_hash TEXT NOT NULL, code_salt TEXT NOT NULL, code_n INTEGER NOT NULL, aktiv INTEGER NOT NULL DEFAULT 1, erstellt TEXT NOT NULL, letzter_login TEXT); CREATE INDEX idx_personen_rolle ON personen (rolle); `); for (const [name, typ] of SPAETER) { a.exec(`ALTER TABLE personen ADD COLUMN ${name} ${typ}`); } const jetzt = new Date().toISOString(); a.prepare(`INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, erstellt, ${SPAETER.map((x) => x[0]).join(", ")}) VALUES ('Luna','creator','h','s',32768,?, ${SPAETER.map((x) => x[2]).join(", ")})`).run(jetzt); /* Eine zweite Person, damit die Zeilenzaehlung nicht zufaellig stimmt: Bei EINER Zeile waere "1 vorher, 1 nachher" auch dann richtig, wenn der Umbau die falsche Zeile kopiert haette. */ a.prepare(`INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, erstellt) VALUES ('Filipe','admin','h2','s2',32768,?)`).run(jetzt); const vorher = a.prepare("PRAGMA table_info(personen)").all().map((z) => z.name); ok(vorher.length === 9 + SPAETER.length, `die alte Tabelle hat ${vorher.length} Spalten (9 alte + ${SPAETER.length} nachgetragene)`); const regel = a.prepare( "SELECT sql FROM sqlite_master WHERE name = 'personen'").get()?.sql || ""; ok(!regel.includes("'manager'"), "und ihr CHECK kennt 'manager' noch nicht -- die Umstellung steht also aus"); a.close(); } /* ======================================================================= 2. Den Server starten -- er stellt beim Hochfahren um ======================================================================= */ console.log("\n=== 2. Nach der Umstellung ==="); await import("./index.js"); await new Promise((r) => setTimeout(r, 1500)); { const a = new DatabaseSync(alt); const spalten = a.prepare("PRAGMA table_info(personen)").all().map((z) => z.name); /* DER EIGENTLICHE BEWEIS. Nicht "es gibt viele Spalten", sondern "es fehlt keine" -- mit Namen, damit die Meldung beim Scheitern sagt, WELCHE. Eine Zahl allein ("erwartet 19, gefunden 9") laesst den Suchenden allein. */ const fehlend = SPAETER.map((x) => x[0]).filter((n) => !spalten.includes(n)); ok(fehlend.length === 0, `keine der ${SPAETER.length} nachgetragenen Spalten ist verschwunden` + (fehlend.length ? ` -- ES FEHLEN: ${fehlend.join(", ")}` : "")); /* UND IHR INHALT. Eine Spalte, die den Umbau ueberlebt, aber leer ankommt, ist derselbe Verlust mit besserem Aussehen: Das passiert, sobald sie im CREATE steht, aber im INSERT fehlt. */ const luna = a.prepare("SELECT * FROM personen WHERE name = 'Luna'").get() || {}; const leer = SPAETER.filter(([n]) => luna[n] === null || luna[n] === undefined) .map(([n]) => n); ok(leer.length === 0, "und keine steht plötzlich leer da" + (leer.length ? ` -- LEER: ${leer.join(", ")}` : "")); ok(luna.alter_bestaetigt_am === "2026-09-01T10:00:00.000Z", `die Altersbestaetigung steht unveraendert (${luna.alter_bestaetigt_am || "WEG"})`); ok(luna.bild === "abc123.jpg", `und das Profilfoto auch (${luna.bild || "WEG"})`); const n = a.prepare("SELECT COUNT(*) AS n FROM personen").get().n; ok(n === 2, `beide Zeilen sind noch da (${n})`); const regel = a.prepare( "SELECT sql FROM sqlite_master WHERE name = 'personen'").get()?.sql || ""; for (const r of ["manager", "spicy", "modi", "hand", "gast"]) { ok(regel.includes(`'${r}'`), `der CHECK erlaubt jetzt '${r}'`); } /* Die Indizes haengen an der Tabelle und gehen beim DROP mit. Ohne sie laeuft alles weiter -- nur langsamer, und das faellt niemandem auf. */ const idx = a.prepare( "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='personen'") .all().map((z) => z.name).filter((x) => x.startsWith("idx_")); ok(idx.includes("idx_personen_rolle"), `der Index hat den Umbau ueberlebt (${idx.join(", ") || "keiner"})`); const kaputt = a.prepare("PRAGMA foreign_key_check").all(); ok(kaputt.length === 0, `keine verwaisten Verweise (${kaputt.length})`); a.close(); } /* ======================================================================= 3. Niemand zaehlt Spalten mehr von Hand ======================================================================= */ console.log("\n=== 3. Keine abgeschriebene Spaltenliste im Haus ==="); { const vonHand = []; for (const f of readdirSync(new URL(".", import.meta.url))) { if (!f.endsWith(".js")) continue; const s = readFileSync(new URL(f, import.meta.url), "utf8"); /* Kommentare zaehlen nicht -- in workspace.js steht der alte Fehler als Beleg beschrieben, und ein Beleg ist kein Code. */ const code = s.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); for (const m of code.matchAll(/INSERT INTO\s+(\w+)_neu\s*\(?/g)) vonHand.push(`${f}: ${m[1]}`); } ok(vonHand.length === 0, `kein Tabellenumbau zaehlt seine Spalten von Hand auf (${vonHand.join(", ") || "—"})`); const ws = readFileSync(new URL("./workspace.js", import.meta.url), "utf8"); const abgeleitet = (ws.match(/checkListeErweitern\(d,/g) || []).length; ok(abgeleitet >= 15, `${abgeleitet} Umstellungen leiten ihre Spalten aus PRAGMA table_info ab`); } /* ======================================================================= 4. Gegenprobe -- kann diese Pruefung auch NICHT IN ORDNUNG sagen? ======================================================================= */ console.log("\n=== 4. Gegenprobe (muss anschlagen) ==="); { /* Der Verlust wird hier ABSICHTLICH nachgestellt: ein Umbau, der eine Spalte auslaesst. Wuerde die Zaehlung oben ihn nicht bemerken, waere sie eine Beruhigung ohne Aussage -- und genau so ist der Fehler dreimal durchgerutscht. */ const probe = join(ordner, "gegenprobe.db"); const b = new DatabaseSync(probe); b.exec(` CREATE TABLE t (id INTEGER PRIMARY KEY, a TEXT, b TEXT); INSERT INTO t (a, b) VALUES ('eins', 'zwei'); CREATE TABLE t_neu (id INTEGER PRIMARY KEY, a TEXT, b TEXT); INSERT INTO t_neu (id, a) SELECT id, a FROM t; DROP TABLE t; ALTER TABLE t_neu RENAME TO t; `); const zeilen = b.prepare("SELECT COUNT(*) AS n FROM t").get().n; const zeile = b.prepare("SELECT * FROM t").get(); ok(zeilen === 1, "die Zeilenzahl stimmt nach dem fehlerhaften Umbau (1) -- sie sagt nichts"); ok(zeile.b === null, "ABER der Inhalt von 'b' ist weg -- genau das findet die Pruefung oben"); b.close(); } console.log(`\n${ok_} Pruefungen, ${fehl} Fehler`); console.log(fehl ? "NICHT IN ORDNUNG\n" : "ALLES IN ORDNUNG\n"); process.exit(fehl ? 1 : 0);