from __future__ import annotations from .shared import ( Any, BACKUP_RESTORE_POLICY, CASHFLOW_KIND_SQL, DEFAULT_LEDGER_BACKUP_DIR, KST, Path, current_kst_month, datetime, db, read_db, read_operation, fail, hashlib, init_db, ok, resolve_db_path, row_to_dict, sqlite3, timedelta, ) from .shared import _current_kst_datetime, _month_range def health_check(db_path: str | None = None) -> dict[str, Any]: path = init_db(db_path) with db(path) as conn: schema_version = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] tx_count = conn.execute("SELECT COUNT(*) FROM transactions WHERE status='active'").fetchone()[0] return ok(server="jarca-ledger-mcp", db_path=path, schema_version=schema_version, active_transactions=tx_count) @read_operation def health_check_readonly(db_path: str | None = None) -> dict[str, Any]: path = resolve_db_path(db_path) with read_db(str(path)) as conn: schema_version = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] tx_count = conn.execute("SELECT COUNT(*) FROM transactions WHERE status='active'").fetchone()[0] return ok(server="jarca-ledger-mcp", db_path=path, schema_version=schema_version, active_transactions=tx_count) def _sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as f: for chunk in iter(lambda: f.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def _file_metadata(path: Path, include_hash: bool = True) -> dict[str, Any]: stat = path.stat() metadata: dict[str, Any] = { "path": str(path.resolve()), "size_bytes": stat.st_size, "mtime": datetime.fromtimestamp(stat.st_mtime).replace(microsecond=0).isoformat(), } if include_hash: metadata["sha256"] = _sha256_file(path) return metadata def _readonly_sqlite_connection(path: Path) -> sqlite3.Connection: conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True) conn.row_factory = sqlite3.Row return conn def _safe_table_counts(conn: sqlite3.Connection) -> dict[str, int]: counts: dict[str, int] = {} for table in ("accounts", "transactions", "budgets", "import_batches", "category_rules", "ledger_categories", "manual_assets", "asset_snapshots", "review_decisions", "reconciliation_sessions"): exists = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone() if exists: counts[table] = int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]) return counts def _safe_monthly_aggregates(conn: sqlite3.Connection) -> dict[str, Any]: current_month = current_kst_month() start, next_month = _month_range(current_month) rows = conn.execute( f"""SELECT {CASHFLOW_KIND_SQL} AS cashflow_kind, SUM(t.amount) AS total, COUNT(*) AS count FROM transactions t LEFT JOIN ledger_categories lc ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date dict[str, Any]: status: dict[str, Any] = {"db_path": str(path.resolve()), "exists": path.exists()} if not path.exists(): return status status.update(_file_metadata(path, include_hash=include_hash)) try: with _readonly_sqlite_connection(path) as conn: if include_integrity: status["integrity_check"] = str(conn.execute("PRAGMA integrity_check").fetchone()[0]) status["table_counts"] = _safe_table_counts(conn) status["aggregate_totals"] = _safe_monthly_aggregates(conn) except Exception as e: status["integrity_check"] = "error" status["error"] = str(e) return status def get_ledger_db_status(db_path: str | None = None, include_hash: bool = True, include_integrity: bool = True) -> dict[str, Any]: """Return non-sensitive metadata evidence for the selected ledger DB.""" path = resolve_db_path(db_path) if not path.exists(): return fail("not_found", f"ledger DB not found: {path}", db_path=str(path), exists=False) return ok(**_ledger_db_status_for_path(path, include_hash=include_hash, include_integrity=include_integrity)) def _resolve_backup_dir(backup_dir: str | None = None) -> Path: return Path(backup_dir).expanduser().resolve() if backup_dir else DEFAULT_LEDGER_BACKUP_DIR.resolve() def _backup_file_metadata(path: Path, include_hash: bool = False) -> dict[str, Any]: metadata = _file_metadata(path, include_hash=include_hash) metadata["name"] = path.name metadata["restore_policy"] = BACKUP_RESTORE_POLICY return metadata def _apply_backup_retention(backup_dir: Path, retention_days: int = 30, retention_count: int = 20) -> list[str]: backups = sorted(backup_dir.glob("*.sqlite3"), key=lambda p: p.stat().st_mtime, reverse=True) keep_count = max(1, int(retention_count)) # Retention comparison needs sub-second precision for retention_days=0: # the timestamp used for backup names is second-truncated, but filesystem mtimes # can identify files created just before retention runs as older than "now". cutoff = datetime.now(KST) - timedelta(days=max(0, int(retention_days))) pruned: list[str] = [] for idx, path in enumerate(backups): mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=KST) should_keep = idx < keep_count or mtime >= cutoff if should_keep: continue path.unlink() pruned.append(str(path.resolve())) return pruned def create_ledger_backup(db_path: str | None = None, backup_dir: str | None = None, retention_days: int = 30, retention_count: int = 20) -> dict[str, Any]: """Create a timestamped SQLite backup and return metadata-only evidence.""" source_path = resolve_db_path(db_path) if not source_path.exists(): return fail("not_found", f"ledger DB not found: {source_path}", db_path=str(source_path), exists=False) backup_root = _resolve_backup_dir(backup_dir) backup_root.mkdir(parents=True, exist_ok=True) timestamp = _current_kst_datetime().strftime("%Y%m%d%H%M%S%f") backup_path = backup_root / f"ledger_{timestamp}.sqlite3" try: # Use SQLite's online backup API so WAL-mode databases are copied consistently. with sqlite3.connect(f"file:{source_path}?mode=ro", uri=True) as source_conn: with sqlite3.connect(backup_path) as backup_conn: source_conn.backup(backup_conn) except Exception: if backup_path.exists(): backup_path.unlink() raise source_status = _ledger_db_status_for_path(source_path, include_hash=True, include_integrity=True) backup_status = _ledger_db_status_for_path(backup_path, include_hash=True, include_integrity=True) pruned_paths = _apply_backup_retention(backup_root, retention_days=retention_days, retention_count=retention_count) return ok( source_db=source_status, backup=backup_status, backup_dir=str(backup_root), retention={"retention_days": int(retention_days), "retention_count": int(retention_count), "pruned_paths": pruned_paths}, restore_policy=BACKUP_RESTORE_POLICY, ) def list_ledger_backups(backup_dir: str | None = None, include_hash: bool = False, limit: int = 50) -> dict[str, Any]: backup_root = _resolve_backup_dir(backup_dir) if not backup_root.exists(): return ok(backup_dir=str(backup_root), backups=[], count=0, restore_policy=BACKUP_RESTORE_POLICY) max_items = max(1, min(int(limit), 500)) files = sorted(backup_root.glob("*.sqlite3"), key=lambda p: p.stat().st_mtime, reverse=True)[:max_items] backups = [_backup_file_metadata(path, include_hash=include_hash) for path in files] return ok(backup_dir=str(backup_root), backups=backups, count=len(backups), restore_policy=BACKUP_RESTORE_POLICY) def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_integrity: bool = True) -> dict[str, Any]: path = Path(file_path).expanduser().resolve() if not path.exists(): return fail("not_found", f"backup file not found: {path}", path=str(path), exists=False) return ok(backup=_ledger_db_status_for_path(path, include_hash=include_hash, include_integrity=include_integrity), restore_policy=BACKUP_RESTORE_POLICY) from .accounts_write_services import create_ledger_account as create_ledger_account @read_operation def list_ledger_accounts(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]: sql = "SELECT * FROM accounts" + (" WHERE active=1" if active_only else "") + " ORDER BY name" with read_db(db_path) as conn: rows = conn.execute(sql).fetchall() return ok(accounts=[row_to_dict(r) for r in rows], count=len(rows))