feat: rewind with impact preview, pre-rewind snapshot, undo toast
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
"""Snapshot service — write a JSON dump of all projected tables to disk.
|
||||
|
||||
Used by the rewind flow (Requirements §10.1, T28) so the user can recover a
|
||||
pre-rewind state if the rewind was a mistake. Stored under
|
||||
``data/snapshots/{kind}/`` with a UTC timestamp filename.
|
||||
|
||||
The dump captures both the event log (so the original event sequence is
|
||||
preserved verbatim) and every projected table (so a future restore could
|
||||
either re-load tables directly or re-project from the saved event log).
|
||||
|
||||
The FTS shadow table ``memories_fts`` is intentionally skipped — it's a
|
||||
virtual table maintained by the ``memories_ai/au/ad`` triggers, so it would
|
||||
rebuild itself on a memories re-load. Snapshotting it would also fail
|
||||
``PRAGMA table_info`` cleanly since FTS5 reports its columns differently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from sqlite3 import Connection
|
||||
|
||||
# Order doesn't affect correctness for snapshotting (we read, not write),
|
||||
# but listing tables explicitly keeps the snapshot stable across schema
|
||||
# evolution: a new table won't silently change the dump shape until it's
|
||||
# added here.
|
||||
PROJECTED_TABLES = [
|
||||
"bots",
|
||||
"you_entity",
|
||||
"edges",
|
||||
"memories",
|
||||
"memories_fts",
|
||||
"chats",
|
||||
"chat_state",
|
||||
"containers",
|
||||
"scenes",
|
||||
"activity",
|
||||
"classifier_failures",
|
||||
]
|
||||
|
||||
|
||||
def take_snapshot(
|
||||
conn: Connection, *, data_dir: Path, kind: str = "rewind"
|
||||
) -> Path:
|
||||
"""Write a JSON dump of the event log and projected tables.
|
||||
|
||||
Returns the path to the written snapshot file. Creates parent
|
||||
directories as needed. Filename is a UTC timestamp in
|
||||
``YYYYMMDDTHHMMSSZ`` form so chronological listing matches creation
|
||||
order.
|
||||
"""
|
||||
snapshot_dir = data_dir / "snapshots" / kind
|
||||
snapshot_dir.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
path = snapshot_dir / f"{timestamp}.json"
|
||||
|
||||
dump: dict[str, list] = {}
|
||||
|
||||
# Event log: pull every column we care about. ``ts`` and the
|
||||
# superseded/hidden flags are needed to faithfully reconstruct the
|
||||
# log on restore.
|
||||
cur = conn.execute(
|
||||
"SELECT id, branch_id, ts, kind, payload_json, superseded_by, hidden "
|
||||
"FROM event_log ORDER BY id"
|
||||
)
|
||||
dump["event_log"] = [
|
||||
{
|
||||
"id": r[0],
|
||||
"branch_id": r[1],
|
||||
"ts": r[2],
|
||||
"kind": r[3],
|
||||
"payload_json": r[4],
|
||||
"superseded_by": r[5],
|
||||
"hidden": r[6],
|
||||
}
|
||||
for r in cur.fetchall()
|
||||
]
|
||||
|
||||
for table in PROJECTED_TABLES:
|
||||
if table == "memories_fts":
|
||||
# Virtual FTS5 table — rebuilt by triggers on insert, no need
|
||||
# to snapshot it (and ``PRAGMA table_info`` reports its
|
||||
# columns differently).
|
||||
continue
|
||||
cur = conn.execute(f"PRAGMA table_info({table})")
|
||||
cols = [c[1] for c in cur.fetchall()]
|
||||
if not cols:
|
||||
# Table not present in this schema version — leave an empty
|
||||
# list rather than raising, so older snapshots can survive.
|
||||
dump[table] = []
|
||||
continue
|
||||
cur = conn.execute(f"SELECT {', '.join(cols)} FROM {table}")
|
||||
dump[table] = [dict(zip(cols, row)) for row in cur.fetchall()]
|
||||
|
||||
# ``default=str`` covers Path-like or datetime values that might
|
||||
# sneak through if a column ever stored them; the projected tables
|
||||
# all use TEXT so this is mostly defensive.
|
||||
path.write_text(json.dumps(dump, default=str))
|
||||
return path
|
||||
Reference in New Issue
Block a user