Files
petal/scripts/migrate_local_user.py
T
prosolis 84ee6bfb9c Phase 17: run the migration, and fix the guard that locked out its own backup
Claire's writing — 8 documents, 33 snapshots, 103 suggestions, 3 vocabulary
words and an image — now belongs to her account rather than to the pre-auth
'local' user, and the VPS is canonical. millenia was left running and
untouched as a frozen fallback; it diverges the moment either side is
written to, so it wants retiring rather than syncing.

The plan's stated prerequisite, that she log in once so her subject exists,
turned out to be false. Authentik's hashed_user_id sub is the user's uid,
derived from her id and the instance secret, so it can be read in advance —
which means the data moves first and she signs in to find her writing
already there, instead of to an empty Petal that fills in later.

The fix here is to the liveness guard, and it is the second attempt at it.
PRAGMA locking_mode = EXCLUSIVE goes on holding its lock after being set
back to NORMAL — SQLite only lets go on that connection's next database
access — so against a real WAL database the script locked itself out of its
own VACUUM INTO backup. It passed locally because the test database had
come out of VACUUM INTO and so was never in WAL mode: the fixture didn't
look like production, the same way the stub identity provider's slashless
issuer didn't. The probe now runs on its own connection and closes it.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 07:52:16 -07:00

228 lines
9.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""Move the pre-auth `local` user's writing onto a real account.
Petal ran as a single hardcoded user (`users.id = 'local'`) for its whole life
before sign-in existed. Everything she has written is owned by that row. This
script re-points it at the account she now signs in as.
Why a script and not a startup migration: the destination is an OIDC subject
id, which is not knowable from inside the app — it belongs to the identity
provider. Running it deliberately, with the app stopped and a backup taken, is
also the only way to be sure nothing is writing to the database halfway through.
What moves: `documents`, `tags`, `vocab_words` and `images` carry `user_id`
directly. `document_versions`, `suggestions` and `document_tags` hang off their
parents and follow without being touched — which is exactly why this must be one
transaction with foreign keys off: re-pointing a parent while its children are
enforced would either fail or cascade.
Sessions belonging to the old identity are deleted rather than moved. A session
is proof that *someone signed in*, and nobody ever signed in as `local`.
Safety: dry-run unless --apply; refuses to run while anything else has the
database open; refuses if the destination already owns writing of its own; takes
a `VACUUM INTO` backup first; and verifies every row it expected to move
actually moved before it commits.
Usage:
# look at what would happen
python3 scripts/migrate_local_user.py data/petal.db --to <oidc-sub>
# do it
python3 scripts/migrate_local_user.py data/petal.db --to <oidc-sub> \\
--email her@example.com --name "Her Name" --apply
The subject id comes from the identity provider. For authentik with the default
`hashed_user_id` sub mode it is the user's `uid`, which is stable and knowable
before she has ever logged in:
docker exec authentik-server-1 ak shell -c \\
"from authentik.core.models import User; print(User.objects.get(username='claire').uid)"
"""
import argparse
import os
import sqlite3
import sys
import time
# The tables that name an owner directly. Everything else in the schema reaches
# its owner through one of these.
OWNED_TABLES = ("documents", "tags", "vocab_words", "images")
LOCAL_USER = "local"
def die(msg: str) -> None:
print(f"error: {msg}", file=sys.stderr)
sys.exit(1)
def open_db(path: str) -> sqlite3.Connection:
if not os.path.exists(path):
die(f"no database at {path}")
conn = sqlite3.connect(path, isolation_level=None)
conn.row_factory = sqlite3.Row
return conn
def table_exists(conn: sqlite3.Connection, name: str) -> bool:
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,)
).fetchone()
return row is not None
def counts(conn: sqlite3.Connection, user_id: str) -> dict[str, int]:
out = {}
for table in OWNED_TABLES:
if not table_exists(conn, table):
continue
out[table] = conn.execute(
f"SELECT COUNT(*) FROM {table} WHERE user_id = ?", (user_id,)
).fetchone()[0]
return out
def require_app_stopped(path: str) -> None:
"""Fail unless nothing else has the database open.
`BEGIN EXCLUSIVE` is not enough, and quietly so: in WAL mode it only
conflicts with another *writer*, so an idle-but-running Petal sails straight
past it — which is precisely the case this guard exists to catch. Taking
`locking_mode = EXCLUSIVE` conflicts with any other connection at all,
because it locks the shared-memory index every WAL reader must map.
Checking for a `-wal` file would be no use either: WAL mode leaves one
behind whether or not anything is running.
This runs on its own connection, which is then closed. Setting locking_mode
back to NORMAL does not release the lock straight away — SQLite drops it on
the connection's next database access, which on a WAL database can leave the
file locked against everything else this script is about to do. Closing is
the only unambiguous way to let go of it.
"""
probe = sqlite3.connect(path, isolation_level=None)
try:
probe.execute("PRAGMA locking_mode = EXCLUSIVE")
probe.execute("BEGIN IMMEDIATE")
probe.execute("COMMIT")
except sqlite3.OperationalError as err:
die(
f"{path} is in use ({err}). Stop Petal first:\n"
" docker compose stop petal # VPS\n"
" systemctl --user stop petal # millenia"
)
finally:
probe.close()
def backup(path: str) -> str:
"""Snapshot the database with VACUUM INTO — one coherent file including the
WAL, taken without a write lock, and it refuses to overwrite."""
dest = f"{path}.pre-migrate-{time.strftime('%Y%m%d-%H%M%S')}"
conn = sqlite3.connect(path)
try:
conn.execute("VACUUM INTO ?", (dest,))
finally:
conn.close()
return dest
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("database", help="path to petal.db")
ap.add_argument("--to", required=True, metavar="SUB",
help="OIDC subject id of the destination account")
ap.add_argument("--from", dest="source", default=LOCAL_USER,
help=f"account to move from (default: {LOCAL_USER})")
ap.add_argument("--email", default="", help="email for the destination account, if it doesn't exist yet")
ap.add_argument("--name", default="", help="display name for the destination account")
ap.add_argument("--pair-lang", default="zh", help="language pair for a newly created account (default: zh)")
ap.add_argument("--apply", action="store_true", help="actually commit (default: dry run)")
ap.add_argument("--no-backup", action="store_true", help="skip the pre-migration snapshot")
args = ap.parse_args()
if args.to == args.source:
die("source and destination are the same account")
require_app_stopped(args.database)
conn = open_db(args.database)
src_counts = counts(conn, args.source)
if not any(src_counts.values()):
die(f"account {args.source!r} owns nothing in this database — wrong file, or already migrated?")
dst_counts = counts(conn, args.to)
if any(dst_counts.values()):
die(
f"account {args.to!r} already owns writing here "
f"({', '.join(f'{k}={v}' for k, v in dst_counts.items() if v)}). "
"Refusing to merge two accounts — that is not something this script can undo."
)
dst = conn.execute("SELECT id, email, display_name FROM users WHERE id = ?", (args.to,)).fetchone()
print(f"database: {args.database}")
print(f"moving from: {args.source}")
print(f"moving to: {args.to}" + ("" if dst else " (will be created)"))
for table, n in src_counts.items():
print(f" {table:<14} {n}")
sessions = 0
if table_exists(conn, "sessions"):
sessions = conn.execute(
"SELECT COUNT(*) FROM sessions WHERE user_id = ?", (args.source,)
).fetchone()[0]
print(f" {'sessions':<14} {sessions} (deleted, not moved)")
if not args.apply:
print("\ndry run — nothing changed. Re-run with --apply to commit.")
return
if not args.no_backup:
dest = backup(args.database)
print(f"\nbackup: {dest}")
# Foreign keys off for the duration: children reference the parents being
# re-pointed, and this is one atomic swap of an identity, not a data change.
conn.execute("PRAGMA foreign_keys = OFF")
conn.execute("BEGIN EXCLUSIVE")
try:
if not dst:
conn.execute(
"INSERT INTO users (id, email, display_name, pair_lang) VALUES (?, ?, ?, ?)",
(args.to, args.email, args.name or args.email, args.pair_lang),
)
for table in src_counts:
conn.execute(
f"UPDATE {table} SET user_id = ? WHERE user_id = ?", (args.to, args.source)
)
if table_exists(conn, "sessions"):
conn.execute("DELETE FROM sessions WHERE user_id = ?", (args.source,))
# Verify before committing: every row that was the source's is now the
# destination's, and the source owns nothing.
moved = counts(conn, args.to)
left = counts(conn, args.source)
if moved != src_counts or any(left.values()):
raise RuntimeError(
f"row counts do not match after the move (expected {src_counts}, "
f"got {moved}, source still holds {left})"
)
conn.execute("DELETE FROM users WHERE id = ?", (args.source,))
conn.execute("COMMIT")
except Exception as err: # noqa: BLE001 — any failure must roll the whole thing back
conn.execute("ROLLBACK")
die(f"migration rolled back: {err}")
finally:
conn.execute("PRAGMA foreign_keys = ON")
print("\nmigrated. Start Petal and sign in as the destination account.")
for table, n in src_counts.items():
print(f" {table:<14} {n}")
if __name__ == "__main__":
main()