Phase 17: a script to move the local user's writing onto a real account
The destination is an OIDC subject id, which the app cannot know — it belongs to the identity provider — so this runs deliberately, with Petal stopped and a backup taken, rather than as a startup migration. documents, tags, vocab_words and images carry user_id directly; versions, suggestions and tag assignments hang off their parents and follow, which is why it has to be one transaction with foreign keys off. Sessions for the old identity are deleted rather than moved: a session is proof someone signed in, and nobody ever signed in as 'local'. Dry run by default, VACUUM INTO backup first, and it verifies every row it expected to move actually moved — and that the source is left owning nothing — before committing. The 'is the app stopped?' guard took two attempts. BEGIN EXCLUSIVE, the obvious check, sails past a running-but-idle Petal because in WAL mode it only conflicts with another writer, which is precisely the case worth catching. PRAGMA locking_mode = EXCLUSIVE conflicts with any connection at all, since it locks the shared-memory index every WAL reader maps. Sequencing this also turned up a crash waiting to happen: the image backfill claims unowned files for 'local', which no longer exists after a migration, and the resulting foreign-key error is fatal inside images.New. Petal would have crash-looped the first time it started on a migrated database. It now skips a missing owner, which costs nothing — the migration moves the image rows itself. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
@@ -77,6 +77,21 @@ func (h *Handler) backfill(owner string) error {
|
||||
if owner == "" {
|
||||
return nil
|
||||
}
|
||||
// The owner may not exist — after the `local` account has been migrated onto
|
||||
// a real one, it doesn't. Claiming for a missing user would violate the
|
||||
// foreign key, and this runs during startup, so the error would take the
|
||||
// whole app down. There is nothing left to claim in that case anyway: the
|
||||
// migration moves the image rows along with everything else.
|
||||
var ownerExists bool
|
||||
if err := h.db.QueryRow(
|
||||
`SELECT EXISTS(SELECT 1 FROM users WHERE id = ?)`, owner,
|
||||
).Scan(&ownerExists); err != nil {
|
||||
return err
|
||||
}
|
||||
if !ownerExists {
|
||||
return nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(h.dir)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -209,6 +209,13 @@ func TestBackfillClaimsExistingFiles(t *testing.T) {
|
||||
if _, err := New(dir, database.DB, "bob"); err != nil {
|
||||
t.Fatalf("second backfill: %v", err)
|
||||
}
|
||||
|
||||
// And an owner who no longer exists — which is what the `local` account
|
||||
// becomes once it has been migrated onto a real one — must be skipped, not
|
||||
// turned into a foreign-key error that takes startup down with it.
|
||||
if _, err := New(dir, database.DB, "nobody-at-all"); err != nil {
|
||||
t.Fatalf("backfill for a missing owner should be a no-op, got: %v", err)
|
||||
}
|
||||
var owners int
|
||||
if err := database.QueryRow(`SELECT COUNT(*) FROM images WHERE name = ?`, orphan).Scan(&owners); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
Reference in New Issue
Block a user