fix(migration): strip the file extension when resolving a v2 description - #128
Conversation
Python v2 recorded `description` straight from os.listdir, so the value
carries ".sql"/".py". v3 compares against `mig_file.stem`, which never
does. _resolve_migration_name tried three matches and none stripped the
extension - strategy 3 tests `description in stem`, but here the stem is
a substring of the description, not the reverse - so it fell through to
the verbatim description and nothing ever matched.
Every already-applied migration therefore looked unapplied and the whole
history replayed on a v2 -> v3 upgrade.
This hit Hertex app-portal on 2026-09-09: a 0.2.206 -> 3.13.52 upgrade
replayed 90 applied migrations and crashlooped the app on the first
non-idempotent one with a duplicate key.
Each match strategy now runs against the description and its
extension-stripped form. A description with no file on disk is still
kept verbatim, so non-matching rows are unaffected.
The existing suite missed this because every fixture seeded a bare stem
("000001_create_users"), which is not what v2 ever wrote. Four tests
added, including a replay of the production failure against a
non-idempotent migration so a regression fails loudly rather than
passing on CREATE TABLE IF NOT EXISTS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YPVsva4pe4rXmQaCM1bfj5
|
Parity check done — Ruby and Node are not affected. The PR body says they were unchecked; this supersedes that. tina4-ruby — not affected. Uses the full filename as the identifier consistently on both sides: tina4-nodejs — not affected. Uses stems like Python (
So Python is the only one of the four where the stored identifier and the compared identifier disagree, and the cause is specific to it: Python v2 recorded One adjacent observation in Node, not verified and not part of this PR: |
A v2 -> v3 upgrade replays every already-applied migration.
The defect
Python v2 (
tina4_python/Migration.py, 0.2.x) recordeddescriptionstraight fromos.listdir, so the stored value carries the file extension:v3 compares against
mig_file.stem, which never carries it._resolve_migration_nametried three match strategies and none stripped the extension. Strategy 3 testsdescription in stem, but here the stem is a substring of the description, not the reverse, so it fell through to returning the description verbatim._get_executed()then returns ids ending in.sqlwhilemigrate()looks for ids without it. Nothing matches, so the entire applied history re-runs.Impact
Hit Hertex app-portal on 2026-09-09. A 0.2.206 -> 3.13.52 upgrade replayed 90 applied migrations and crashlooped the app on the first non-idempotent one:
DDL migrations using
CREATE TABLE IF NOT EXISTSreplay silently, so the damage only surfaces at the first migration carrying data.The fix
Each match strategy now runs against the description and its extension-stripped form. A description with no matching file on disk is still kept verbatim, so non-matching rows behave exactly as before.
Why the existing suite missed it
Every fixture in
test_issue_115_v2_upgrade.pyseedsdescriptionas a bare stem (000001_create_users), which is not what v2 ever wrote. Ten tests passed over a live defect.Four tests added:
test_v2_description_with_sql_extension_resolves_to_the_stemtest_v2_filenames_do_not_replay_on_upgrade- replays the production failure against a non-idempotent migration, so a regression fails loudly instead of passing onCREATE TABLE IF NOT EXISTStest_v2_python_migration_filename_also_resolves-.pymigrationstest_unrelated_description_still_falls_back_verbatim- control against over-reachVerification
test_issue_115_v2_upgrade.pyParity
Not a straight port. tina4-php is shaped differently and looks unaffected: its v2 table already carried
migration_id, and it uses full filenames as the identifier (x.sql). Python is the one that moved to stems while v2 stored filenames. Ruby and Node have not been checked and may warrant a look.🤖 Generated with Claude Code
https://claude.ai/code/session_01YPVsva4pe4rXmQaCM1bfj5