The migrator was not atomic, and could record nothing at all - #175
Conversation
…at all Three faults, found while adopting the migrator in a consumer app. Migrator.run applied and recorded each migration in turn with no transaction anywhere, so a failure part way left that migration's earlier statements applied and its ledger row absent. The next launch re-ran it from its first statement, met the table it had already created, and failed identically. A host that migrates inside Magic.init before runApp has no UI to report that from, and the only repair is deleting the database. The whole run is one transaction now rather than each migration: a ledger recording one and not the next describes a schema nobody designed, and the host cannot learn which half it has. A caller that already opened a transaction is not nested into, because sqlite refuses a nested BEGIN and opening one unconditionally would break every host that wraps the call itself, which is what a host had to do before this. CommonDatabase.autocommit is false exactly while a transaction is open and is the only thing that can tell the two cases apart. The tracking table stays outside the transaction so a failed first run leaves somewhere to record the retry. Second fault, latent rather than observed: _ensureMigrationsTable creates the ledger with a raw execute that DatabaseManager never hears about, and getColumns caches the EMPTY answer a missing table gives. _recordMigration filters its keys against that cache through QueryBuilder, and an empty filter makes insert return 0 without inserting and without throwing. So anything that read the ledger's columns first left every migration re-running on every launch for ever. One clearSchemaCache after the create, with a test that reproduces it by reading the columns deliberately. Third, documentation: void up() async compiles and is recorded complete at its first suspension, because run cannot await a void. The contract now says so and names the synchronous alternatives.
This repo's TOC check requires an <a name> tag rather than relying on GitHub's generated slug, and CI caught both new entries.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
Note Kodizm (AI-generated). May contain mistakes; verify before acting. The atomicity fix and the cache fix are both correct and properly covered; the new transaction introduces one undocumented constraint on migrations that manage their own transaction, and it can report a failed run that actually committed. Major
Minor
Not flagged, for scope: TestsEight new tests in Checks I ran
|
…t failure Kodizm review round 1, and the Major is a real regression the first version of this PR introduced. DB.beginTransaction, commit and rollback are a documented pattern (doc/database/getting-started.md), so a migration written that way was legitimate before run opened a transaction of its own. With the BEGIN it was not, and the failure shape was the worst possible one: a commit() inside up() closed the migrator's transaction, so every later migration ran unprotected and the closing COMMIT threw AFTER every migration had succeeded and committed its ledger row. The caller saw a failure from a run that fully worked, and the retry found nothing pending. Two changes. A SAVEPOINT rather than a BEGIN, which nests, so the autocommit branch is gone and the same code path runs whether or not the host wrapped the call. Verified in all three shapes: no transaction open, inside one, and unwinding through ROLLBACK TO. And a guard after each up(): autocommit is false while a savepoint is open, so a true means the migration ended the transaction under us. It throws naming the migration rather than failing late and confusingly, and stops the loop there rather than carrying on unprotected. Three minors, all correct. The schema cache is cleared on the rollback path too. The 'tracking table outside the transaction' claim only held for an owned run and now says so. The doc named DatabaseManager's futures but not Schema.hasTable / hasColumn / getColumns, which are what a migration author would actually reach for, and offered no Schema.create or Schema.table as the synchronous alternatives. Also merged run's two dartdoc openings, so the real summary is the one dartdoc takes.
|
Round 1 addressed. The Major is a real regression the first version of this PR introduced, and the failure shape you described is the worst one available: a Two changes rather than one. A A guard after each That makes it a documented breaking change rather than a silent one, and the CHANGELOG says so. All three minors were correct and are fixed:
Three new tests cover the Major: the error names the offending migration, a later migration never runs after one breaks the contract, and the rollback clears the cache. All three were red before the fix. On the one you did not flag:
|
|
Note Kodizm (AI-generated). May contain mistakes; verify before acting. The Since Minor
TestsThree new tests: the error names the offending migration, a later migration never runs after one breaks the contract, and the schema-cache rollback — the third is vacuous as described above. The other two are real and fail without the guard. Checks I ran
|
Kodizm review round 2. Three minors, all correct. The one that matters: in the wrapped shape this PR tells hosts they may use, the new guard's diagnostic was destroyed. A migration calling DB.commit() closes the HOST's transaction too, so DB.transaction's catch found nothing to roll back and sqlite threw 'cannot rollback - no transaction is active' over the StateError naming the migration. The caller saw a message about transactions instead of the cause. Fixed in DB.transaction rather than in the migrator, because it is not a migrator problem: any callback that closes the transaction itself had its own error replaced, and one that committed and succeeded had the mirror of it on the other branch. Both are guarded on autocommit now. Closing it from inside is still a mistake; the guard stops that mistake from hiding the next one. Second: my 'a rollback clears the schema cache too' test was vacuous. It passed with the clearSchemaCache deleted, because nothing in that run ever populates the cache for that table. I did not mutate it, which is exactly the rule I hold everything else to. Deleted rather than repaired: there is no reachable case today, since up() is synchronous while every cache-populating API is a future, so the only entry the cache can hold mid-run is magic_migrations, which survives the rollback anyway. The line stays with the reasoning in a comment that says it is deliberately untested. Third: the guard is early and named but NOT atomic, and 'all of them, or none' does not hold for it. By the time the guard sees anything the offending migration's statements and every earlier ledger row are committed and there is nothing to unwind. The dartdoc, the doc page and the CHANGELOG all say so now rather than leaving the headline overclaiming.
|
Round 2 addressed. All three were correct. The wrapped-shape diagnostic. Fixed in The vacuous test. You are right, and I should have caught it: I mutated the savepoint and the guard and did not mutate this one. Deleted rather than repaired, because there is no reachable case today for the reason you gave: The headline overclaimed. The guard is early and named but not atomic, and "all of them, or none" does not hold for that one case: by the time the guard sees anything the offending migration's statements and every earlier ledger row are committed and there is nothing to unwind. The dartdoc, the doc page and the CHANGELOG all state it as the exception now, including that a migration in that shape without On your round-1 note about
|
|
Note Kodizm (AI-generated). May contain mistakes; verify before acting. The three round-2 points are closed correctly; the new Since Major
Minor
TestsThe new Checks I ran
|
Kodizm review round 3. The Major is right and I missed it. Guarding the catch branch protects the callback's real error, which is what round 2 asked for. Guarding the success branch the same way does the opposite: there is no error to protect there, so it converts a loud failure into silence. A callback that commits half way and keeps writing ran everything after that point outside any transaction, and returning normally told the caller the block was atomic when it was not. That is the class of defect the rest of this PR exists to remove, and Migrator.run already treats the identical situation as an error worth naming the culprit for. The success branch throws now, naming what happened and what to do instead. The catch branch still skips, for the reason it was added. Two minors, both correct. doc/database/getting-started.md still said the entire transaction is rolled back on any exception, which is now conditional, and its Manual Transaction Control section is exactly the pattern that produces the guarded case. And [CommonDatabase.autocommit] could not resolve as a dartdoc link because the symbol is not in scope in either file. Three new tests: the self-committing callback is an error, an ordinary callback still commits and returns its value, an ordinary failure still rolls back. The mutation that never throws turns the first red. Cross-checked against the consumer this came from: its migration suite is green against this branch.
|
Round 3 addressed. The Major is right and I had the asymmetry backwards. Guarding the catch branch protects the callback's real error, which is what round 2 asked for. Guarding the success branch the same way does the opposite: there is no error to protect there, so it converts a loud failure into silence. A callback that commits half way and keeps writing ran everything after that point outside any transaction, and returning normally told the caller the block was atomic when it was not. You are right that it is the same class of defect the rest of this PR exists to remove, and that The success branch throws now, naming what happened and what to do instead. The catch branch still skips, for the reason it was added. Both minors fixed. Three new tests, which also closes your note that Cross-checked against the consumer this came from: its migration suite, which wraps
That is three rounds, so I will stop revising here. One thing stays open by choice rather than oversight, and you flagged it in round 1: |
|
Note Kodizm (AI-generated). May contain mistakes; verify before acting. The round-3 Major and both minors are closed correctly; the success branch now throws, the catch branch still skips, and three tests cover both shapes - one doc surface is left out of sync. Since Minor
Tests
Checks I ran
The |
Kodizm round 4, one minor and it is a sync step I owed rather than a judgement call. facades-api.md said 'Auto-commit on success, auto-rollback on error', which is now wrong in both directions: on success a callback that closed the transaction gets a StateError rather than a commit, and on failure the rollback is conditional. CLAUDE.md's post-change sync asks for the skills mirror when a facade API changes; round 3 reached doc/database but not this. SKILL.md bumped with it.
|
Fixed, and I want to be explicit about why I am touching this after saying I would stop at three rounds. This is not a fourth round of revision. It is a sync step I owed and skipped:
Thank you for confirming the breaking change has no in-repo callers — I had not checked The |
|
@kodizm please re-review. Since your round 4: Everything else is unchanged since |
|
Note Kodizm (AI-generated). May contain mistakes; verify before acting. The round-4 minor is closed; the only commit since is the skills-mirror sync and its new text matches the code, so I have nothing left to raise. Since One observation, not a finding: TestsUnchanged this round. The three Checks I ran
The |
…atter (#177) The stamp comment said Skill v0.1.31 while the frontmatter said 0.1.34. The review that spotted it called the drift pre-existing; it is not. At the 0.0.14 release both read 0.1.31, and the three bumps that opened the gap are mine: #174, #176 and #175 each raised the frontmatter and left the comment alone. The stamp is what a reader checks to see whether the skill was verified against the current API surface, so one that lags by three revisions says the opposite of what it is for.
Three faults, all found while adopting the migrator in a consumer app rather than by reading it.
1. The run was not atomic, and that produces a host that never boots again
runapplied and recorded each migration in turn with no transaction anywhere. A failure part way through left that migration's earlier statements applied and its ledger row absent. The next launch re-ran it from its first statement, met the table it had already created, and failed identically.A host that migrates inside
Magic.initbeforerunApphas no UI to report that from, and the only repair is deleting the database.The whole run is one transaction now, not each migration. A ledger recording one migration and not the next describes a schema nobody designed, and the host has no way to learn which half it has. SQLite rolls DDL back like anything else, so this is one
BEGIN.A caller that already opened a transaction is not nested into. sqlite refuses a nested
BEGINwithcannot start a transaction within a transaction, so opening one unconditionally would break every host that wraps the call itself, which is exactly what a host had to do before this landed.CommonDatabase.autocommitis false precisely while a transaction is open and is the only thing that can tell the two cases apart. Both shapes now work:The tracking table stays outside the transaction, so a failed first run still leaves somewhere to record the retry.
2. Every migration could be applied and silently never recorded
Latent rather than observed, and it is the more interesting one.
_ensureMigrationsTablecreates the ledger with a rawexecute, whichDatabaseManagernever hears about, andgetColumnscaches the empty answer a missing table gives._recordMigrationgoes throughQueryBuilder, which filters every key against that cache, and an empty filter makesinsertreturn 0 without inserting and without throwing (query_builder.dart:278-280).So anything that read the ledger's columns before it existed would leave every migration applied and never recorded, re-running on every launch for ever. One
clearSchemaCache(_table)after the create, with a test that reproduces it by reading the columns deliberately.3.
void up() asynccompiles and is a silent defectruncannot await avoid, so an async body is recorded complete the moment it reaches its first suspension and the migrator commits over work that has not happened. Documentation only: the contract now says so and names the synchronous alternatives, becauseDatabaseManager().getColumnsandhasColumnboth answer futures and are the natural things to reach for inside a migration that needs to sense a schema.Gates
dart analyze— no issuesdart format .— no diffflutter test— 1570 green, 8 of them newCHANGELOG.md,doc/database/migrations.md(two new sections plus TOC),skills/magic-framework/references/eloquent-orm.md,SKILL.mdversion bumped