81 lines
4.3 KiB
Python
81 lines
4.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Create durable subscription event receipts/history, with a SQLite backup.
|
|
|
|
Stop the production backend before --apply and pass --service-stopped to confirm.
|
|
No automatic startup DDL exists. Dry run is read-only; only hashes enter history.
|
|
Receipt retention is independent of the most recent ten visible update records.
|
|
"""
|
|
import argparse
|
|
import datetime
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import sqlite3
|
|
|
|
DDL = (
|
|
"""CREATE TABLE IF NOT EXISTS subscription_event_receipt (
|
|
event_id TEXT PRIMARY KEY NOT NULL, key_hash TEXT NOT NULL,
|
|
client TEXT NOT NULL, occurred_at INTEGER NOT NULL, source TEXT NOT NULL,
|
|
ingested_at INTEGER NOT NULL, fingerprint TEXT NOT NULL, outcome TEXT NOT NULL
|
|
)""",
|
|
"""CREATE TABLE IF NOT EXISTS subscription_event_binding (
|
|
key_hash TEXT PRIMARY KEY NOT NULL, user_id INTEGER,
|
|
user_name TEXT NOT NULL, first_seen_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL
|
|
)""",
|
|
"CREATE INDEX IF NOT EXISTS idx_subscription_event_binding_user ON subscription_event_binding(user_id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_sub_record_user_time ON sub_update_record(user_id,time DESC,id DESC)",
|
|
)
|
|
|
|
|
|
def migrate(database: Path, apply=False, service_stopped=False):
|
|
if not database.is_file():
|
|
raise ValueError("database must already exist")
|
|
if apply and not service_stopped:
|
|
raise ValueError("stop backend and supply --service-stopped before applying")
|
|
uri = database.resolve().as_uri() + ("?mode=rw" if apply else "?mode=ro")
|
|
with sqlite3.connect(uri, uri=True) as db:
|
|
for table in ("User", "sub_bind", "sub_update_record"):
|
|
columns = {row[1] for row in db.execute(f'PRAGMA table_info("{table}")')}
|
|
if not columns or (table != "User" and "user_id" not in columns):
|
|
raise ValueError("stable user links migration is required first")
|
|
unresolved = db.execute("SELECT count(*) FROM sub_bind sb LEFT JOIN User u ON u.id=sb.user_id WHERE sb.user_id IS NOT NULL AND u.id IS NULL").fetchone()[0]
|
|
if unresolved:
|
|
raise ValueError("resolve non-null dangling subscription user IDs before migration")
|
|
tables = {row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
|
result = {"apply": apply, "tables_to_create": sorted({"subscription_event_receipt", "subscription_event_binding"} - tables)}
|
|
if not apply:
|
|
return result
|
|
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
|
|
backup = database.with_name(database.name + ".before-subscription-events." + stamp)
|
|
# Create mode 600 before any sensitive database bytes are written.
|
|
backup.touch(mode=0o600, exist_ok=False)
|
|
with sqlite3.connect(backup) as snapshot:
|
|
db.backup(snapshot)
|
|
try:
|
|
db.execute("BEGIN IMMEDIATE")
|
|
for statement in DDL:
|
|
db.execute(statement)
|
|
now = int(datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000)
|
|
for key, user_id, name in db.execute("SELECT sb.key,sb.user_id,coalesce(u.username,sb.user) FROM sub_bind sb LEFT JOIN User u ON u.id=sb.user_id"):
|
|
key_hash = hashlib.sha256(key.encode("utf-8")).hexdigest()
|
|
db.execute("""INSERT INTO subscription_event_binding VALUES(?,?,?,?,?)
|
|
ON CONFLICT(key_hash) DO UPDATE SET user_name=excluded.user_name,last_seen_at=excluded.last_seen_at
|
|
WHERE subscription_event_binding.user_id IS excluded.user_id""", (key_hash, user_id, name, now, now))
|
|
db.commit()
|
|
except BaseException:
|
|
db.rollback()
|
|
raise
|
|
result["backup"] = str(backup)
|
|
result["history_bindings"] = db.execute("SELECT count(*) FROM subscription_event_binding").fetchone()[0]
|
|
result["integrity"] = db.execute("PRAGMA quick_check").fetchone()[0]
|
|
return result
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("database", type=Path)
|
|
parser.add_argument("--apply", action="store_true")
|
|
parser.add_argument("--service-stopped", action="store_true")
|
|
args = parser.parse_args()
|
|
print(json.dumps(migrate(args.database, args.apply, args.service_stopped), ensure_ascii=False))
|