Publish LumaOps source
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
"""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]
|
||||
Reference in New Issue
Block a user