import { existsSync, mkdirSync } from "node:fs"; import { dirname } from "node:path"; import Database from "better-sqlite3"; import { drizzle } from "drizzle-orm/better-sqlite3"; import * as schema from "./schema.js"; const databasePath = process.env.DATABASE_PATH ?? "./data/launchpad.db"; // Stellt sicher, dass das Verzeichnis für die SQLite-Datei existiert // (relevant im Docker-Volume /data). const dir = dirname(databasePath); if (dir !== "." && !existsSync(dir)) { mkdirSync(dir, { recursive: true }); } const sqlite = new Database(databasePath); sqlite.pragma("journal_mode = WAL"); sqlite.pragma("foreign_keys = ON"); export const db = drizzle(sqlite, { schema }); /** * Legt die Tabellen an, falls sie noch nicht existieren. * Für den ersten Commit reicht ein einfaches "CREATE TABLE IF NOT EXISTS", * spätere Commits ersetzen dies durch echte Drizzle-Migrationen (drizzle-kit). */ export function ensureSchema(): void { sqlite.exec(` CREATE TABLE IF NOT EXISTS devices ( id TEXT PRIMARY KEY, hostname TEXT NOT NULL, ip TEXT NOT NULL, mac TEXT, manufacturer TEXT, model TEXT, online INTEGER NOT NULL DEFAULT 0, source TEXT NOT NULL, last_scan TEXT, last_ping TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS services ( id TEXT PRIMARY KEY, device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, display_name TEXT NOT NULL, category TEXT, favorite INTEGER NOT NULL DEFAULT 0, "order" REAL NOT NULL DEFAULT 0, alias TEXT NOT NULL DEFAULT '[]', icon TEXT, hostname TEXT NOT NULL, url TEXT NOT NULL, https INTEGER NOT NULL DEFAULT 0, visible INTEGER NOT NULL DEFAULT 1, display_name_edited_manually INTEGER NOT NULL DEFAULT 0, category_edited_manually INTEGER NOT NULL DEFAULT 0, last_suggested_display_name TEXT, last_suggested_category TEXT, port INTEGER NOT NULL, favicon TEXT, description TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS categories ( id TEXT PRIMARY KEY, name TEXT NOT NULL, "order" REAL NOT NULL DEFAULT 0, color TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS scan_logs ( id TEXT PRIMARY KEY, type TEXT NOT NULL, target_id TEXT, level TEXT NOT NULL, message TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS bookmarks ( id TEXT PRIMARY KEY, url TEXT NOT NULL, display_name TEXT NOT NULL, hostname TEXT NOT NULL, description TEXT, category TEXT, icon TEXT, favicon TEXT, favorite INTEGER NOT NULL DEFAULT 0, alias TEXT NOT NULL DEFAULT '[]', "order" REAL NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS recent_visits ( id TEXT PRIMARY KEY, item_type TEXT NOT NULL, item_id TEXT NOT NULL, visited_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS app_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS read_later ( id TEXT PRIMARY KEY, url TEXT NOT NULL, display_name TEXT NOT NULL, favicon TEXT, saved_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS detected_apis ( id TEXT PRIMARY KEY, service_id TEXT NOT NULL REFERENCES services(id) ON DELETE CASCADE, path TEXT NOT NULL, type TEXT NOT NULL, status INTEGER NOT NULL, detected_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS scan_dismissals ( id TEXT PRIMARY KEY, kind TEXT NOT NULL, dedupe_key TEXT NOT NULL, dismissed_at TEXT NOT NULL ); `); // Leichte Migration für Datenbanken, die vor Einführung von "visible"/ // "color" angelegt wurden: CREATE TABLE IF NOT EXISTS rüstet bei bereits // existierenden Tabellen keine neuen Spalten nach, das übernehmen wir hier // manuell. Bestehende Dienste werden dabei auf sichtbar (1) gesetzt, damit // sich am bisherigen Verhalten nichts unerwartet ändert. ensureColumn("services", "visible", "INTEGER NOT NULL DEFAULT 1"); ensureColumn("categories", "color", "TEXT"); // Für Datenbanken vor Einführung der Unterscheidung "manuell angepasst" vs. // "vom Scan abweichend" bei displayName/category (siehe schema.ts). Default // 0 (nicht manuell bearbeitet) ist für Bestandsdaten die richtige Annahme. ensureColumn("services", "display_name_edited_manually", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("services", "category_edited_manually", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("services", "last_suggested_display_name", "TEXT"); ensureColumn("services", "last_suggested_category", "TEXT"); ensureColumn("devices", "last_ping", "TEXT"); deduplicateServicesByDeviceAndPort(); // Verhindert künftig auf Datenbankebene, dass zwei Dienste mit demselben // (Gerät, Port) gleichzeitig existieren - kann sonst passieren, wenn zwei // Scans desselben Geräts überlappen (z. B. "Alle Geräte scannen" läuft // noch, während gleichzeitig auch "Jetzt scannen" für genau dieses eine // Gerät geklickt wird) und beide parallel einen "existiert noch nicht"-Fund // zu einem CREATE statt UPDATE machen. Erst NACH der Bereinigung oben // anlegen, sonst würde das Erstellen des Index an vorhandenen Duplikaten // scheitern. sqlite.exec( `CREATE UNIQUE INDEX IF NOT EXISTS idx_services_device_port ON services(device_id, port)` ); // Verhindert doppelte Einträge für denselben Vorschlag (z. B. wenn zwei // Browser fast gleichzeitig denselben neuen Dienst bestätigen) - der // Dismiss-Endpunkt selbst nutzt ohnehin "INSERT OR IGNORE" (siehe // db/repositories/scanDismissals.ts), dieser Index macht das zusätzlich // auf Datenbankebene verbindlich. sqlite.exec( `CREATE UNIQUE INDEX IF NOT EXISTS idx_scan_dismissals_kind_key ON scan_dismissals(kind, dedupe_key)` ); } /** * Entfernt doppelte Dienst-Einträge für dasselbe (Gerät, Port) - kann bei * älteren Datenbanken vorkommen, wenn zwei überlappende Scans vor Einführung * des UNIQUE-Index (s. o.) beide gleichzeitig einen neuen Dienst angelegt * haben. Behält den Eintrag mit den meisten ausgefüllten Feldern (Favicon, * Kategorie, ...), bei Gleichstand den ältesten (kleinste created_at). */ function deduplicateServicesByDeviceAndPort(): void { const rows = sqlite .prepare(`SELECT * FROM services ORDER BY created_at ASC`) .all() as Array>; const groups = new Map>>(); for (const row of rows) { const key = `${row.device_id}::${row.port}`; const list = groups.get(key) ?? []; list.push(row); groups.set(key, list); } function richness(row: Record): number { return ["favicon", "category", "description", "icon"].filter((f) => !!row[f]).length; } for (const group of groups.values()) { if (group.length <= 1) continue; const keeper = [...group].sort((a, b) => richness(b) - richness(a))[0]; const toDelete = group.filter((r) => r.id !== keeper.id); for (const row of toDelete) { sqlite.prepare(`DELETE FROM services WHERE id = ?`).run(row.id as string); } } } function ensureColumn(table: string, column: string, definition: string): void { const existingColumns = sqlite.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string; }>; const hasColumn = existingColumns.some((col) => col.name === column); if (!hasColumn) { sqlite.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); } }