"""SQLite-Persistenz mit Migrationen (PLAN.md §24). Regeln (§24.1, §24.4): - WAL-Modus, Foreign Keys aktiv, kurze Transaktionen - keine Datenbankoperation im Renderthread (nur Control Core nutzt sie) - automatisches Backup vor Migration - Integritätscheck beim Start nach unsauberem Shutdown - jede Schemaänderung: Vorwärtsmigration, Test mit Altdaten, Backup, dokumentierte Nicht-Rückwärtskompatibilität, neue schema_version Projektdateien liegen als JSON-Dateien (§24.2: große Medien als Dateien, SQLite speichert Index, Einstellungen, Pluginstatus, Projektmetadaten). """ from __future__ import annotations import json import shutil import sqlite3 import time from dataclasses import dataclass from pathlib import Path SCHEMA_VERSION = 1 _MIGRATIONS: dict[int, str] = { 1: """ CREATE TABLE IF NOT EXISTS meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, name TEXT NOT NULL, schema_version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, data TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS plugin_status ( plugin_id TEXT PRIMARY KEY, version TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 0, state TEXT NOT NULL DEFAULT 'discovered', package_hash TEXT, updated_at TEXT NOT NULL ); """, } @dataclass class MigrationResult: """Ergebnis einer Migration (für TEST_REPORT und Gate-Doku).""" from_version: int to_version: int backup_path: Path | None integrity_ok: bool class Database: """SQLite-Wrapper für den Control Core (nicht im Renderthread!). - open(): öffnet mit WAL, FK, Integritätscheck - migrate(): führt fehlende Migrationen aus, Backup vorher - save_project/load_project/list_projects: Projektmetadaten + JSON-Daten - set_setting/get_setting: App-Einstellungen - upsert_plugin_status/get_plugin_status: Pluginverwaltung (§14.5) """ def __init__(self, path: Path) -> None: self._path = Path(path) self._conn: sqlite3.Connection | None = None @property def connection(self) -> sqlite3.Connection: if self._conn is None: raise RuntimeError("database not opened") return self._conn @property def schema_version(self) -> int: row = self.connection.execute( "SELECT value FROM meta WHERE key='schema_version'" ).fetchone() return int(row[0]) if row else 0 def open(self, integrity_check: bool = True) -> None: """Öffnet die DB: WAL, Foreign Keys, Busy-Timeout, Integritätscheck.""" self._path.parent.mkdir(parents=True, exist_ok=True) self._conn = sqlite3.connect(self._path, timeout=5.0) self._conn.execute("PRAGMA journal_mode=WAL") # §24.1 self._conn.execute("PRAGMA foreign_keys=ON") # §24.1 self._conn.execute("PRAGMA busy_timeout=5000") if integrity_check: # nach unsauberem Shutdown (§24.1) row = self._conn.execute("PRAGMA integrity_check").fetchone() if row and row[0] != "ok": raise sqlite3.DatabaseError(f"integrity check failed: {row[0]}") def close(self) -> None: if self._conn is not None: self._conn.close() self._conn = None def migrate(self, backup_dir: Path | None = None) -> MigrationResult: """Führt Migrationen bis SCHEMA_VERSION aus; Backup vorher (§24.4). Migrationen sind reine Vorwärtsmigrationen; jede Änderung erhöht schema_version. Altdaten werden beim Backup erhalten. """ conn = self.connection current = 0 try: current = self.schema_version except sqlite3.OperationalError: pass # meta-Tabelle existiert noch nicht → Version 0 if current >= SCHEMA_VERSION: return MigrationResult(current, current, None, True) backup_path: Path | None = None if self._path.exists() and backup_dir is not None: backup_dir.mkdir(parents=True, exist_ok=True) stamp = time.strftime("%Y%m%d-%H%M%S") backup_path = backup_dir / f"{self._path.name}.pre-migration-{stamp}.bak" shutil.copy2(self._path, backup_path) # §24.4: Backup vor Migration with conn: # kurze Transaktion je Version (§24.1) for version in range(current + 1, SCHEMA_VERSION + 1): sql = _MIGRATIONS.get(version) if sql is None: raise RuntimeError(f"missing migration for version {version}") conn.executescript(sql) conn.execute( "INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)", (str(SCHEMA_VERSION),), ) row = conn.execute("PRAGMA integrity_check").fetchone() ok = bool(row and row[0] == "ok") return MigrationResult(current, SCHEMA_VERSION, backup_path, ok) # ---------- Projekte (§24.2) ---------- def save_project(self, project: dict) -> None: """Speichert Projektmetadaten + JSON-Daten transaktionell.""" required = ("id", "name", "schema_version", "created_at", "updated_at") for key in required: if key not in project: raise ValueError(f"project missing field {key!r}") with self.connection: self.connection.execute( """INSERT OR REPLACE INTO projects (id, name, schema_version, created_at, updated_at, data) VALUES (?, ?, ?, ?, ?, ?)""", ( project["id"], project["name"], int(project["schema_version"]), project["created_at"], project["updated_at"], json.dumps(project, ensure_ascii=False), ), ) def load_project(self, project_id: str) -> dict | None: row = self.connection.execute( "SELECT data FROM projects WHERE id=?", (project_id,) ).fetchone() if row is None: return None return json.loads(row[0]) def list_projects(self) -> list[dict]: rows = self.connection.execute( "SELECT id, name, schema_version, updated_at FROM projects" " ORDER BY updated_at DESC" ).fetchall() return [ { "id": r[0], "name": r[1], "schema_version": r[2], "updated_at": r[3], } for r in rows ] def delete_project(self, project_id: str) -> None: with self.connection: self.connection.execute("DELETE FROM projects WHERE id=?", (project_id,)) # ---------- Einstellungen ---------- def set_setting(self, key: str, value: str) -> None: with self.connection: self.connection.execute( "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)", (key, value, time.strftime("%Y-%m-%dT%H:%M:%S%z")), ) def get_setting(self, key: str, default: str | None = None) -> str | None: row = self.connection.execute( "SELECT value FROM settings WHERE key=?", (key,) ).fetchone() return row[0] if row else default # ---------- Plugin-Status (§14.5) ---------- def upsert_plugin_status( self, plugin_id: str, version: str, enabled: bool, state: str, package_hash: str | None = None, ) -> None: if state not in { "discovered", "validated", "installed", "enabled", "compiled", "active", "quarantined", "incompatible", "disabled", }: raise ValueError(f"invalid plugin state {state!r}") with self.connection: self.connection.execute( """INSERT OR REPLACE INTO plugin_status (plugin_id, version, enabled, state, package_hash, updated_at) VALUES (?, ?, ?, ?, ?, ?)""", ( plugin_id, version, int(enabled), state, package_hash, time.strftime("%Y-%m-%dT%H:%M:%S%z"), ), ) def get_plugin_status(self) -> dict[str, dict]: rows = self.connection.execute( "SELECT plugin_id, version, enabled, state, package_hash, updated_at" " FROM plugin_status" ).fetchall() return { r[0]: { "version": r[1], "enabled": bool(r[2]), "state": r[3], "package_hash": r[4], "updated_at": r[5], } for r in rows }