package store import ( "database/sql" "errors" "os" "path/filepath" "testing" "time" ) // migrate_test.go pins the mechanics `tmctl migrate` stands on (backlog row 174): a database an OLDER // binary wrote converges to head and unlocks the read-only path, the MONEY it holds survives the // conversion untouched, a database a NEWER binary wrote is refused before anything is written, and a // step that fails leaves nothing of itself behind. // atVersion runs fn with the migration chain TRUNCATED to v steps, so anything opened inside it sees // exactly the database a binary of that vintage would have written and worked with. // // The fixture truncates the chain instead of deleting rows from schema_version because those are not the // same thing: rolling the counter back leaves HEAD's tables in place and makes the next open re-apply // the newest step, which backlog row 49а says is not idempotent for an ALTER. Truncation also keeps the // fixture correct when a new migration lands — it never names a version or a table. // // It mutates a package-level var, which is safe because these tests do not run in parallel (nothing in // this package calls t.Parallel). func atVersion(t *testing.T, v int, fn func()) { t.Helper() full := migrations migrations = migrations[:v] defer func() { migrations = full }() fn() } // openAtVersion creates a project database exactly as a binary with v migrations would have left it. func openAtVersion(t *testing.T, path string, v int) { t.Helper() atVersion(t, v, func() { s, err := Open(path) if err != nil { t.Fatal(err) } if err := s.Close(); err != nil { t.Fatal(err) } }) } // rawVersion reads MAX(schema_version) through its own connection, deliberately NOT through the // product's own reader: the state a migration left behind must be asserted independently of the code // that reports it, and this way it can also be read from a database the product is currently refusing. func rawVersion(t *testing.T, path string) int { t.Helper() db, err := sql.Open("sqlite", "file:"+path) if err != nil { t.Fatal(err) } defer db.Close() var v int if err := db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`).Scan(&v); err != nil { t.Fatal(err) } return v } func TestMigrateBringsAnOlderProjectToHeadAndUnlocksTheReadPath(t *testing.T) { head := SchemaHead() path := filepath.Join(t.TempDir(), "book.db") openAtVersion(t, path, head-1) // The deadlock itself: the read-only projections the platform calls before every spawn refuse a // database an older binary wrote, and they never migrate it. _, err := OpenReadOnly(path) var mismatch *SchemaMismatchError if !errors.As(err, &mismatch) { t.Fatalf("a read-only open of an older schema must return the typed refusal, got %v", err) } if mismatch.Found != head-1 || mismatch.Expected != head || !mismatch.Stale() { t.Fatalf("found=%d expected=%d stale=%v, want %d/%d/true", mismatch.Found, mismatch.Expected, mismatch.Stale(), head-1, head) } applied, err := Migrate(path, nil) if err != nil { t.Fatalf("migrate: %v", err) } if applied.From != head-1 || applied.To != head { t.Fatalf("transition %+v, want v%d -> v%d", applied, head-1, head) } if got := rawVersion(t, path); got != head { t.Fatalf("schema is at v%d after migrating, want v%d", got, head) } ro, err := OpenReadOnly(path) if err != nil { t.Fatalf("the read-only path must work after a migration: %v", err) } ro.Close() } func TestMigrateIsANoOpOnAProjectAlreadyAtHead(t *testing.T) { head := SchemaHead() path := filepath.Join(t.TempDir(), "book.db") openAtVersion(t, path, head) for i := range 2 { applied, err := Migrate(path, nil) if err != nil { t.Fatalf("call %d: %v", i, err) } if applied.From != head || applied.To != head { t.Fatalf("call %d: transition %+v, want a no-op at v%d", i, applied, head) } } } func TestMigrateCreatesAProjectThatWasNeverRun(t *testing.T) { // The deploy sweep runs over every book, including one that has been accepted but never translated. // Creating it at head is what every other write open already does on first touch, and it keeps the // sweep uniform instead of making the caller ask which books exist yet. path := filepath.Join(t.TempDir(), "fresh.db") applied, err := Migrate(path, nil) if err != nil { t.Fatal(err) } if applied.From != 0 || applied.To != SchemaHead() { t.Fatalf("transition %+v, want v0 -> v%d", applied, SchemaHead()) } ro, err := OpenReadOnly(path) if err != nil { t.Fatalf("a created project must be readable at head: %v", err) } ro.Close() } // TestMigrateKeepsCommittedMoneyAndZeroesOnlyTheStaleReservation is the money test. // // It is the one property the platform's ratified ceiling formula rests on (PD-158: the argument is // committed + increment, WITHOUT reserved, precisely because a write open zeroes leftover reservations // and a read-only status does not). A migration is a write open, so it performs that recovery — and it // must move `committed` by not one cent while doing it, or every ceiling computed from a status taken // after a deploy would be wrong. func TestMigrateKeepsCommittedMoneyAndZeroesOnlyTheStaleReservation(t *testing.T) { head := SchemaHead() path := filepath.Join(t.TempDir(), "book.db") caps := Ceilings{BookUSD: 10, DayUSD: 10} // A book an older binary ran: one settled call, and one reservation whose process died before it // settled — exactly the state a crashed or killed run leaves behind. atVersion(t, head-1, func() { s, err := Open(path) if err != nil { t.Fatal(err) } defer s.Close() job := mustSnapshotAndJob(t, s) res, verdict, err := s.Reserve("book", 0.10, caps) if err != nil || verdict != ReserveOK { t.Fatalf("reserve: %v %v", verdict, err) } cp := Checkpoint{RequestHash: "h1", JobID: job.ID, Stage: "draft", Role: "translator", ModelRequested: "m", ModelActual: "m", ResponseText: "текст", UsageJSON: "{}", CostUSD: 0.04} if err := s.SettleWithCheckpoint(res, 0.04, cp, nil); err != nil { t.Fatal(err) } if _, verdict, err = s.Reserve("book", 0.25, caps); err != nil || verdict != ReserveOK { t.Fatalf("stale reserve: %v %v", verdict, err) } committed, reserved, err := s.SpentUSD("book") if err != nil || committed != 0.04 || reserved != 0.25 { t.Fatalf("fixture: committed=%v reserved=%v err=%v", committed, reserved, err) } }) if _, err := Migrate(path, nil); err != nil { t.Fatalf("migrate: %v", err) } // Twice: the second call is the idempotency half — a deploy sweep that runs over a book it already // migrated must not move money either. for i := range 2 { if i == 1 { if _, err := Migrate(path, nil); err != nil { t.Fatalf("repeat migrate: %v", err) } } ro, err := OpenReadOnly(path) if err != nil { t.Fatalf("call %d: %v", i, err) } committed, reserved, err := ro.SpentUSD("book") ro.Close() if err != nil { t.Fatal(err) } if committed != 0.04 { t.Fatalf("call %d: committed moved to %v — a migration must not touch settled money", i, committed) } if reserved != 0 { t.Fatalf("call %d: reserved=%v, want 0 — the write open owes the recovery pass", i, reserved) } } if got := rawVersion(t, path); got != head { t.Fatalf("schema v%d after migrating, want v%d", got, head) } // The checkpoint the money is anchored to is still there: `committed == SUM(checkpoints)` is the // invariant the platform settles from, so a migration that kept the number and lost the row would // pass the assertion above and still have broken the book. ro, err := OpenReadOnly(path) if err != nil { t.Fatal(err) } defer ro.Close() cp, err := ro.GetCheckpoint("h1") if err != nil || cp == nil || cp.CostUSD != 0.04 { t.Fatalf("checkpoint after migration: %+v err=%v", cp, err) } } // TestOpenRefusesAProjectNewerThanTheBinary: the write path used to accept a database it could not // understand and would then have written through pre-dating code. It is refused in both open modes, and // the refusal happens BEFORE the recovery pass — a live reservation still standing afterwards is the // proof that nothing of the refused process reached the file. func TestOpenRefusesAProjectNewerThanTheBinary(t *testing.T) { head := SchemaHead() path := filepath.Join(t.TempDir(), "book.db") s, err := Open(path) if err != nil { t.Fatal(err) } if _, verdict, err := s.Reserve("book", 0.25, Ceilings{BookUSD: 10, DayUSD: 10}); err != nil || verdict != ReserveOK { t.Fatalf("reserve: %v %v", verdict, err) } s.Close() db, err := sql.Open("sqlite", "file:"+path) if err != nil { t.Fatal(err) } if _, err := db.Exec(`INSERT INTO schema_version (version) VALUES (?)`, head+7); err != nil { t.Fatal(err) } db.Close() for name, open := range map[string]func(string) (*Store, error){"write": Open, "read-only": OpenReadOnly} { st, err := open(path) if err == nil { st.Close() t.Fatalf("%s open of a newer schema must be refused", name) } var mismatch *SchemaMismatchError if !errors.As(err, &mismatch) { t.Fatalf("%s open: %v, want a typed SchemaMismatchError", name, err) } if mismatch.Found != head+7 || mismatch.Expected != head || mismatch.Stale() { t.Fatalf("%s open: found=%d expected=%d stale=%v", name, mismatch.Found, mismatch.Expected, mismatch.Stale()) } } if _, err := Migrate(path, nil); !errors.As(err, new(*SchemaMismatchError)) { t.Fatalf("migrate of a newer schema must be refused with the typed error, got %v", err) } // Nothing was written: the reservation the fixture left standing is still standing, so the recovery // pass never ran on a schema this binary does not know. ro, err := sql.Open("sqlite", "file:"+path) if err != nil { t.Fatal(err) } defer ro.Close() var reserved float64 if err := ro.QueryRow(`SELECT COALESCE(SUM(reserved_usd), 0) FROM spend WHERE book_id = 'book'`).Scan(&reserved); err != nil { t.Fatal(err) } if reserved != 0.25 { t.Fatalf("reserved=%v after three refused opens, want the fixture's 0.25 untouched", reserved) } } // TestAFailedMigrationStepLeavesTheDatabaseAtItsPreviousVersion answers, by execution, the question // `tmctl migrate` raises: is APPLYING a step atomic? It is — DDL is transactional in SQLite and the // version row is inserted in the SAME transaction as the step — so a process that dies mid-migration // leaves a database at its previous version rather than a half-applied one whose ALTER steps (row 49а) // will not converge on the next try. The migration bodies themselves are untouched by this pack. func TestAFailedMigrationStepLeavesTheDatabaseAtItsPreviousVersion(t *testing.T) { head := SchemaHead() path := filepath.Join(t.TempDir(), "book.db") // A step whose FIRST statement succeeds and whose second does not: if application were not atomic, // `probe` would outlive the failure. Restored with defer, so a panic inside Open cannot leave the // package-level chain rewritten for whatever runs next. full := migrations defer func() { migrations = full }() migrations = append(append([]string(nil), migrations...), `CREATE TABLE probe (x INTEGER); THIS IS NOT SQL;`) s, err := Open(path) if err == nil { s.Close() t.Fatal("a migration step that does not execute must fail the open") } if got := rawVersion(t, path); got != head { t.Fatalf("schema recorded v%d after a failed step, want the previous v%d", got, head) } db, err := sql.Open("sqlite", "file:"+path) if err != nil { t.Fatal(err) } defer db.Close() var probes int if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'probe'`).Scan(&probes); err != nil { t.Fatal(err) } if probes != 0 { t.Fatal("the failed step's first statement survived: applying a migration is not atomic") } } // TestADatabaseWithNothingAppliedIsAVersionAndNotAnError: a file that exists but has no // schema_version table — a process killed between creating one and committing the first migration — // used to fail the read path with prose and exit 1, i.e. as an unrepairable project. It is version 0: // the SAME typed refusal as any other mismatch, so the same "caught it → migrate → retry" repairs it. func TestADatabaseWithNothingAppliedIsAVersionAndNotAnError(t *testing.T) { dir := t.TempDir() empty := filepath.Join(dir, "empty.db") if err := os.WriteFile(empty, nil, 0o644); err != nil { t.Fatal(err) } _, err := OpenReadOnly(empty) var mismatch *SchemaMismatchError if !errors.As(err, &mismatch) { t.Fatalf("a database with nothing applied must be the typed refusal, got %v", err) } if mismatch.Found != 0 || mismatch.Expected != SchemaHead() || !mismatch.Stale() { t.Fatalf("found=%d expected=%d stale=%v, want 0/%d/true", mismatch.Found, mismatch.Expected, mismatch.Stale(), SchemaHead()) } if _, err := Migrate(empty, nil); err != nil { t.Fatalf("and a migration must repair it: %v", err) } ro, err := OpenReadOnly(empty) if err != nil { t.Fatalf("read path after the repair: %v", err) } ro.Close() // A file that is not a database at all must NOT read as "version 0, migrate me". junk := filepath.Join(dir, "junk.db") if err := os.WriteFile(junk, []byte("this is not a database, it is a text file"), 0o644); err != nil { t.Fatal(err) } if _, err := OpenReadOnly(junk); err == nil || errors.As(err, &mismatch) { t.Fatalf("a corrupt file must fail loudly and NOT as a schema mismatch, got %v", err) } } // TestTheMigrationSeamRunsUnderTheLockAndOnlyWhenAStepWillRun pins the placement `tmctl migrate` takes // its restore point in. The three properties are what make a backup a backup: it runs BEFORE the first // step (a copy taken afterwards is not a restore point), only when there IS a step (else a repeat call // litters, and its second-precision stamp collides), and inside the lock (else a project a live run // owns is copied in full for a migration that is refused anyway — once per retry). func TestTheMigrationSeamRunsUnderTheLockAndOnlyWhenAStepWillRun(t *testing.T) { head := SchemaHead() path := filepath.Join(t.TempDir(), "book.db") openAtVersion(t, path, head-1) var seen []Migration versionAtHook := -1 applied, err := Migrate(path, func(m Migration) error { seen = append(seen, m) versionAtHook = rawVersion(t, path) // The lock is ours while the hook runs: a second opener must be turned away, which is what // makes "back up here" different from "back up before calling". if s, err := Open(path); !errors.Is(err, ErrLocked) { if err == nil { s.Close() } t.Errorf("the seam must run INSIDE the project lock; a concurrent open got %v", err) } return nil }) if err != nil { t.Fatal(err) } if len(seen) != 1 || seen[0] != (Migration{From: head - 1, To: head}) { t.Fatalf("the seam saw %+v, want exactly one v%d -> v%d", seen, head-1, head) } if versionAtHook != head-1 { t.Fatalf("the database was at v%d when the seam ran, want the PRE-migration v%d", versionAtHook, head-1) } if applied.From != head-1 || applied.To != head { t.Fatalf("transition %+v", applied) } // Nothing left to apply → the seam does not run at all. seen = nil if _, err := Migrate(path, func(m Migration) error { seen = append(seen, m); return nil }); err != nil { t.Fatal(err) } if len(seen) != 0 { t.Fatalf("the seam ran %d time(s) on a project already at head", len(seen)) } } // TestTheSeamIsNotChargedToTheStoreOperationBudget: the seam's work is the CALLER's (a restore point // whose cost scales with the book), and opTimeout bounds operations that are O(1) in the size of the // book. While the two shared one deadline, a large project failed the migration on the copy it had just // paid for — «context deadline exceeded» on the first step, exit 1 outside the refusal band, and a // retry that copied the whole database again (round-2 review, reproduced on 2.6 GB). // // The budget is shrunk instead of the seam sleeping ten real seconds: same property, no dead time in // the battery. It is restored on the way out and nothing here runs in parallel. func TestTheSeamIsNotChargedToTheStoreOperationBudget(t *testing.T) { head := SchemaHead() defer func(original time.Duration) { opTimeout = original }(opTimeout) opTimeout = 150 * time.Millisecond path := filepath.Join(t.TempDir(), "book.db") openAtVersion(t, path, head-1) applied, err := Migrate(path, func(Migration) error { time.Sleep(3 * opTimeout) // a restore point on a book far larger than the budget return nil }) if err != nil { t.Fatalf("a slow seam must not fail the migration it protects: %v", err) } if applied.From != head-1 || applied.To != head { t.Fatalf("transition %+v, want v%d -> v%d", applied, head-1, head) } if got := rawVersion(t, path); got != head { t.Fatalf("schema at v%d, want v%d", got, head) } } // TestASeamFailureAbortsTheMigrationWithNothingApplied: a caller that cannot take its restore point is // telling the engine not to migrate — the answer is to stop with the database exactly as it was, not to // proceed unprotected. func TestASeamFailureAbortsTheMigrationWithNothingApplied(t *testing.T) { head := SchemaHead() path := filepath.Join(t.TempDir(), "book.db") openAtVersion(t, path, head-1) refuse := errors.New("no room for a restore point") if _, err := Migrate(path, func(Migration) error { return refuse }); !errors.Is(err, refuse) { t.Fatalf("the seam's error must abort the migration, got %v", err) } if got := rawVersion(t, path); got != head-1 { t.Fatalf("schema moved to v%d after a refused seam, want v%d", got, head-1) } // And the lock is released, so the next attempt is not locked out by the failed one. if _, err := Migrate(path, nil); err != nil { t.Fatalf("a retry after a refused seam: %v", err) } }