Skip to content

feat(state): back up the ledger before migrating it, and migrate atomically - #42

Merged
JasonZQH merged 1 commit into
devfrom
feat/migration-safety
Jul 28, 2026
Merged

feat(state): back up the ledger before migrating it, and migrate atomically#42
JasonZQH merged 1 commit into
devfrom
feat/migration-safety

Conversation

@JasonZQH

Copy link
Copy Markdown
Owner

v0.1.2 — migration safety, the roadmap's next rung.

Every command that opens .curator/ runs pending migrations (initialize_database has 114 call sites), and until now it did so on the live ledger with no copy and no transaction. This needs to land before v0.1.4, which adds the Execution tables and dual-writes — the first migration with real scope to damage user data.

Three gaps

No backup. The ledger is now copied to .curator/archive/…-pre-migration.sqlite before anything writes — but only when there is actually something to migrate, since copying on every open would be absurd at 114 call sites.

It uses sqlite3.Connection.backup(), not shutil.copy. The ledger runs in WAL mode, and a copy of the main file alone can miss committed pages still in the -wal. That isn't theoretical — a file copy of a freshly written ledger cannot even see the table:

shutil.copy      -> ERROR: no such table: probe
Connection.backup -> ['kept']

No transaction. Migrations committed one at a time, so a failure on the third left the first two applied and the ledger in a state no version of Curator is written to read. They now share a single transaction and roll back together — DDL included, reusing the existing CuratorConnection.begin_transaction().

No visibility. Nothing could answer "what will opening this ledger do to it". curator doctor now reports pending migrations without applying them. It only ever used connect_database, never initialize_database, so it is a dry run by construction rather than by a flag — which is also why this adds no curator migrate command, whose non-dry-run branch would just duplicate what running any command already does.

On the backup filename

Backups are timestamped, not named for the schema version they precede. A migration number is an internal counter, and pre-v3 reads like a SQLite version to the person who has to find the file mid-recovery. Since a timestamp is no easier to recall, doctor prints the newest backup with the full restore command:

Migrations: ok — Schema is current.
Backup: Newest pre-migration backup: .curator/archive/curator-20260728T202220Z-pre-migration.sqlite
  restore: cp .curator/archive/curator-20260728T202220Z-pre-migration.sqlite .curator/curator.sqlite

It finds that by reading the directory rather than the ledger, so it still answers when the ledger will not open — which is when someone actually needs it. Naming is shared with reset's archive so the two cannot drift.

Rollback stays documented rather than a command: restoring is one cp, and a command that overwrites a live ledger needs a confirmation gate and a test surface the roadmap did not ask for.

Verification

381 passing (372 before, 9 new), ruff clean. Beyond the unit tests:

  • The atomicity test fails without the fix — confirmed by reverting the transaction and re-running: the raw RuntimeError escapes and the half-applied ALTER TABLE survives.
  • A real v0.1.0 ledger, built by v0.1.0's own code in a git worktree at that tag, opens under this branch with no pending migrations and no backup (correct — v0.1.0 already carried migrations 1 and 2). Injecting a v0.1.4-style migration then produces a backup that opens cleanly and still reads schema_version [1, 2].
  • Backups only when warranted — a fresh ledger writes none, and ten repeat opens of a current ledger write none.

Version is deliberately not bumped; CHANGELOG lands under [Unreleased]. The v0.1.1 lesson was that pinning docs to a tag that does not exist yet is a 404.

🤖 Generated with Claude Code

…ically

Every command that opens .curator/ runs pending migrations, and until now it
did so on the live ledger with no copy and no transaction. v0.1.4 adds the
Execution tables and dual-writes; that is the first migration with real scope
to damage user data, and it should not land before there is a way back.

Three gaps, all in initialize_database:

No backup. The ledger is now copied to .curator/archive/ before anything
writes, but only when there is actually something to migrate — with 114 call
sites, copying on every open would be absurd. It uses sqlite3's online backup
API, not shutil.copy: the ledger runs in WAL mode, and a copy of the main file
alone can miss committed pages still in the -wal. Verified — a file copy of a
freshly written ledger cannot even see the table.

No transaction. Migrations committed one at a time, so a failure on the third
left the first two applied and the ledger in a state no version is written to
read. They now share one transaction and roll back together, DDL included.

No visibility. Nothing could answer "what will opening this ledger do".
curator doctor now reports pending migrations without applying them — it only
ever used connect_database, never initialize_database, so it is a dry run by
construction rather than by a flag.

Backups are timestamped rather than named for the schema version they precede:
a migration number is an internal counter, and "v3" reads like a SQLite
version to the person who has to find the file. Since a timestamp is no easier
to recall, doctor prints the newest backup with the full restore command, and
finds it by reading the directory so it still answers when the ledger will not
open. Naming is shared with reset's archive so the two cannot drift.

Rollback stays documented rather than a command: restoring is one cp, and a
command that overwrites a live ledger needs a confirmation gate and a test
surface the roadmap did not ask for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JasonZQH
JasonZQH merged commit c629274 into dev Jul 28, 2026
5 checks passed
@JasonZQH

Copy link
Copy Markdown
Owner Author

Code review

Found 1 issue:

  1. A backup interrupted by a full disk, an I/O error, or a killed process left a 0-byte file carrying the real …-pre-migration.sqlite name. sqlite3.connect creates the destination before connection.backup() runs, and there was no cleanup on the failure path. Being the newest, that file was then selected by latest_pre_migration_backup and printed by curator doctor as the ledger to restore — and copying it over a live ledger destroys it.

https://github.com/JasonZQH/CURATOR/blob/6df5031a3ac0a37e6a1e5c8b21f0e0f5a1a09c8f/src/curator/state/backup.py#L36-L44

Reproduced against this commit: with a good 290KB backup already on disk, one failed attempt left a 0-byte sibling and latest_pre_migration_backup returned the 0-byte one.

Fixed in #43 — backups are written to a .partial name the restore glob does not match and renamed into place only once complete.

The other four dimensions (CLAUDE.md adherence, git history, prior PR comments, code-comment invariants) returned no findings.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant