75c949ad25
Wire ServiceDataHarness.cascade_delete into SettingsStore.delete_service (best-effort try/except, logs on failure). Fix migration runner to also catch 'no such table' on fresh DBs (ALTER TABLE before init_schema). Integration test proves end-to-end cascade across both concerns (qBit samples + media items) with multi-instance preservation. Backend: 322 pytest pass, ruff clean.
159 lines
6.4 KiB
Python
159 lines
6.4 KiB
Python
"""Lifecycle-only storage harness for service-owned persistent data.
|
|
|
|
This module owns the *lifecycle* of per-concern SQLite databases: provisioning,
|
|
schema migrations, and cascade-delete when a service instance is removed. It
|
|
does **not** own data operations — each integration implements its own Store
|
|
with bespoke operations (``append``/``window``, ``replace_items``/``query``,
|
|
etc.). This keeps the general interface narrow (lifecycle) and the specific
|
|
interfaces rich (per-integration operations).
|
|
|
|
Each storage *concern* is registered with a :class:`StorageConcern` dataclass
|
|
declaring its DB filename, ordered migration statements, owned tables, and the
|
|
column used for service scoping.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import sqlite3
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StorageConcern:
|
|
"""A per-integration storage namespace registered with the harness."""
|
|
|
|
concern_key: str # e.g. "qbittorrent", "media_index"
|
|
db_filename: str # e.g. "qbittorrent.db", "media_index.sqlite"
|
|
migrations: list[str] # ordered CREATE/ALTER statements (idempotent via IF NOT EXISTS or ALTER-catch)
|
|
tables: list[str] = field(default_factory=list) # tables owned by this concern (for cascade)
|
|
service_id_column: str = "service_id"
|
|
|
|
|
|
class ServiceDataHarness:
|
|
"""Lifecycle-only registry of per-concern storage.
|
|
|
|
Owns:
|
|
* DB provisioning (per-concern SQLite files under ``base_dir``).
|
|
* Schema migrations (run on first access via :meth:`run_migrations`).
|
|
* ``service_id`` cascade-delete when a service instance is removed.
|
|
|
|
Does **not** own:
|
|
* Data operations — each store keeps bespoke append/window/query/etc.
|
|
* A generic value table or generic CRUD layer.
|
|
"""
|
|
|
|
def __init__(self, base_dir: Path | str) -> None:
|
|
self._base_dir = Path(base_dir)
|
|
self._concerns: dict[str, StorageConcern] = {}
|
|
|
|
@property
|
|
def base_dir(self) -> Path:
|
|
return self._base_dir
|
|
|
|
def register(self, concern: StorageConcern) -> None:
|
|
"""Register a storage concern. Called at startup / on first access."""
|
|
self._concerns[concern.concern_key] = concern
|
|
|
|
def db_path(self, concern_key: str) -> Path:
|
|
"""Return the absolute path to a concern's DB file."""
|
|
concern = self._concerns[concern_key]
|
|
return self._base_dir / concern.db_filename
|
|
|
|
def connect(self, concern_key: str) -> sqlite3.Connection:
|
|
"""Open a WAL-mode connection to a concern's DB."""
|
|
path = self.db_path(concern_key)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
conn = sqlite3.connect(path, timeout=30)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA busy_timeout=30000")
|
|
return conn
|
|
|
|
def run_migrations(self) -> None:
|
|
"""Run pending migrations for every registered concern.
|
|
|
|
Each migration string is split into individual statements (by ``;``)
|
|
and executed individually. ``ALTER TABLE ... ADD COLUMN`` statements
|
|
that fail with "duplicate column name" are silently skipped, making
|
|
migrations idempotent across re-runs and fresh installs where
|
|
``init_schema`` may have already created the column.
|
|
"""
|
|
for concern in self._concerns.values():
|
|
path = self.db_path(concern.concern_key)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
conn = sqlite3.connect(path, timeout=30)
|
|
try:
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
for migration_sql in concern.migrations:
|
|
statements = [s.strip() for s in migration_sql.split(";") if s.strip()]
|
|
for stmt in statements:
|
|
try:
|
|
conn.execute(stmt)
|
|
except sqlite3.OperationalError as exc:
|
|
lowered = str(exc).lower()
|
|
if "duplicate column name" in lowered or "no such table" in lowered:
|
|
logger.debug("Skipping migration (already applied or table absent): %s", stmt[:80])
|
|
else:
|
|
raise
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
def cascade_delete(self, service_id: str) -> None:
|
|
"""Delete all rows for a ``service_id`` across every concern's tables.
|
|
|
|
Called from :meth:`SettingsStore.delete_service` after the service row
|
|
is removed. Best-effort: callers wrap in try/except so a harness
|
|
failure does not block service deletion.
|
|
"""
|
|
for concern in self._concerns.values():
|
|
col = concern.service_id_column
|
|
path = self.db_path(concern.concern_key)
|
|
if not path.exists():
|
|
continue
|
|
with sqlite3.connect(path, timeout=30) as conn:
|
|
for table in concern.tables:
|
|
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
|
if col in cols:
|
|
conn.execute(f"DELETE FROM {table} WHERE {col} = ?", (service_id,))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Module-level singleton
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_HARNESS: ServiceDataHarness | None = None
|
|
|
|
|
|
def get_service_data_harness() -> ServiceDataHarness:
|
|
"""Return the process-wide harness singleton, initializing it on first call.
|
|
|
|
Lazy registration of built-in concerns happens here (local imports avoid
|
|
circular dependencies). Migrations are run immediately after registration.
|
|
"""
|
|
global _HARNESS
|
|
if _HARNESS is None:
|
|
base_dir = Path(os.environ.get("BACKEND_CACHE_DIR", ".cache/media_library_viewer"))
|
|
_HARNESS = ServiceDataHarness(base_dir)
|
|
# Register built-in concerns (lazy import avoids circular dependency).
|
|
from media_library_viewer_api.services.qbittorrent_store import QBITTORRENT_CONCERN
|
|
|
|
_HARNESS.register(QBITTORRENT_CONCERN)
|
|
|
|
from media_library_viewer_api.services.media_index_impl import MEDIA_INDEX_CONCERN
|
|
|
|
_HARNESS.register(MEDIA_INDEX_CONCERN)
|
|
_HARNESS.run_migrations()
|
|
return _HARNESS
|
|
|
|
|
|
def reset_service_data_harness() -> None:
|
|
"""Reset the singleton (for testing)."""
|
|
global _HARNESS
|
|
_HARNESS = None
|