Skip to content

The migrator was not atomic, and could record nothing at all - #175

Merged
anilcancakir merged 6 commits into
masterfrom
fix/migrator-is-not-atomic
Sep 20, 2026
Merged

anilcancakir merged 6 commits into
masterfrom
fix/migrator-is-not-atomic

Conversation

@anilcancakir

Copy link
Copy Markdown
Member

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

run applied 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.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, 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 BEGIN with cannot 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.autocommit is false precisely while a transaction is open and is the only thing that can tell the two cases apart. Both shapes now work:

await Migrator().run([...]);                        // the migrator's transaction
await DB.transaction(() => Migrator().run([...]));  // yours

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.

_ensureMigrationsTable creates the ledger with a raw execute, which DatabaseManager never hears about, and getColumns caches the empty answer a missing table gives. _recordMigration goes through QueryBuilder, which filters every key against that cache, and an empty filter makes insert return 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() async compiles and is a silent defect

run cannot await a void, 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, because DatabaseManager().getColumns and hasColumn both answer futures and are the natural things to reach for inside a migration that needs to sense a schema.

Gates

  • dart analyze — no issues
  • dart format . — no diff
  • flutter test — 1570 green, 8 of them new
  • Every new test was red before the fix and names the state it prevents
  • Post-change sync: CHANGELOG.md, doc/database/migrations.md (two new sections plus TOC), skills/magic-framework/references/eloquent-orm.md, SKILL.md version bumped

…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

codecov Bot commented Sep 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@kodizm

kodizm Bot commented Sep 20, 2026

Copy link
Copy Markdown

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

lib/src/database/migrations/migrator.dart:120 — correctness. The run now always has a transaction open while up() executes, so a migration that manages its own transaction breaks in a way it did not before. DB.beginTransaction() inside up() throws cannot start a transaction within a transaction. Worse, a DB.commit() inside up() closes the migrator's transaction, so every later migration runs unprotected and the migrator's own COMMIT at line 139 throws cannot commit - no transaction is active — after all migrations have run and their ledger rows are committed. The caller sees a throw from a run that fully succeeded, and on the next launch nothing is pending. Verified both error texts: sqlite3 :memory: "BEGIN; CREATE TABLE a(x); BEGIN;"cannot start a transaction within a transaction; sqlite3 :memory: "COMMIT;"cannot commit - no transaction is active. The docs cover the host-wraps-the-call case but say nothing about DB.beginTransaction/commit inside a migration, which doc/database/getting-started.md:195 documents as a supported pattern. Nothing in this repo does it, so this is a consumer-facing break.

Minor

lib/src/database/migrations/migrator.dart:102 — the doc block and CHANGELOG both state the tracking table is created outside the transaction "so a failed first run still leaves somewhere to record the retry". That holds only when the migrator owns the transaction. In the DB.transaction(() => Migrator().run([...])) shape the PR explicitly supports, _ensureMigrationsTable's CREATE TABLE is inside the caller's transaction and the caller's rollback drops it. Harmless today (every entry point re-creates it), but the stated invariant and the test that asserts it only cover the owned case.

lib/src/database/migrations/migrator.dart:142clearSchemaCache() runs only on the success path. After a ROLLBACK, any column list cached during the undone migrations stays in DatabaseManager._schemaCache describing schema that no longer exists. Narrow today because the only entry populated mid-run is magic_migrations, which survives the rollback, but a migration that reads a table it just altered would leave the manager lying about that table until the next successful run.

doc/database/migrations.md:127 — the new "up() and down() are synchronous" section names DatabaseManager().getColumns and hasColumn as the futures to avoid, but not Schema.hasTable / Schema.hasColumn / Schema.getColumns, which this same page documents at line 235 and which are what a migration author would actually reach for. It also offers only DB.statement and DB.select as the synchronous alternatives, omitting Schema.create and Schema.table — the API every other example on the page uses.

lib/src/database/migrations/migrator.dart:60-100 — the dartdoc now has two openings: the original summary and example, then a second summary ("Runs every pending migration, or none of them.") after the code fence. Dartdoc takes the first sentence as the one-line summary, so the new headline does not surface.

Not flagged, for scope: rollback() and reset() are still per-migration with no transaction, so the guarantee run now makes does not hold in reverse. Pre-existing, but worth a follow-up now that the two differ.

Tests

Eight new tests in test/database/migrator_atomicity_test.dart cover partial-migration rollback, whole-run rollback, an empty ledger after failure, a successful retry, the caller-owned transaction in both the success and failure shape, and the stale-schema-cache regression by reading the columns deliberately first. The Major above is the one behaviour the new transaction changes that nothing exercises.

Checks I ran

  • dart analyze lib/src/database/migrations/ test/database/migrator_atomicity_test.dartNo issues found!, exit 0.
  • flutter test test/databaseAll tests passed!, 104 tests, exit 0. I did not run the full suite.
  • sqlite3 :memory: for the two nested-transaction error texts quoted above.
  • Read: migrator.dart, migration.dart, the new test, CHANGELOG.md, doc/database/migrations.md, plus database_manager.dart, query_builder.dart, db.dart and schema.dart for the callers. Not reviewed: the skills/magic-framework/SKILL.md and references/eloquent-orm.md patches.

…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.
@anilcancakir

Copy link
Copy Markdown
Member Author

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 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 rather than one.

A SAVEPOINT instead of a BEGIN. It nests, so the autocommit branch is gone entirely and the same code path runs whether or not the host wrapped the call. Verified in all three shapes:

SAVEPOINT m; CREATE TABLE a(x); RELEASE m;                  -> a
BEGIN; SAVEPOINT m; CREATE TABLE a(x); RELEASE m; COMMIT;   -> a
SAVEPOINT m; CREATE TABLE a(x); ROLLBACK TO m; RELEASE m;   -> 0 tables

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 and stops the loop there rather than carrying on unprotected. DB.beginTransaction() inside up() still throws from sqlite, which is self-describing and unwinds through the same rollback.

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:

  • clearSchemaCache() now runs on the rollback path too.
  • The "tracking table outside the transaction" claim only held for an owned run. It now says exactly that, and why the wrapped case is harmless.
  • The synchronous/async split 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 / Schema.table as the synchronous alternatives. Both lists are complete now, with a PRAGMA table_info example for sensing a schema synchronously.
  • run's dartdoc had two openings; merged, so the real summary is the one dartdoc takes.

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: rollback() and reset() are still per-migration with no savepoint, so the guarantee run now makes does not hold in reverse. Agreed that is worth closing, and I have left it out of this PR deliberately rather than by oversight: it is a behaviour change to two more entry points and deserves its own review rather than riding a fix whose diff is already this size.

dart analyze clean, dart format . no diff, flutter test 1573 green.

@kodizm

kodizm Bot commented Sep 20, 2026

Copy link
Copy Markdown

Note

Kodizm (AI-generated). May contain mistakes; verify before acting.

The SAVEPOINT rewrite closes the Major from round 1 and all four round-1 minors are fixed; what is left is one test that proves nothing and two rough edges on the contract-breaking path itself.

Since fe53377: BEGIN/autocommit branching replaced by a single SAVEPOINT magic_migrator + ROLLBACK TO/RELEASE, a post-up() guard that throws naming a migration which closed the transaction, clearSchemaCache() on the rollback path, the dartdoc/doc/CHANGELOG corrections, and three new tests.

Minor

test/database/migrator_atomicity_test.dart:247 — test-coverage. a rollback clears the schema cache too passes with or without the new clearSchemaCache() in the catch block, so it does not cover the line it was written for. Nothing in that run ever populates _schemaCache['cachefail_a']: _TwoStepMigration.up uses raw DB.statement, and the only cache-populating call in run is QueryBuilder.insertgetColumns('magic_migrations'), which is never reached because the migration throws first. The final getColumns('cachefail_a') therefore falls through to PRAGMA table_info on a table that does not exist and returns empty either way (database_manager.dart:107-122). Worth noting while fixing it: because up() is synchronous and every cache-populating API (getColumns, QueryBuilder) is a future, the only entry the cache can hold mid-run is magic_migrations, which survives the rollback anyway — so this may be a guard with no reachable case today, which is fine, but the test should say that rather than assert a tautology.

lib/src/database/migrations/migrator.dart:163 — correctness. In the wrapped shape the PR supports, the new guard's diagnostic is destroyed. DB.transaction(() => Migrator().run([...])) with a migration that calls DB.commit(): the commit closes the host's transaction too, autocommit is true so run skips the unwind and rethrows the StateError naming the migration, then DB.transaction's catch calls rollback() unconditionally (db.dart:189-191) and sqlite throws over it. Verified: sqlite3 :memory: "ROLLBACK;"Error: stepping, cannot rollback - no transaction is active. The caller sees that instead of the message identifying the offending migration. The two new guard tests both use the unwrapped shape.

lib/src/database/migrations/migrator.dart:147 — the guard makes the failure early and named, but not atomic: the self-committing migration's own statements and every earlier migration's ledger row stay committed, which is exactly the half-applied, partly-unrecorded state the rest of this PR exists to prevent. The test only passes because _CommittingMigration uses CREATE TABLE IF NOT EXISTS; a real migration in that shape still boot-loops on retry. It is a documented contract break so the behaviour is defensible, but the doc and dartdoc headline "runs every pending migration, or none of them" holds for every shape except this one, and neither says so.

Tests

Three 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

  • dart analyze lib/src/database/migrations test/database/migrator_atomicity_test.dartNo issues found!, exit 0.
  • dart format --output=none --set-exit-if-changed on the same paths → 0 changed, exit 0.
  • flutter test test/databaseAll tests passed!, 107 tests, exit 0. I did not run the full suite.
  • sqlite3 :memory: "ROLLBACK;" for the error text quoted above.
  • Read this round: the full migrator.dart, the full new test file, and the patches for CHANGELOG.md, doc/database/migrations.md, skills/magic-framework/references/eloquent-orm.md; plus db.dart and database_manager.dart as callers. The doc snippet's DB.select(...).any((Map<String, dynamic> row) => ...) type-checks against db.dart:85.

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.
@anilcancakir

Copy link
Copy Markdown
Member Author

Round 2 addressed. All three were correct.

The wrapped-shape diagnostic. 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 then succeeded had the mirror of it on the commit() branch. Both are guarded on autocommit now. Closing it from inside the callback is still a mistake and the guard does not make it one less; it stops that mistake from hiding the next one. New test covers the exact path you named, and it fails without the guard with the error text you quoted.

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: 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 clearSchemaCache() line stays, with a comment that says it is deliberately untested and why, and that it stops being a no-op the day a synchronous introspection helper lands. A test that passes with its own fix deleted is worse than no test.

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 IF NOT EXISTS still boot-loops on retry.

On your round-1 note about rollback() and reset(): still deliberately out of scope, for the same reason. They are two more entry points changing behaviour and the diff here is already large enough to review badly.

dart analyze clean, dart format . no diff, flutter test 1573 green.

@kodizm

kodizm Bot commented Sep 20, 2026

Copy link
Copy Markdown

Note

Kodizm (AI-generated). May contain mistakes; verify before acting.

The three round-2 points are closed correctly; the new DB.transaction guard is right on the catch branch but silent on the success branch, and the facade's own doc page still states the old behaviour.

Since f315d1b: the "not atomic for the self-committing case" caveat added to the dartdoc, doc/database/migrations.md and the CHANGELOG; the vacuous schema-cache test deleted with a comment explaining why the line stays; DB.transaction guarding both commit() and rollback() on autocommit; one new test for the wrapped-shape diagnostic.

Major

lib/src/facades/db.dart:200 — correctness. The guard is asymmetric and the success branch loses a signal rather than preserving one. On the catch branch it protects the callback's real error, which is the fix asked for. On the success branch there is no error to protect, so it turns a loud failure into silence: a callback that calls DB.commit() half way and then keeps writing had commit() throw cannot commit - no transaction is active and told the caller something was wrong; it now returns normally while the writes after that commit() ran outside any transaction and the caller believes the block was atomic. That is the same class of defect the rest of this PR exists to remove, and Migrator.run treats the identical situation as an error worth naming the culprit for (migrator.dart:155). Nothing exercises the success branch of the guard.

Minor

doc/database/getting-started.md:190 — the page still says "If any operation throws an exception, the entire transaction is rolled back", which is now conditional: a callback that closed the transaction itself gets no rollback. CLAUDE.md's post-change sync asks for the matching doc/** page when a facade changes, and this behaviour change to DB.transaction only reached doc/database/migrations.md. The "Manual Transaction Control" section immediately below is the pattern that produces the newly-guarded case, so it is the natural place to say it.

lib/src/facades/db.dart:183[CommonDatabase.autocommit] will not resolve to a link: db.dart imports only query_builder.dart, database_manager.dart and magic.dart, so the symbol is not in scope for dartdoc. Same reference in migrator.dart:96. Harmless, renders as literal text.

Tests

The new is named even when the host wrapped the call in its own transaction covers the exact path from round 2 and is a real test — it fails without the db.dart catch-branch guard with the cannot rollback text. Deleting the vacuous cache test was the right call and the comment left behind says why. The success-branch guard (if (!autocommit) commit()) has no test; DB.transaction has no test file of its own at all, and everything exercising it lives in test/database/migrator_atomicity_test.dart.

Checks I ran

  • dart analyze lib/src/facades/db.dart lib/src/database/migrations test/database/migrator_atomicity_test.dartNo issues found!, exit 0.
  • dart format --output=none --set-exit-if-changed on the same paths → 0 changed, exit 0.
  • flutter test test/databaseAll tests passed!, 107 tests, exit 0. I did not run the full suite.
  • Read this round: the full migrator.dart and db.dart, the five patches in this range, and doc/database/getting-started.md. Not re-reviewed: files unchanged since f315d1b.

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.
@anilcancakir

Copy link
Copy Markdown
Member Author

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 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.

Both minors fixed. doc/database/getting-started.md 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, so the caveat sits there with a line saying the manual API is an alternative to DB.transaction rather than something to use inside one. [CommonDatabase.autocommit] is plain code formatting in both files now.

Three new tests, which also closes your note that DB.transaction's success branch had none: 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, which wraps Migrator().run in DB.transaction, is green against this branch.

dart analyze clean, dart format . no diff, flutter test 1576 green.

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: rollback() and reset() are still per-migration with no savepoint, so the guarantee run now makes does not hold in reverse. It is a behaviour change to two more entry points and this diff is already large enough to review badly.

@kodizm

kodizm Bot commented Sep 20, 2026

Copy link
Copy Markdown

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 7acfd1c: DB.transaction's success branch throws a StateError naming what happened instead of quietly skipping the commit, the dartdoc/CHANGELOG state the asymmetry and why each branch differs, doc/database/getting-started.md carries the caveat next to Manual Transaction Control, [CommonDatabase.autocommit] is plain code formatting in both files, and three tests cover the self-committing callback, the ordinary success and the ordinary failure. Nothing in lib/ or example/ calls DB.transaction, so the breaking change has no in-repo callers to update (grep -rn "DB.transaction" lib test example - only the facade itself and the test file).

Minor

skills/magic-framework/references/facades-api.md:188 — maintainability. The row still reads "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 SKILL.md + the matching reference when a facade API changes; this round reached doc/database/getting-started.md but not the skills mirror, and eloquent-orm.md:337 only documents the migrator half.

Tests

a callback that commits half way is an error, not a silent success is the real one - it fails if the new if (autocommit) throw is removed, since the old code returned normally. The other two pin the paths that must not regress (ordinary table exists and 7 returned; undone rolled back). That closes the round-3 note that the success branch had no coverage.

Checks I ran

  • flutter pub get → exit 0 (the checkout had no resolved package config; the first dart analyze failed on uri_does_not_exist until this ran).
  • dart analyze lib/src/facades/db.dart lib/src/database/migrations test/database/migrator_atomicity_test.dartNo issues found!, exit 0.
  • dart format --output=none --set-exit-if-changed on the same paths → 4 files (0 changed), exit 0.
  • flutter test test/databaseAll tests passed!, 110 tests including the three new ones, exit 0. I did not run the full suite.
  • Read this round: the full db.dart transaction path, the new test group, and the patches for CHANGELOG.md, doc/database/getting-started.md and migrator.dart. Not re-reviewed: files unchanged since 7acfd1c.

The rollback() / reset() gap stays open by your choice and I am not re-raising it.

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.
@anilcancakir

Copy link
Copy Markdown
Member Author

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: CLAUDE.md asks for the skills mirror when a facade API changes, round 3 reached doc/database/getting-started.md and not facades-api.md, and the row there stated my own new behaviour wrongly in both directions. Leaving a doc saying "auto-commit on success, auto-rollback on error" when neither is now unconditional is shipping a known-false sentence, which is different from disagreeing about a judgement call.

facades-api.md now says what both branches do and why the callback must not close the transaction itself. SKILL.md bumped with it.

Thank you for confirming the breaking change has no in-repo callers — I had not checked example/ and you are right that grep -rn "DB.transaction" lib test example turns up only the facade and the test file.

The rollback() / reset() gap stays open by choice, as noted.

@anilcancakir

Copy link
Copy Markdown
Member Author

@kodizm please re-review.

Since your round 4: skills/magic-framework/references/facades-api.md now states what both DB.transaction branches actually do (success throws a StateError when the callback closed the transaction; failure skips the rollback so the callback's own error survives), and SKILL.md is bumped with it. That was the sync step I owed rather than a judgement call, which is why I touched it after saying I would stop revising.

Everything else is unchanged since 1ffa383. The rollback() / reset() savepoint gap stays open deliberately.

@kodizm

kodizm Bot commented Sep 20, 2026

Copy link
Copy Markdown

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 1ffa383: one commit, 6fa4920, touching two files. facades-api.md:188 now states both branches - success throws a StateError when the callback closed the transaction, failure skips the rollback so the callback's own error survives - which is exactly what lib/src/facades/db.dart:206-214 does and what the dartdoc above it says. SKILL.md frontmatter bumped 0.1.330.1.34. No code changed, so nothing earlier in the PR needs re-reviewing.

One observation, not a finding: SKILL.md's stamp comment on line 8 still reads Skill v0.1.31 (2026-09-19) while the frontmatter says 0.1.34. That drift predates this PR - master already had 0.1.33 in frontmatter against the same v0.1.31 comment - so it is not yours to fix here.

Tests

Unchanged this round. The three DB.transaction tests from round 3 and the migrator atomicity group still cover the behaviour the new doc row describes; I verified them green in round 4.

Checks I ran

  • git show 6fa4920 → docs only, 2 files, 2 insertions / 2 deletions, exit 0.
  • Read lib/src/facades/db.dart:173-214 to confirm the new table row matches the implemented branches.
  • No test or analyze run this round: the commit touches no Dart. Round 4's flutter test test/database (110 passed, exit 0) still stands for the code in this PR.
  • Not re-reviewed: everything unchanged since 1ffa383.

The rollback() / reset() savepoint gap remains open by your choice and I am not re-raising it.

@anilcancakir
anilcancakir merged commit d38d0ee into master Sep 20, 2026
6 checks passed
anilcancakir added a commit that referenced this pull request Sep 20, 2026
…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.
@anilcancakir anilcancakir mentioned this pull request Sep 21, 2026
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