"""SQLite connection management and ordered migration runner.""" from __future__ import annotations import contextlib import logging import shutil import sqlite3 from collections.abc import Iterator from datetime import UTC, datetime from pathlib import Path from .config import Settings LOGGER = logging.getLogger(__name__) class Database: def __init__(self, settings: Settings) -> None: self.path = settings.database_path self.backup_dir = settings.data_dir / "backups" self.migrations_dir = Path(__file__).with_name("migrations") def initialize(self) -> None: self.path.parent.mkdir(parents=True, exist_ok=True) self.backup_dir.mkdir(parents=True, exist_ok=True) with self.connection() as conn: conn.execute( "CREATE TABLE IF NOT EXISTS schema_migrations " "(version TEXT PRIMARY KEY, applied_at TEXT NOT NULL)" ) applied = { row["version"] for row in conn.execute("SELECT version FROM schema_migrations") } for migration in sorted(self.migrations_dir.glob("*.sql")): version = migration.stem if version in applied: continue sql = migration.read_text(encoding="utf-8") if "-- destructive: true" in sql.lower() and self.path.exists(): self.backup("pre-migration") LOGGER.info("Applying database migration %s", version) conn.executescript(sql) conn.execute( "INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)", (version, utc_now()), ) conn.commit() @contextlib.contextmanager def connection(self) -> Iterator[sqlite3.Connection]: conn = sqlite3.connect(self.path, timeout=5, isolation_level=None, check_same_thread=False) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") conn.execute("PRAGMA journal_mode = WAL") conn.execute("PRAGMA synchronous = NORMAL") conn.execute("PRAGMA busy_timeout = 5000") try: yield conn finally: conn.close() @contextlib.contextmanager def transaction(self) -> Iterator[sqlite3.Connection]: with self.connection() as conn: conn.execute("BEGIN IMMEDIATE") try: yield conn except Exception: conn.rollback() raise else: conn.commit() def backup(self, reason: str = "manual") -> Path: timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") target = self.backup_dir / f"lumaops-{timestamp}-{safe_filename(reason)}.db" with self.connection() as source, sqlite3.connect(target) as destination: source.backup(destination) return target def restore(self, backup_path: Path) -> Path: resolved = backup_path.resolve() if self.backup_dir.resolve() not in resolved.parents: raise ValueError("Backupbestand valt buiten de beheerde back-upmap") if not resolved.is_file(): raise FileNotFoundError(resolved) safety = self.backup("pre-restore") temp = self.path.with_suffix(".restore.tmp") shutil.copy2(resolved, temp) with sqlite3.connect(temp) as candidate: result = candidate.execute("PRAGMA integrity_check").fetchone() if result is None or result[0] != "ok": temp.unlink(missing_ok=True) raise ValueError("De back-up slaagt niet voor de SQLite-integriteitscontrole") temp.replace(self.path) return safety def ping(self) -> bool: try: with self.connection() as conn: return bool(conn.execute("SELECT 1").fetchone()[0] == 1) except sqlite3.Error: return False def utc_now() -> str: return datetime.now(UTC).isoformat() def safe_filename(value: str) -> str: return "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in value)[:48]