fix(address): make region-nullable migration atomic and self-healing - #215
Open
sroussey wants to merge 1 commit into
Open
fix(address): make region-nullable migration atomic and self-healing#215sroussey wants to merge 1 commit into
sroussey wants to merge 1 commit into
Conversation
The SQLite `state_or_country` rebuild in `migrateAddressRegionNullable` issued five top-level DDL statements — DROP legacy / RENAME / drop per-index / async `setupDatabase()` / INSERT / DROP legacy — with no transaction boundary. An interrupt between the RENAME and the final INSERT stranded every historical address in `addresses__legacy_region`, and the migration's own early-return checks (`tableExists`, `notnull`) would then treat the freshly rebuilt (empty, nullable) `addresses` as already-migrated, so the stranded rows were never recovered. - Wrap the five mutations in a single `BEGIN IMMEDIATE`/`COMMIT` block (manual, not `db.transaction(...)`, because `setupDatabase()` is async and the wrapper's callback form must be sync). An interrupt inside now rolls back to the original `addresses` table. - Ahead of the existing shape checks, self-heal a stranded `addresses__legacy_region` from a prior interrupted run: if legacy carries data and `addresses` is missing or empty, restore the pre-migration state; otherwise discard the stale scratch table. - Postgres path is left unchanged (a single-statement `DROP NOT NULL` is already atomic); the JSDoc now states that explicitly so a future reader does not add redundant wrapping. Tests: - Replace the `indexes.length > 0` threshold with an equality check against the exact index set a fresh `setupAllDatabases()` produces on `addresses` (memoized helper reused across the two migrated-shape assertions). - Add "recovers from an interrupt between RENAME and INSERT" — seeds legacy rows, simulates the exact stranded state (rename aside, empty rebuilt `addresses`), then asserts the next boot recovers every row. - Add "rolls back a failed migration attempt" — proxies `ADDRESS_REPOSITORY_TOKEN.setupDatabase` to throw once, expects the whole call to reject, and asserts the legacy shape and rows survive with no leftover scratch table. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MVYgNUoS8194CMP9fDCQLL
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The SQLite branch of
migrateAddressRegionNullable(added in the parent PR #213 base branch) ran the five rebuild DDL steps at the top level with no transaction boundary —DROP legacy/RENAME/ drop-per-index / asyncsetupDatabase()/INSERT ... SELECT/DROP legacy. An interrupt between the RENAME and the final INSERT strands every historical address inaddresses__legacy_region, and the migration's own early-return guards (tableExists,notnull === 0) then treat the freshly rebuilt (empty, nullable)addressesas already-migrated on the next boot — so the stranded rows are never recovered. Junction tables referenceaddress_hash_id, so the data cannot simply be re-extracted.This PR closes both ends of that failure mode without changing the postgres path (already atomic — one
DROP NOT NULLDDL).Changes
BEGIN IMMEDIATE/COMMITblock, with a matchingROLLBACKon any thrown error. The block is manual rather thandb.transaction(...)because one of the enclosed steps (ADDRESS_REPOSITORY_TOKEN.setupDatabase()) is async and the wrapper's sync-callback form cannotawait. Every DDL step runs on the singlegetDb()connection, so it enrolls in the transaction.sqlite_masterforaddresses__legacy_region; if present, choose in an atomic block based on the two row counts:addressesis missing/empty → restore the pre-migration state (drop the emptyaddresses, rename legacy back) so the normal flow below rebuilds it,DROP NOT NULLis atomic and does not need wrapping, so a future reader does not add redundant ceremony.Test plan
bun run test src/storage/address/AddressRegionNullableMigration.test.ts— 5 passed (existing 3 preserved, 2 added).bun run test src/storage/address/— 69 passed across 4 files (regression sweep on the address tier).expect(indexes.length).toBeGreaterThan(0)with an equality check against the exact user-index tuple set a freshsetupAllDatabases()produces onaddresses(memoized helper, reused across the two migrated-shape assertions). A silent unindexed rebuild now fails the assertion; the previous threshold would not.setupDatabase()a fresh emptyaddresses), then asserts the next boot reaches every seeded row, has the correct index set, and cleared the legacy table.ADDRESS_REPOSITORY_TOKENso its firstsetupDatabase()call throws, expectssetupAllDatabases()to reject, and asserts the legacy shape (notnull === 1) plus every seeded row still exist, with noaddresses__legacy_regionleftover.Stacks on top of #213 (
forms-worklist-streaming); rebase after that lands.Generated by Claude Code