DB backup script and alembic auto-backup hook #6

Merged
claude-code merged 4 commits from feat/5-db-backups into main 2026-04-17 11:57:19 -06:00
2 changed files with 76 additions and 0 deletions
Showing only changes of commit a492294dd7 - Show all commits

1
.gitignore vendored
View file

@ -65,6 +65,7 @@ quartermaster.db
quartermaster.db-journal quartermaster.db-journal
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
backups/
# Flask stuff: # Flask stuff:
instance/ instance/

75
scripts/backup-db.sh Executable file
View file

@ -0,0 +1,75 @@
#!/usr/bin/env bash
#
# Copy the quartermaster SQLite database into backups/ before any schema
# change, data migration, or destructive operation. Safe to run while the
# application is writing to the DB: uses Python's sqlite3.Connection.backup
# (the online backup API), not a plain file copy.
#
# Usage:
# scripts/backup-db.sh [reason]
#
# `reason` becomes part of the backup filename. Defaults to "manual".
#
# Resolves the database path in this order:
# 1. QUARTERMASTER_DB_URL if it starts with sqlite:/// (uses the path part)
# 2. ./quartermaster.db relative to the repo root
#
# Exit status:
# 0 backup written, or source DB does not exist (nothing to do)
# 1 backup failed, or a non-file sqlite URL was supplied
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
reason="${1:-manual}"
exec python3 - "$reason" <<'PY'
import os
import re
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
reason = sys.argv[1]
db_path = Path("quartermaster.db")
url = os.environ.get("QUARTERMASTER_DB_URL")
if url:
if url.startswith("sqlite:///"):
db_path = Path(url[len("sqlite:///"):])
elif url.startswith("sqlite://"):
print(f"backup-db.sh: non-file sqlite URL not supported: {url}", file=sys.stderr)
sys.exit(1)
else:
print(f"backup-db.sh: QUARTERMASTER_DB_URL is not a sqlite URL: {url}", file=sys.stderr)
sys.exit(1)
if not db_path.is_file():
print(f"backup-db.sh: no database at {db_path}, nothing to back up.")
sys.exit(0)
slug = re.sub(r"[^A-Za-z0-9]+", "-", reason).strip("-") or "manual"
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_root = os.environ.get("QUARTERMASTER_BACKUP_DIR")
if backup_root:
dest_dir = Path(backup_root)
else:
dest_dir = db_path.resolve().parent / "backups"
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / f"quartermaster-{timestamp}-{slug}.db"
src = sqlite3.connect(str(db_path))
try:
dst = sqlite3.connect(str(dest))
try:
src.backup(dst)
finally:
dst.close()
finally:
src.close()
print(f"backup-db.sh: {db_path} -> {dest} ({dest.stat().st_size} bytes)")
PY