Record standby subscription events with signed durable receipts
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
#!/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))
|
||||
Reference in New Issue
Block a user