from __future__ import annotations import os import sqlite3 from abc import ABC, abstractmethod from typing import Any, Callable, ClassVar, Dict, List, Optional, TYPE_CHECKING from ..config.persist_config import PersistConfig if TYPE_CHECKING: from .sync_node import SyncNode class PersistenceBackend(ABC): """同步系统持久化契约。""" backend_name: ClassVar[str] = "abstract" @staticmethod def _is_read_only_sql(sql: str) -> bool: stmt = (sql or "").strip().lower() return stmt.startswith("select") or stmt.startswith("pragma") or stmt.startswith("with") @classmethod def query_records( cls, *, backend: str, db_path: str, sql: str, limit: int = 200, ) -> tuple[List[str], List[Dict[str, Any]]]: if backend != "sqlite": raise NotImplementedError(f"records query backend not supported yet: {backend}") if not cls._is_read_only_sql(sql): raise ValueError("Only read-only SQL is allowed") path = db_path if not os.path.exists(path): raise FileNotFoundError(f"DB not found: {path}") with sqlite3.connect(path) as conn: conn.row_factory = sqlite3.Row cursor = conn.execute(sql) rows = cursor.fetchmany(max(1, min(limit, 2000))) columns = [item[0] for item in (cursor.description or [])] data = [{k: row[k] for k in columns} for row in rows] return columns, data @classmethod def records_summary( cls, *, backend: str, db_path: str, ) -> Dict[str, Any]: summary: Dict[str, Any] = { "backend": backend, "db_path": db_path, "db_exists": os.path.exists(db_path), "tables": [], "sample_bindings": [], } if not os.path.exists(db_path): return summary if backend != "sqlite": summary["error"] = f"records summary backend not supported yet: {backend}" return summary with sqlite3.connect(db_path) as conn: conn.row_factory = sqlite3.Row tables = conn.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" ).fetchall() table_names = [str(r["name"]) for r in tables] for name in table_names: count = conn.execute(f"SELECT COUNT(1) AS c FROM {name}").fetchone()["c"] summary["tables"].append({"name": name, "count": int(count)}) if "bindings" in table_names: sample = conn.execute( "SELECT node_type, local_id, remote_id FROM bindings ORDER BY last_updated DESC LIMIT 50" ).fetchall() summary["sample_bindings"] = [ { "node_type": row["node_type"], "local_id": row["local_id"], "remote_id": row["remote_id"], } for row in sample ] return summary @abstractmethod async def initialize(self) -> None: raise NotImplementedError @abstractmethod async def save_node(self, collection_id: str, scope: str, node: "SyncNode") -> None: raise NotImplementedError @abstractmethod async def save_nodes_bulk(self, collection_id: str, scope: str, nodes: List["SyncNode"]) -> None: raise NotImplementedError @abstractmethod async def delete_node(self, collection_id: str, scope: str, node_id: str) -> None: raise NotImplementedError @abstractmethod async def delete_nodes_bulk(self, collection_id: str, scope: str, node_ids: List[str]) -> None: raise NotImplementedError @abstractmethod async def load_nodes( self, collection_id: str, scope: str, *, exclude_node_types: Optional[List[str]] = None, ) -> List[Dict[str, Any]]: raise NotImplementedError @abstractmethod async def save_binding(self, scope: str, node_type: str, local_id: str, payload_dict: Dict[str, Any]) -> None: raise NotImplementedError @abstractmethod async def save_bindings_bulk(self, scope: str, node_type: str, bindings: Dict[str, Optional[str]]) -> None: raise NotImplementedError @abstractmethod async def delete_binding(self, scope: str, node_type: str, local_id: str) -> None: raise NotImplementedError @abstractmethod async def delete_bindings_bulk(self, scope: str, node_type: str, local_ids: List[str]) -> None: raise NotImplementedError @abstractmethod async def load_bindings(self, scope: str, node_type: str) -> List[Dict[str, Any]]: raise NotImplementedError @abstractmethod async def load_bind_data_id_overrides(self, node_type: str, scope: str) -> Dict[str, Optional[str]]: raise NotImplementedError @abstractmethod async def delete_scope_node_types(self, *, scope: str, node_types: List[str]) -> None: raise NotImplementedError @abstractmethod async def copy_nodes_between_scopes( self, *, collection_id: str, source_scope: str, target_scope: str, node_types: List[str], ) -> None: raise NotImplementedError @abstractmethod async def copy_bindings_between_scopes( self, *, source_scope: str, target_scope: str, node_types: List[str], ) -> None: raise NotImplementedError @abstractmethod async def dump_to_runtime(self, filename: str) -> None: raise NotImplementedError @abstractmethod async def close(self) -> None: raise NotImplementedError PersistenceBackendFactory = Callable[[PersistConfig], PersistenceBackend] _PERSISTENCE_BACKEND_REGISTRY: dict[str, PersistenceBackendFactory] = {} def register_persistence_backend(name: str, factory: PersistenceBackendFactory, *, override: bool = False) -> None: backend_name = name.strip() if not backend_name: raise ValueError("backend name cannot be empty") if not override and backend_name in _PERSISTENCE_BACKEND_REGISTRY: raise ValueError(f"persistence backend already registered: {backend_name}") _PERSISTENCE_BACKEND_REGISTRY[backend_name] = factory def get_registered_persistence_backends() -> Dict[str, PersistenceBackendFactory]: return dict(_PERSISTENCE_BACKEND_REGISTRY) def create_persistence_backend( persist_config: PersistConfig, ) -> PersistenceBackend: backend_name = (persist_config.backend or "").strip() if not backend_name: raise ValueError("persist.backend cannot be empty") if not _PERSISTENCE_BACKEND_REGISTRY: register_persistence_backend( "sqlite", lambda config: SQLitePersistenceBackend(config), ) factory = _PERSISTENCE_BACKEND_REGISTRY.get(backend_name) if factory is None: available = ", ".join(sorted(_PERSISTENCE_BACKEND_REGISTRY)) or "none" raise NotImplementedError(f"Persistence backend not supported yet: {backend_name}. Available: {available}") return factory(persist_config) def _register_builtin_persistence_backends() -> None: from .persistence_backends.sqlite_backend import SQLitePersistenceBackend register_persistence_backend("sqlite", lambda config: SQLitePersistenceBackend(config), override=True) _register_builtin_persistence_backends()