Skip to content

Release v0.1.2 — migration safety - #45

Merged
JasonZQH merged 6 commits into
mainfrom
dev
Jul 28, 2026
Merged

Release v0.1.2 — migration safety#45
JasonZQH merged 6 commits into
mainfrom
dev

Conversation

@JasonZQH

Copy link
Copy Markdown
Owner

Promotes dev to main for the v0.1.2 cut.

What ships

The ledger is backed up before a schema migration (#42). Opening .curator/ with a newer Curator applies pending migrations automatically — initialize_database has ~114 call sites, so effectively any command migrates. It now copies the ledger to .curator/archive/…-pre-migration.sqlite first, using SQLite's online backup API rather than a file copy: the ledger runs in WAL mode, and a copy of the main file alone can miss committed pages still in the -wal. Verified rather than assumed — a file copy of a freshly written ledger cannot even see the table. A ledger with nothing to migrate, the usual case, is not copied.

Migrations are all-or-nothing (#42). They used to commit 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 — and because the version was never recorded, the next open would retry and fail on duplicate column name, permanently. They now share one transaction and roll back together, DDL included.

curator doctor shows what is pending, and how to get back (#42). It reports migrations without applying them — doctor only ever uses connect_database, never initialize_database, so it is a dry run by construction rather than by a flag. It also prints the newest backup with a ready-to-paste restore command, found by reading the directory rather than the ledger, so it still answers when the ledger will not open.

A half-written backup is never offered as a restore point (#43). Found reviewing #42. sqlite3.connect creates the destination before the copy runs, so a backup interrupted by a full disk or a killed process left a 0-byte file carrying the real name — which, being newest, became what doctor advertised. Copying that over a live ledger destroys it. Backups are now written to a .partial name the restore path ignores and renamed into place only once complete.

Version

pyproject.toml and curator.__version__ both move to 0.1.2; the drift test added in v0.1.1 fails if only one moves. README pins and the version table point at v0.1.2.

Verification

  • 383 passing on CI across 3.11/3.12, plus macOS smoke and the state/mock-provider guard.
  • Both the atomicity test and the two backup regression tests were confirmed to fail without their fixes, by reverting the source and re-running.
  • A real v0.1.0 ledger, built by v0.1.0's own code at that tag, opens under this branch with no migrations and no backup (correct — v0.1.0 already carried migrations 1 and 2). Injecting a v0.1.4-shaped migration produces a backup that opens cleanly and still reads schema_version [1, 2].
  • A five-dimension review of feat(state): back up the ledger before migrating it, and migrate atomically #42 surfaced the fix(state): never offer a half-written backup as a restore point #43 defect; the other four dimensions returned no findings.

After merge

  1. git tag -a v0.1.2 -m "Curator v0.1.2" && git push origin v0.1.2 — fires release-smoke, which builds, clean-installs the wheel and sdist, and publishes them with SHA256SUMS.
  2. gh release create v0.1.2 from the CHANGELOG entry.
  3. The Dependabot grouping and target-branch: dev config from v0.1.1 finally takes effect, since Dependabot reads it from the default branch. chore(deps-dev): bump ruff from 0.15.19 to 0.16.0 #33 also unblocks once the Ruff pin reaches main.

🤖 Generated with Claude Code

JasonZQH and others added 6 commits July 28, 2026 13:24
…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>
feat(state): back up the ledger before migrating it, and migrate atomically
Found reviewing #42. sqlite3.connect creates the destination file before
connection.backup() runs, and backup_ledger had no cleanup on the failure
path — so 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.

That file is the newest, so latest_pre_migration_backup selected it and
curator doctor printed it as the ledger to restore. Following that advice
copies an empty database over a live ledger and destroys it — on exactly the
disaster path this code exists to protect.

Reproduced before fixing: 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.

Backups are now written to a .partial name the restore glob does not match and
renamed into place only after the copy completes, so an incomplete backup can
never carry the real name — this covers the killed-process case too, which
cleanup in an except block would not. The partial is removed on failure, and
an unusable file is skipped when choosing what to advertise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(state): never offer a half-written backup as a restore point
Moves both version declarations to 0.1.2, dates the changelog entry, and
points the README install pins and version table at the new tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
chore(release): v0.1.2 — migration safety
@JasonZQH
JasonZQH merged commit dec58cf into main Jul 28, 2026
11 checks passed
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