diff --git a/server/pruef-umstellung.mjs b/server/pruef-umstellung.mjs new file mode 100644 index 00000000..55cbe9ef --- /dev/null +++ b/server/pruef-umstellung.mjs @@ -0,0 +1,225 @@ +/* ===================================================================== + 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); diff --git a/server/workspace.js b/server/workspace.js index 4de4050d..4595959f 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -3068,92 +3068,42 @@ function umstellungen(d) { um "AND nicht abgebrochen" zu ergaenzen, und die eine vergessene waere ein stiller Fehler gewesen: Die Aufgabe stuende weiter im Brett, und niemand wuesste warum. */ - const aufgabenPlan = d.prepare( - "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'aufgaben'").get()?.sql || ""; - if (aufgabenPlan && !aufgabenPlan.includes("'abgebrochen'")) { - const sicherung = `${DB_PFAD}.vor-abbruch-${jetztStempel}`; - try { - d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`); - console.log("[workspace] Sicherung vor der Umstellung:", sicherung); - } catch (fehler) { - console.error("[workspace] Sicherung fehlgeschlagen, Umstellung abgebrochen:", fehler?.message); - return; - } + /* ---- BIS ZUM 21.09.2026 STAND HIER EIN NEUBAU VON HAND ---- - d.exec("PRAGMA foreign_keys = OFF"); - try { - /* Die Spalten stehen hier vollstaendig, weil die Schleife oben - sie zu diesem Zeitpunkt schon ergaenzt hat. Wer hier eine - vergisst, verliert ihren Inhalt still -- deshalb wird nach dem - Tausch die Zeilenzahl verglichen. */ - const vorher = d.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n; - d.exec("BEGIN"); - d.exec(` - CREATE TABLE aufgaben_neu ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - titel TEXT NOT NULL, - beschreibung TEXT, - status TEXT NOT NULL DEFAULT 'offen' - CHECK (status IN ('offen','arbeit','review','erledigt','abgebrochen')), - prioritaet TEXT NOT NULL DEFAULT 'mittel' - CHECK (prioritaet IN ('hoch','mittel','niedrig')), - creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, - verantwortlich_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, - frist TEXT, - erstellt TEXT NOT NULL, - erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL, - geaendert TEXT, - erledigt_am TEXT, - creator_extern TEXT, - verantwortlich_extern TEXT, - abbruch_grund TEXT, - abgebrochen_am TEXT, - abbruch_von INTEGER REFERENCES personen(id) ON DELETE SET NULL, - status_vorher TEXT - ); - INSERT INTO aufgaben_neu - (id, titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id, - frist, erstellt, erstellt_von, geaendert, erledigt_am, - creator_extern, verantwortlich_extern, - abbruch_grund, abgebrochen_am, abbruch_von, status_vorher) - SELECT id, titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id, - frist, erstellt, erstellt_von, geaendert, erledigt_am, - creator_extern, verantwortlich_extern, - abbruch_grund, abgebrochen_am, abbruch_von, status_vorher - FROM aufgaben; - DROP TABLE aufgaben; - ALTER TABLE aufgaben_neu RENAME TO aufgaben; - CREATE INDEX IF NOT EXISTS idx_aufgaben_status ON aufgaben (status); - CREATE INDEX IF NOT EXISTS idx_aufgaben_creator ON aufgaben (creator_id); - `); - /* Nach dem RENAME heisst die neue Tabelle wieder "aufgaben" -- - gezaehlt wird also unter dem alten Namen, und der Vergleich - laeuft noch INNERHALB der Transaktion. Stimmt er nicht, ist - ein ROLLBACK noch moeglich. */ - const nachher = d.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n; - /* Stimmt die Zahl nicht, wird NICHT bestaetigt. Lieber laeuft das - Abbrechen noch nicht, als dass eine Aufgabe verschwindet. */ - if (nachher !== vorher) { - d.exec("ROLLBACK"); - console.error(`[workspace] Umstellung abgebrochen: ${vorher} Aufgaben vorher, ` - + `${nachher} nachher. Sicherung: ${sicherung}`); - } else { - d.exec("COMMIT"); - const kaputt = d.prepare("PRAGMA foreign_key_check").all(); - if (kaputt.length) { - console.error("[workspace] ACHTUNG: nach der Umstellung", kaputt.length, - "verwaiste Verweise. Sicherung liegt unter", sicherung); - } else { - console.log(`[workspace] Status 'abgebrochen' freigeschaltet, ${vorher} Aufgaben, Verweise geprueft.`); - } - } - } catch (fehler) { - try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ } - console.error("[workspace] Umstellung 'abgebrochen' fehlgeschlagen:", fehler?.message); - } finally { - d.exec("PRAGMA foreign_keys = ON"); - } - } + Er zaehlte die Spalten einzeln auf -- einmal im CREATE, einmal im + INSERT ... SELECT. Achtzehn Stueck. Die Tabelle hatte zu dem + Zeitpunkt DREIUNDZWANZIG: `vorlage`, `kategorie`, `aus_eintrag_id`, + `aufwand` und `aus_punkt` kamen spaeter dazu und wurden nie + nachgetragen. + + WAS DAS BEDEUTET HAETTE: Wer die Umstellung noch vor sich hat -- + eine zurueckgespielte Sicherung, eine frische Anlage aus einem + alten Stand -- verliert diese fuenf Spalten MIT INHALT. Ohne + Fehlermeldung, bei unveraenderter Zeilenzahl. Die Zaehlung, die + als Sicherung gedacht war, kann einen Spaltenverlust gar nicht + sehen: 40 Zeilen vorher, 40 Zeilen nachher, alles "in Ordnung". + + GENAU DAS IST IN DIESEM HAUS SCHON EINMAL PASSIERT (11.09.2026, + Eintragstabelle: 24 Spalten hinein, 21 heraus). Damals wurde die + Lehre gezogen, die Liste abzuleiten statt zu pflegen -- und + `checkListeErweitern` unten tut genau das (`PRAGMA table_info`). + Dieser Block hier war aelter und wurde bei der Umstellung + uebersehen; eine Liste, die niemand pflegt, kann nicht veralten, + aber eine zweite Fassung daneben eben doch. + + Gefunden hat es nicht das Lesen, sondern pruef-abbrechen: Sie baut + absichtlich eine ALTE Datenbank und laesst die Umstellung darauf + laufen. Danach meldete der Server + "Aufgaben lesen: no such column: a.aufwand" -- und die Pruefung + bekam eine 503 statt einer Liste. + + Auf dem echten Server ist die Umstellung laengst gelaufen (24 + Spalten, CHECK mit 'abgebrochen'); dort ist nichts verloren + gegangen. Der Aufruf unten ist deshalb im Betrieb ein Nulldurchgang + -- er steht hier fuer den Tag, an dem jemand eine Sicherung + zurueckspielt. */ + checkListeErweitern(d, "aufgaben", "status", "abgebrochen", + ["offen", "arbeit", "review", "erledigt", "abgebrochen"], jetztStempel); /* ---- Der sechste Bereich: "agentur" (06.09.2026) ---- @@ -3210,64 +3160,32 @@ function umstellungen(d) { ["live", "content", "technik", "community", "schutz", "agentur"], jetztStempel); /* ---- Rolle "manager" erlauben ---- */ - const bauplan = d.prepare( - "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'personen'").get()?.sql || ""; - if (!bauplan.includes("'manager'")) { - const sicherung = `${DB_PFAD}.vor-manager-${jetztStempel}`; - try { - d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`); - console.log("[workspace] Sicherung vor der Umstellung:", sicherung); - } catch (fehler) { - /* Ohne Sicherung wird NICHT umgestellt. Lieber laeuft der Manager - noch nicht, als dass Daten ohne Netz angefasst werden. */ - console.error("[workspace] Sicherung fehlgeschlagen, Umstellung abgebrochen:", fehler?.message); - return; - } + /* ---- AUCH HIER STAND EIN NEUBAU VON HAND (ersetzt 21.09.2026) ---- - /* Fremdschluessel muessen aus sein, weil andere Tabellen auf - personen(id) zeigen -- und das laesst sich nicht innerhalb einer - Transaktion umschalten. */ - d.exec("PRAGMA foreign_keys = OFF"); - try { - d.exec("BEGIN"); - d.exec(` - CREATE TABLE personen_neu ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - rolle TEXT NOT NULL CHECK (rolle IN ('admin','manager','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 - ); - INSERT INTO personen_neu - (id, name, rolle, code_hash, code_salt, code_n, aktiv, erstellt, letzter_login) - SELECT id, name, rolle, code_hash, code_salt, code_n, aktiv, erstellt, letzter_login - FROM personen; - DROP TABLE personen; - ALTER TABLE personen_neu RENAME TO personen; - `); - d.exec("COMMIT"); + Er zaehlte NEUN Spalten auf. `personen` hat neunzehn. Verloren + gegangen waeren: - /* Nach dem Tausch pruefen, ob die Verweise noch stimmen. Findet - sich etwas, wird das laut gemeldet -- stillschweigend kaputte - Verweise waeren das Schlimmste an dieser Stelle. */ - const kaputt = d.prepare("PRAGMA foreign_key_check").all(); - if (kaputt.length) { - console.error("[workspace] ACHTUNG: nach der Umstellung", kaputt.length, - "verwaiste Verweise. Sicherung liegt unter", sicherung); - } else { - console.log("[workspace] Rolle 'manager' freigeschaltet, Verweise geprueft."); - } - } catch (fehler) { - try { d.exec("ROLLBACK"); } catch { /* schon zurueckgerollt */ } - console.error("[workspace] Umstellung fehlgeschlagen:", fehler?.message); - } finally { - d.exec("PRAGMA foreign_keys = ON"); - } - } + bild, ueber_mich, tiktok, instagram, youtube, twitch, + chat_kachel, code_kennung, stufe, alter_bestaetigt_am + + Das ist die empfindlichste Tabelle des Hauses, und zwei Eintraege + darin sind mehr als Komfort: `bild` sind die Profilfotos, und + `alter_bestaetigt_am` ist die Altersbestaetigung. Waere sie beim + Zurueckspielen einer Sicherung verschwunden, haette niemand eine + Fehlermeldung gesehen -- alle waeren still wieder unbestaetigt + gewesen, auf einer Seite, die genau das nicht sein darf. + + Derselbe Befund wie beim Block darueber, und dieselbe Loesung: + `checkListeErweitern` leitet die Spaltenliste aus + `PRAGMA table_info` ab. Eine Liste, die niemand pflegt, kann nicht + veralten. + + DIE KETTE IST KUMULATIV -- jeder Schritt nennt alle bis dahin + erlaubten Werte, und dieser ist der erste. Deshalb steht er VOR + den Aufrufen fuer spicy/modi/hand/gast weiter unten; die Reihenfolge + ist Teil der Bedeutung, nicht Zufall. */ + checkListeErweitern(d, "personen", "rolle", "manager", + ["admin", "manager", "scout", "creator"], jetztStempel); /* ===================================================================== ROLLE "spicy" (Spicy Media) FREISCHALTEN — 07.09.2026