Files
LaunchPad/apps/backend/src/db/client.ts

225 lines
7.7 KiB
TypeScript

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<Record<string, unknown>>;
const groups = new Map<string, Array<Record<string, unknown>>>();
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<string, unknown>): 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}`);
}
}