FastAPI service on 127.0.0.1:9922 that monitors user-chosen directories with pruned inotify watches, stores versions in a local content-addressed spool indexed in SQLite, coalesces bursts (first + last), and offers history, diff and dry-run-first bulk restore. Includes a CLI with a systemd user unit installer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
187 lines
6.2 KiB
Python
187 lines
6.2 KiB
Python
"""SQLite metadata index."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Iterable
|
|
|
|
SCHEMA_VERSION = 1
|
|
|
|
SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS roots (
|
|
id INTEGER PRIMARY KEY,
|
|
path TEXT NOT NULL UNIQUE,
|
|
mode TEXT NOT NULL DEFAULT 'pending',
|
|
watch_count INTEGER NOT NULL DEFAULT 0,
|
|
polled_dirs INTEGER NOT NULL DEFAULT 0,
|
|
baseline_state TEXT NOT NULL DEFAULT 'pending',
|
|
baseline_files INTEGER NOT NULL DEFAULT 0,
|
|
added_at REAL NOT NULL,
|
|
last_scan_at REAL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS projects (
|
|
id INTEGER PRIMARY KEY,
|
|
root_id INTEGER REFERENCES roots(id) ON DELETE SET NULL,
|
|
path TEXT NOT NULL UNIQUE,
|
|
detected_by TEXT NOT NULL,
|
|
created_at REAL NOT NULL,
|
|
last_activity_at REAL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS files (
|
|
id INTEGER PRIMARY KEY,
|
|
root_id INTEGER REFERENCES roots(id) ON DELETE SET NULL,
|
|
project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
|
|
path TEXT NOT NULL UNIQUE,
|
|
exists_on_disk INTEGER NOT NULL DEFAULT 1,
|
|
inode INTEGER,
|
|
mtime_ns INTEGER,
|
|
size INTEGER,
|
|
first_seen_at REAL NOT NULL,
|
|
last_version_at REAL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS blobs (
|
|
sha256 TEXT PRIMARY KEY,
|
|
size INTEGER NOT NULL,
|
|
stored_size INTEGER NOT NULL,
|
|
created_at REAL NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS versions (
|
|
id INTEGER PRIMARY KEY,
|
|
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
|
blob_sha256 TEXT NOT NULL REFERENCES blobs(sha256),
|
|
captured_at REAL NOT NULL,
|
|
source TEXT NOT NULL,
|
|
reason TEXT NOT NULL,
|
|
pinned INTEGER NOT NULL DEFAULT 0,
|
|
durability TEXT NOT NULL DEFAULT 'local'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS renames (
|
|
id INTEGER PRIMARY KEY,
|
|
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
|
old_path TEXT NOT NULL,
|
|
new_path TEXT NOT NULL,
|
|
at REAL NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS restore_plans (
|
|
id TEXT PRIMARY KEY,
|
|
created_at REAL NOT NULL,
|
|
expires_at REAL NOT NULL,
|
|
criteria_json TEXT NOT NULL,
|
|
plan_json TEXT NOT NULL,
|
|
status TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
id INTEGER PRIMARY KEY,
|
|
at REAL NOT NULL,
|
|
action TEXT NOT NULL,
|
|
details_json TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS versions_file_time ON versions(file_id, captured_at);
|
|
CREATE INDEX IF NOT EXISTS versions_blob ON versions(blob_sha256);
|
|
CREATE INDEX IF NOT EXISTS files_root ON files(root_id);
|
|
CREATE INDEX IF NOT EXISTS files_project ON files(project_id);
|
|
"""
|
|
|
|
|
|
def row_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
|
return dict(row) if row is not None else None
|
|
|
|
|
|
class Database:
|
|
def __init__(self, path: Path):
|
|
self.conn = sqlite3.connect(path, check_same_thread=False, isolation_level=None)
|
|
self.conn.row_factory = sqlite3.Row
|
|
self.conn.execute("PRAGMA journal_mode=WAL")
|
|
self.conn.execute("PRAGMA synchronous=NORMAL")
|
|
self.conn.execute("PRAGMA foreign_keys=ON")
|
|
self.conn.executescript(SCHEMA)
|
|
current = self.conn.execute("PRAGMA user_version").fetchone()[0]
|
|
if current < SCHEMA_VERSION:
|
|
self.conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
|
|
|
|
def close(self) -> None:
|
|
self.conn.close()
|
|
|
|
def execute(self, sql: str, params: Iterable[Any] = ()) -> sqlite3.Cursor:
|
|
return self.conn.execute(sql, tuple(params))
|
|
|
|
def one(self, sql: str, params: Iterable[Any] = ()) -> dict[str, Any] | None:
|
|
return row_dict(self.conn.execute(sql, tuple(params)).fetchone())
|
|
|
|
def all(self, sql: str, params: Iterable[Any] = ()) -> list[dict[str, Any]]:
|
|
return [dict(r) for r in self.conn.execute(sql, tuple(params)).fetchall()]
|
|
|
|
def transaction(self):
|
|
return _Transaction(self.conn)
|
|
|
|
def audit(self, action: str, **details: Any) -> None:
|
|
self.execute(
|
|
"INSERT INTO audit_log(at, action, details_json) VALUES (?, ?, ?)",
|
|
(time.time(), action, json.dumps(details, default=str)),
|
|
)
|
|
|
|
# roots
|
|
|
|
def roots(self) -> list[dict[str, Any]]:
|
|
return self.all("SELECT * FROM roots ORDER BY id")
|
|
|
|
def root(self, root_id: int) -> dict[str, Any] | None:
|
|
return self.one("SELECT * FROM roots WHERE id = ?", (root_id,))
|
|
|
|
def update_root(self, root_id: int, **fields: Any) -> None:
|
|
cols = ", ".join(f"{k} = ?" for k in fields)
|
|
self.execute(f"UPDATE roots SET {cols} WHERE id = ?", (*fields.values(), root_id))
|
|
|
|
# files and versions
|
|
|
|
def file_by_path(self, path: str) -> dict[str, Any] | None:
|
|
return self.one("SELECT * FROM files WHERE path = ?", (path,))
|
|
|
|
def file(self, file_id: int) -> dict[str, Any] | None:
|
|
return self.one("SELECT * FROM files WHERE id = ?", (file_id,))
|
|
|
|
def latest_version(self, file_id: int) -> dict[str, Any] | None:
|
|
return self.one(
|
|
"SELECT * FROM versions WHERE file_id = ? ORDER BY captured_at DESC, id DESC LIMIT 1",
|
|
(file_id,),
|
|
)
|
|
|
|
def version(self, version_id: int) -> dict[str, Any] | None:
|
|
return self.one(
|
|
"SELECT v.*, f.path, b.size FROM versions v "
|
|
"JOIN files f ON f.id = v.file_id JOIN blobs b ON b.sha256 = v.blob_sha256 "
|
|
"WHERE v.id = ?",
|
|
(version_id,),
|
|
)
|
|
|
|
def version_at(self, file_id: int, as_of: float) -> dict[str, Any] | None:
|
|
return self.one(
|
|
"SELECT * FROM versions WHERE file_id = ? AND captured_at <= ? "
|
|
"ORDER BY captured_at DESC, id DESC LIMIT 1",
|
|
(file_id, as_of),
|
|
)
|
|
|
|
def history(self, file_id: int) -> list[dict[str, Any]]:
|
|
return self.all(
|
|
"SELECT v.id, v.captured_at, v.source, v.reason, v.pinned, v.durability, "
|
|
"v.blob_sha256, b.size FROM versions v JOIN blobs b ON b.sha256 = v.blob_sha256 "
|
|
"WHERE v.file_id = ? ORDER BY v.captured_at DESC, v.id DESC",
|
|
(file_id,),
|
|
)
|
|
|
|
|
|
class _Transaction:
|
|
def __init__(self, conn: sqlite3.Connection):
|
|
self.conn = conn
|
|
|
|
def __enter__(self):
|
|
self.conn.execute("BEGIN IMMEDIATE")
|
|
return self.conn
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
self.conn.execute("ROLLBACK" if exc_type else "COMMIT")
|
|
return False
|