"""Restore a Scribe backup into an empty instance, from the command line. python -m scribe.restore /backups/scribe-20261015T020000Z.tar This is how a fresh instance is brought back after losing the old one. Run it inside the Scribe container, which already has the database settings and has already migrated the schema on start. It reads either the scheduled backup's tar (services/backup_file.py) or the JSON the admin download produces. It refuses a database that already holds an account. A restore re-creates every user, project and record from the file with new ids; into a live instance it would add a second copy of everything rather than replace it. (Settings → Backup & Restore still offers the upload for that case, on purpose and with its own warning.) Unlike the upload, nothing limits the file's size here, so this is also the way in for a backup too big for the web upload. Afterwards, restart Scribe: search indexes are not part of a backup and are rebuilt from the restored records at startup. """ from __future__ import annotations import argparse import asyncio import json import sys from pathlib import Path from sqlalchemy import func, select def load_backup(path: Path) -> dict: """The payload in `path`, whichever of the two backup formats it is.""" from scribe.services.backup_file import is_archive, read_archive with open(path, "rb") as f: if is_archive(f): return read_archive(f) return json.load(f) async def _account_count() -> int: from scribe.models import async_session from scribe.models.user import User async with async_session() as session: return (await session.execute(select(func.count()).select_from(User))).scalar_one() async def run(path: Path) -> int: from scribe.models import engine from scribe.services.backup import restore_full_backup from scribe.services.backup_file import ArchiveError try: try: payload = load_backup(path) except FileNotFoundError: print(f"There is no file at {path}.", file=sys.stderr) return 2 except (ArchiveError, ValueError) as exc: print(f"{path} isn't a backup this Scribe can read: {exc}", file=sys.stderr) return 2 accounts = await _account_count() if accounts: print( f"This database already holds {accounts} account(s). A restore adds a second " "copy of everything rather than replacing it, so it only runs into an empty " "instance. Start Scribe on an empty database and run this again.", file=sys.stderr, ) return 3 stats = await restore_full_backup(payload) print(json.dumps({"restored": stats}, indent=2, default=str)) print("Restored. Restart Scribe so the search indexes are rebuilt.", file=sys.stderr) return 0 finally: await engine.dispose() def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="python -m scribe.restore", description="Restore a Scribe backup (.tar or .json) into an empty instance.", ) parser.add_argument("file", type=Path, help="the backup file") args = parser.parse_args(argv) return asyncio.run(run(args.file)) if __name__ == "__main__": sys.exit(main())