/* ===================================================================== Das Löschkonzept (15.09.2026) Stufe 8 aus dem Plan: „Löschkonzept je Datenart · IP-Adressen im Protokoll nach 30 Tagen weg." --------------------------------------------------------------------- BEI ETWAS, DAS LÖSCHT, IST DIE WICHTIGERE HÄLFTE: WAS ES NICHT LÖSCHT Eine Aufräumfunktion, die zu viel mitnimmt, fällt erst auf, wenn jemand etwas sucht, das es nicht mehr gibt — und dann ist es weg. Deshalb steht zu jeder Löschung hier eine Gegenprobe daneben: IP nach 30 Tagen weg → und die von gestern bleibt IP weg → und die ZEILE bleibt (nur das Feld) abgelaufene Sitzung weg → und die gültige bleibt abgelaufene Sitzung weg → und ich bin danach noch angemeldet Die letzte ist die unbequemste und die wichtigste: Ein Aufräumlauf, der die eigene Anmeldung mitnimmt, wirft im Betrieb alle hinaus. Aufrufen mit: node server/pruef-aufbewahrung.mjs ===================================================================== */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { request as httpAnfrage } from "node:http"; import { eigenerPort } from "./helfer-port.mjs"; const PORT = await eigenerPort(import.meta, "pruef-aufbewahrung"); const ordner = mkdtempSync(join(tmpdir(), "ws-aufb-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); process.env.PORT = String(PORT); process.env.SITE_ACCESS_SECRET = "lokaler-test"; process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; const express = (await import("express")).default; const ec = express.response.cookie; express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; import { notbremse } from "./helfer-notbremse.mjs"; await import("./index.js"); notbremse(180_000, "pruef-aufbewahrung"); await new Promise((r) => setTimeout(r, 700)); process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); let fehler = 0, geprueft = 0; const melde = (t) => console.log(t); const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; const CREW = "crew.dogfather-universe.com"; const HAUS = "workspace.dogfather-universe.com"; const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); const modul = await import("./workspace-aufbewahrung.js"); const oeffne = () => new DatabaseSync(process.env.WORKSPACE_DB); const vorTagen = (n) => new Date(Date.now() - n * 86400000).toISOString(); const inTagen = (n) => new Date(Date.now() + n * 86400000).toISOString(); { const d = oeffne(); const jetzt = new Date().toISOString(); const schluessel = randomBytes(32).toString("hex"); d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") .run("code_kennung_schluessel", schluessel, jetzt); const anlegen = (name, rolle, code) => { const salt = randomBytes(16).toString("hex"); const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + " VALUES (?,?,?,?,?,?,1,?)") .run(name, rolle, hash, salt, 32768, createHmac("sha256", schluessel).update(code).digest("hex"), jetzt); return d.prepare("SELECT last_insert_rowid() AS id").get().id; }; anlegen("Filipe", "admin", "CODE-DOGI-0001"); anlegen("Rieke", "hand", "CODE-HAND-0001"); d.close(); } function roh(pfad, host, kopf = {}, koerper = null, methode = null) { if (methode === "DELETE") koerper = null; return new Promise((fertig, schief) => { const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: pfad, method: methode || (koerper ? "POST" : "GET"), headers: { Host: host, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf }, }, (antwort) => { let text = ""; antwort.on("data", (s) => { text += s; }); antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers })); }); a.on("error", schief); if (koerper) a.write(JSON.stringify(koerper)); a.end(); }); } const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } }; async function anmelden(rolle, code, host) { const r = await roh("/workspace/api/anmelden", host, {}, { rolle, code }); return [].concat(r.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "); } const kDogi = await anmelden("admin", "CODE-DOGI-0001", HAUS); const kHand = await anmelden("hand", "CODE-HAND-0001", CREW); ok(!!kDogi && !!kHand, "DogFather und die rechte Hand sind angemeldet"); /* ======================================================================= 1. DIE TAFEL IST VOLLSTÄNDIG ======================================================================= */ melde(""); melde("=== 1. Die Tafel ==="); { const f = modul.FRISTEN; ok(f.length >= 5, `${f.length} Datenarten im Löschkonzept`); ok(new Set(f.map((x) => x.schluessel)).size === f.length, "kein Schlüssel doppelt"); const unvollstaendig = f.filter((x) => !x.was || !x.frist || !x.grundlage || !x.wirkung || !x.art); ok(unvollstaendig.length === 0, unvollstaendig.length ? `UNVOLLSTÄNDIG: ${unvollstaendig.map((x) => x.schluessel).join(", ")}` : "jede Datenart nennt Frist, Rechtsgrundlage und Wirkung"); const arten = new Set(f.map((x) => x.art)); ok([...arten].every((a) => ["raeumen", "fremd", "bleibt"].includes(a)), `nur die drei bekannten Arten (${[...arten].join(", ")})`); /* Ein Loeschkonzept braucht beides: was geht und was bleibt. Stuende hier nur „raeumen", waere es eine Aufraeumliste, kein Konzept. */ ok(arten.has("raeumen") && arten.has("bleibt"), "es gibt sowohl Gelöschtes als auch begründet Aufgehobenes"); ok(f.every((x) => typeof x.offen === "function"), "jede Zeile kann sagen, wie viele Datensätze sie betrifft"); ok(f.filter((x) => x.art === "raeumen").every((x) => typeof x.raeumen === "function"), "und jede, die räumt, kann es auch"); ok(f.filter((x) => x.art !== "raeumen").every((x) => typeof x.raeumen !== "function"), "was nicht räumen soll, kann es auch nicht — „bleibt“ ist keine Absichtserklärung"); } /* ======================================================================= 2. IP-ADRESSEN NACH 30 TAGEN — UND DIE VON GESTERN BLEIBT ======================================================================= */ melde(""); melde("=== 2. IP-Adressen im Protokoll ==="); { const d = oeffne(); const rein = d.prepare( "INSERT INTO protokoll (zeitpunkt, person_id, rolle, aktion, detail, ip) VALUES (?,?,?,?,?,?)"); rein.run(vorTagen(modul.IP_TAGE + 10), null, null, "test_alt", "ALT", "203.0.113.7"); rein.run(vorTagen(modul.IP_TAGE - 1), null, null, "test_jung", "JUNG", "203.0.113.8"); rein.run(vorTagen(modul.IP_TAGE + 10), null, null, "test_ohne", "OHNE", null); d.close(); const vorher = json(await roh("/workspace/api/aufbewahrung", HAUS, { cookie: kDogi })); const zeile = (vorher.fristen || []).find((z) => z.schluessel === "protokoll_ip"); ok(zeile && zeile.anzahl >= 1, `${zeile?.anzahl} IP(s) warten auf das Löschen`); const lauf = await roh("/workspace/api/aufbewahrung/jetzt", HAUS, { cookie: kDogi }, {}); ok(lauf.code === 200, `der Lauf geht durch (${lauf.code})`); const getan = json(lauf).getan || []; ok(getan.some((g) => g.schluessel === "protokoll_ip" && g.anzahl >= 1), `und meldet, was er getan hat (${JSON.stringify(getan)})`); const d2 = oeffne(); const alt = d2.prepare("SELECT ip FROM protokoll WHERE aktion = 'test_alt'").get(); const jung = d2.prepare("SELECT ip FROM protokoll WHERE aktion = 'test_jung'").get(); const zeilen = d2.prepare("SELECT COUNT(*) AS n FROM protokoll WHERE aktion LIKE 'test_%'").get().n; d2.close(); ok(alt && alt.ip === null, "die IP von vor 40 Tagen ist weg"); /* GEGENPROBE 1 -- ohne sie beweist die Zeile darüber nichts: Eine Funktion, die ALLE IPs leert, bestünde sie genauso. */ ok(jung && jung.ip === "203.0.113.8", `die von vor ${modul.IP_TAGE - 1} Tagen steht noch da (${jung?.ip})`); /* GEGENPROBE 2: Geleert wird das FELD, nicht die Zeile. Das Protokoll ist die Sicherung gegen stille Änderungen -- ein Aufräumlauf, der Einträge entfernt, hebelt genau die aus. */ ok(zeilen === 3, `alle ${zeilen} Protokollzeilen stehen noch (nur das Feld wurde geleert)`); const nachher = json(await roh("/workspace/api/aufbewahrung", HAUS, { cookie: kDogi })); const zeile2 = (nachher.fristen || []).find((z) => z.schluessel === "protokoll_ip"); ok(zeile2 && zeile2.anzahl === 0, `danach wartet keine mehr (${zeile2?.anzahl})`); } /* ======================================================================= 3. ABGELAUFENE ANMELDUNGEN — UND MEINE EIGENE BLEIBT ======================================================================= */ melde(""); melde("=== 3. Abgelaufene Anmeldungen ==="); { const d = oeffne(); const vorher = d.prepare("SELECT COUNT(*) AS n FROM sitzungen").get().n; d.prepare("INSERT INTO sitzungen (token_hash, person_id, erstellt, gueltig_bis, ip, browser)" + " VALUES (?,?,?,?,?,?)") .run("test-abgelaufen", 1, vorTagen(3), vorTagen(1), "203.0.113.9", "Testbrowser"); d.prepare("INSERT INTO sitzungen (token_hash, person_id, erstellt, gueltig_bis, ip, browser)" + " VALUES (?,?,?,?,?,?)") .run("test-gueltig", 1, new Date().toISOString(), inTagen(1), "203.0.113.10", "Testbrowser"); d.close(); ok(vorher >= 2, `${vorher} echte Anmeldungen vorhanden (meine eigene ist dabei)`); const lauf = json(await roh("/workspace/api/aufbewahrung/jetzt", HAUS, { cookie: kDogi }, {})); ok((lauf.getan || []).some((g) => g.schluessel === "sitzungen_abgelaufen" && g.anzahl === 1), `genau eine abgelaufene Anmeldung geräumt (${JSON.stringify(lauf.getan)})`); const d2 = oeffne(); const weg = d2.prepare("SELECT 1 FROM sitzungen WHERE token_hash = 'test-abgelaufen'").get(); const da = d2.prepare("SELECT 1 FROM sitzungen WHERE token_hash = 'test-gueltig'").get(); const uebrig = d2.prepare("SELECT COUNT(*) AS n FROM sitzungen").get().n; d2.close(); ok(!weg, "die abgelaufene ist weg"); ok(!!da, "die gültige steht noch da"); ok(uebrig === vorher + 1, `und alle echten auch (${uebrig} statt ${vorher + 1})`); /* DIE UNBEQUEMSTE GEGENPROBE: Bin ich danach noch angemeldet? Ein Aufräumlauf, der die eigene Sitzung mitnimmt, wirft im Betrieb alle hinaus -- und zwar genau einmal täglich. */ const ich = await roh("/workspace/api/ich", HAUS, { cookie: kDogi }); ok(ich.code === 200, `ich bin nach dem Lauf noch angemeldet (${ich.code})`); const sie = await roh("/workspace/api/ich", CREW, { cookie: kHand }); ok(sie.code === 200, `die rechte Hand auch (${sie.code})`); } /* ======================================================================= 4. WER DIE TAFEL SEHEN DARF ======================================================================= */ melde(""); melde("=== 4. Wer sie sieht ==="); { const hand = await roh("/workspace/api/aufbewahrung", CREW, { cookie: kHand }); ok(hand.code === 404, `die rechte Hand bekommt 404 statt 403 (${hand.code})`); ok(!/Rechtsgrundlage|Art\. 6/.test(hand.text), "und kein Wort aus der Tafel"); const ohne = await roh("/workspace/api/aufbewahrung", HAUS, {}); ok(ohne.code === 401, `ohne Anmeldung 401 (${ohne.code})`); const laufFremd = await roh("/workspace/api/aufbewahrung/jetzt", CREW, { cookie: kHand }, {}); ok(laufFremd.code === 404, `und niemand sonst stößt den Lauf an (${laufFremd.code})`); /* GEGENPROBE: DogFather kann beides -- sonst wäre „404“ oben auch dann grün, wenn es den Weg gar nicht gibt. */ const chef = await roh("/workspace/api/aufbewahrung", HAUS, { cookie: kDogi }); ok(chef.code === 200, `DogFather schon (${chef.code})`); } /* ======================================================================= 5. DER LAUF HINTERLÄSST EINE SPUR ======================================================================= */ melde(""); melde("=== 5. Nachvollziehbar ==="); { const d = oeffne(); const spur = d.prepare( "SELECT COUNT(*) AS n FROM protokoll WHERE aktion LIKE 'aufbewahrung%'").get().n; d.close(); ok(spur >= 1, `${spur} Protokolleinträge über das Aufräumen selbst`); const tafel = json(await roh("/workspace/api/aufbewahrung", HAUS, { cookie: kDogi })); ok(tafel.letzte && tafel.letzte.zeitpunkt, `der letzte Lauf steht in der Tafel (${tafel.letzte?.zeitpunkt?.slice(0, 16)})`); ok(Array.isArray(tafel.letzte?.fehler) && tafel.letzte.fehler.length === 0, "und er lief ohne Fehler"); /* EIN ZWEITER LAUF DARF NICHTS MEHR FINDEN -- und muss das auch sagen dürfen. Eine Funktion, die jedes Mal „aufgeräumt" meldet, ohne dass etwas da war, wäre nicht zu unterscheiden von einer, die nichts tut. */ const zweiter = json(await roh("/workspace/api/aufbewahrung/jetzt", HAUS, { cookie: kDogi }, {})); ok((zweiter.getan || []).length === 0, `der zweite Lauf findet nichts mehr (${JSON.stringify(zweiter.getan)})`); } /* ======================================================================= 6. DRITTER AUSGANG: WAS, WENN EINE TABELLE FEHLT? ======================================================================= */ melde(""); melde("=== 6. Konnte nicht nachsehen ==="); { /* Eine Zählung, die scheitert, muss `null` liefern -- nicht 0. „Null Datensätze betroffen" und „konnte nicht nachsehen" sind zwei verschiedene Auskünfte, und die zweite darf nicht wie die erste aussehen. */ const d = oeffne(); const erfunden = { offen: (dd) => { try { return dd.prepare("SELECT COUNT(*) AS n FROM gibt_es_nicht").get()?.n ?? null; } catch { return null; } }, }; const wert = erfunden.offen(d); d.close(); ok(wert === null, `eine Zählung über eine fehlende Tabelle ergibt null, nicht 0 (${wert})`); const tafel = json(await roh("/workspace/api/aufbewahrung", HAUS, { cookie: kDogi })); const zahlen = (tafel.fristen || []).map((z) => z.anzahl); ok(zahlen.length === modul.FRISTEN.length && zahlen.every((z) => z !== null), `alle ${zahlen.length} Zeilen konnten wirklich zählen (${zahlen.join(", ")})`); } /* ======================================================================= 7. UND STEHT ES AUCH AUF DEM BILDSCHIRM? Ein Loeschkonzept, das nur in einer Schnittstelle antwortet, ist ein Dokument in einer Schublade. Geprueft wird deshalb die Seite selbst -- und der Knopf: Er muss eine Zahl wirklich fallen lassen. ======================================================================= */ melde(""); melde("=== 7. Auf der Seite ==="); { const { chromium } = await import( "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await chromium.launch(); const kontext = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const seite = await kontext.newPage(); const konsole = []; seite.on("console", (m) => { if (m.type() === "error" && !/favicon|ERR_/i.test(m.text())) konsole.push(m.text()); }); seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message)); await seite.goto(`http://127.0.0.1:${PORT}/workspace/`, { waitUntil: "domcontentloaded" }); await seite.click('.rolle[data-rolle="admin"]'); await seite.fill("#code", "CODE-DOGI-0001"); await seite.click("button[type=submit]"); await seite.waitForURL("**/start.html", { timeout: 20000 }); /* Etwas zu raeumen anlegen, DAMIT DER KNOPF ETWAS ZU TUN HAT. Ohne das waere „die Zahl faellt" trivial wahr: sie stand schon auf null. */ { const d = oeffne(); d.prepare( "INSERT INTO protokoll (zeitpunkt, person_id, rolle, aktion, detail, ip) VALUES (?,?,?,?,?,?)") .run(vorTagen(modul.IP_TAGE + 5), null, null, "test_bildschirm", "X", "203.0.113.11"); d.close(); } await seite.goto(`http://127.0.0.1:${PORT}/workspace/automation.html`, { waitUntil: "networkidle" }); await seite.waitForSelector(".aufb", { timeout: 8000 }).catch(() => {}); const stand = await seite.evaluate(() => { const zeilen = [...document.querySelectorAll(".aufb")]; return { anzahl: zeilen.length, arten: [...new Set(zeilen.map((z) => z.dataset.art))].sort(), schilder: [...new Set(zeilen.map((z) => z.querySelector(".aufb__art")?.textContent.trim()))], offen: zeilen.filter((z) => z.querySelector('.aufb__zahl[data-offen="ja"]')).length, text: document.getElementById("aufbewahrung")?.innerText || "", }; }); ok(stand.anzahl === modul.FRISTEN.length, `alle ${stand.anzahl} Datenarten stehen auf der Seite`); /* Die Sorte darf nicht NUR an der Farbe haengen -- wer Farben schlecht sieht, muss dasselbe lesen koennen. */ ok(stand.schilder.some((s) => /gelöscht/i.test(s || "")) && stand.schilder.some((s) => /bleibt/i.test(s || "")), `die Sorte steht als Wort daneben, nicht nur als Farbe (${stand.schilder.join(" / ")})`); ok(stand.offen >= 1, `${stand.offen} Zeile(n) melden offene Datensätze`); ok(/Art\. 6|Art\. 17/.test(stand.text), "die Rechtsgrundlage steht dabei"); /* DER KNOPF MUSS WIRKEN -- gemessen an der Zahl, nicht am Gefuehl. */ await seite.click("#aufb-jetzt"); await seite.waitForFunction( () => !document.querySelector('.aufb__zahl[data-offen="ja"]'), null, { timeout: 8000 }).catch(() => {}); const nachher = await seite.evaluate(() => document.querySelectorAll('.aufb__zahl[data-offen="ja"]').length); ok(nachher === 0, `nach dem Knopf wartet nichts mehr (${nachher})`); const d3 = oeffne(); const weg = d3.prepare("SELECT ip FROM protokoll WHERE aktion = 'test_bildschirm'").get(); d3.close(); ok(weg && weg.ip === null, "und die IP ist in der Datenbank wirklich weg"); ok(konsole.length === 0, konsole.length ? "Fehler im Browser: " + konsole[0].slice(0, 90) : "keine Fehler im Browser"); await browser.close(); } /* ===================================================================== WAS ZU ENDE GEHT, MUSS IM KONZEPT STEHEN (20.09.2026) So ist `hilfe_faelle` herausgefallen: Die Tabelle kam am 19.09. dazu, das Löschkonzept wurde am 15.09. geschrieben, und nichts hat die beiden je miteinander verglichen. In `hilfe_nachrichten` stehen die empfindlichsten Texte des Hauses; sie wären unbegrenzt liegen geblieben. Die zugehörige Aufräumfunktion gab es sogar -- nur rief sie niemand auf. --------------------------------------------------------------------- WARUM NICHT „JEDE TABELLE MUSS DRINSTEHEN" Gemessen: 46 Tabellen, davon 41 mit Bezug auf `personen`. Eine Regel „alle müssen genannt sein" ergäbe 38 Meldungen, von denen fast alle richtig wären -- sie sind über `personen_geloescht` abgedeckt, den Eintrag, der sagt: Wer gelöscht wird, nimmt seine Daten mit, und die Datenbank erzwingt das. Eine Prüfung, die 38-mal meldet, wo 1-mal richtig wäre, wird abgeschaltet. Das ist kein besseres Ergebnis als gar keine. --------------------------------------------------------------------- DAS SCHMALE, TREFFSICHERE MERKMAL Gesucht wird nach dem, was `hilfe_faelle` von den anderen 45 unterschied: eine Spalte, die sagt „hier ist etwas zu Ende" (`geschlossen_am`, `erledigt_am`, `beendet_am` …). Solche Zeilen laufen von selbst ab -- unabhängig davon, ob die Person noch da ist. Für sie MUSS jemand entschieden haben, ob sie verschwinden oder bleiben. Gemessen: Genau ZWEI Tabellen im ganzen Haus tragen so eine Spalte. Beide sind jetzt im Konzept -- eine mit Frist, eine ausdrücklich ohne. Die dritte, die morgen dazukommt, macht diese Prüfung rot. ===================================================================== */ melde(""); melde("=== Was zu Ende geht, steht im Konzept ==="); { const d = oeffne(); try { const tabellen = d.prepare( "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") .all().map((r) => r.name); /* Welche Tabellen nennt das Konzept? ABGELEITET aus dem SQL der Einträge, nicht aus einer zweiten Liste -- sonst hätte man genau das Problem noch einmal, nur eine Ebene höher. */ const genannt = new Set(); for (const f of modul.FRISTEN) { for (const fn of [f.offen, f.raeumen]) { if (typeof fn !== "function") continue; for (const m of String(fn).matchAll(/(?:FROM|UPDATE|INTO)\s+([a-z_]+)/gi)) { genannt.add(m[1]); } } } const ENDE = /^(geschlossen|beendet|erledigt|abgeschlossen|abgelaufen|archiviert)_am$/; const mitEnde = tabellen.filter((t) => d.prepare(`PRAGMA table_info(${t})`).all().some((c) => ENDE.test(c.name))); /* DIE ZAHL GEHÖRT IN DIE BEDINGUNG: Fände die Prüfung gar keine solche Tabelle (etwa weil sich die Schreibweise ändert), wäre „alle gedeckt" trivialerweise wahr -- und sie schwiege genau dann, wenn sie reden müsste. */ ok(mitEnde.length >= 2, `es gibt ${mitEnde.length} Tabellen, deren Zeilen von selbst zu Ende gehen ` + `(${mitEnde.join(", ")})`); const fehlend = mitEnde.filter((t) => !genannt.has(t)); ok(fehlend.length === 0, fehlend.length ? `NICHT im Löschkonzept: ${fehlend.join(", ")} — für jede dieser Tabellen ` + `muss jemand entschieden haben, ob sie verschwindet oder bleibt` : `und jede davon steht im Löschkonzept (${mitEnde.length} von ${mitEnde.length})`); /* Und der Meldeweg ausdrücklich, weil er der Anlass war. */ ok(genannt.has("hilfe_faelle"), "der vertrauliche Meldeweg ist darunter — er war der Anlass für diese Prüfung"); /* GEGENPROBE: Eine erfundene Tabelle mit Abschluss-Spalte muss auffallen. Ohne sie wüsste niemand, ob die Prüfung überhaupt hinsieht. */ d.exec("CREATE TABLE IF NOT EXISTS probe_ablauf (id INTEGER PRIMARY KEY, beendet_am TEXT)"); const nochmal = d.prepare( "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") .all().map((r) => r.name) .filter((t) => d.prepare(`PRAGMA table_info(${t})`).all().some((c) => ENDE.test(c.name))) .filter((t) => !genannt.has(t)); ok(nochmal.includes("probe_ablauf"), `Gegenprobe: eine erfundene Tabelle mit Abschluss-Spalte fällt auf (${nochmal.join(", ")})`); d.exec("DROP TABLE probe_ablauf"); } finally { d.close(); } } /* ------------------------------------------------------------------- */ melde(""); melde(`${geprueft} Pruefungen, ${fehler} Fehler`); melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } process.exit(fehler ? 1 : 0);