// The migrator: fresh DB -> head, idempotent re-run, and forward-only from every released // user_version (M1 DoD: "a forward-only test from every released schema version"). #include #include "check.hpp" #include "store/migrations.hpp" #include "store/sqlite.hpp" using namespace velox::daemon::store; namespace { std::int64_t head_version() { std::int64_t v = 0; for (const auto& m : embedded_migrations()) v = std::max(v, m.version); return v; } bool table_exists(Db& db, const char* name) { auto st = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1"); if (!st) return false; if (!st->bind(1, std::string_view(name))) return false; auto row = st->step(); return row && *row; } std::int64_t count(Db& db, const char* sql) { auto st = db.prepare(sql); if (!st) return -1; auto row = st->step(); if (!row || !*row) return -1; return st->column_int(0); } } // namespace void run() { const std::int64_t head = head_version(); CHECK(head >= 1); // --- fresh in-memory DB migrates cleanly to head -------------------------------- { auto db = Db::open(":memory:"); CHECK(db.has_value()); if (!db) return; CHECK_EQ(db->user_version(), 0); auto out = migrate_to_head(*db); CHECK(out.has_value()); if (out) { CHECK_EQ(out->from_version, 0); CHECK_EQ(out->to_version, head); CHECK_EQ(static_cast(out->applied), head); } CHECK_EQ(db->user_version(), head); for (const char* t : {"settings", "categories", "queues", "tasks", "segments", "rules", "history", "pairings"}) { CHECK(table_exists(*db, t)); } // Seed rows the initial migration inserts. CHECK_EQ(count(*db, "SELECT count(*) FROM categories WHERE builtin=1"), 6); CHECK_EQ(count(*db, "SELECT count(*) FROM queues"), 1); // FK + cascade wired: a segment for a missing task is rejected; deleting a task // takes its segments with it. CHECK(db->exec("INSERT INTO tasks(task_id,url,save_dir,created_at) " "VALUES('t1','http://x','/tmp','2026-09-10T00:00:00Z')") .has_value()); CHECK(db->exec("INSERT INTO segments(task_id,idx,start_byte,end_byte) " "VALUES('t1',0,0,99)") .has_value()); CHECK(!db->exec("INSERT INTO segments(task_id,idx,start_byte,end_byte) " "VALUES('nope',0,0,99)") .has_value()); CHECK(db->exec("DELETE FROM tasks WHERE task_id='t1'").has_value()); CHECK_EQ(count(*db, "SELECT count(*) FROM segments"), 0); // A whole-file zero-length download: one segment, end_byte = -1 (ADR 0010 B3a). CHECK(db->exec("INSERT INTO tasks(task_id,url,save_dir,created_at,size_bytes) " "VALUES('z','http://x','/tmp','2026-09-10T00:00:00Z',0)") .has_value()); CHECK(db->exec("INSERT INTO segments(task_id,idx,start_byte,end_byte) " "VALUES('z',0,0,-1)") .has_value()); } // --- 0003: start_mode is rebuilt to the contract's values, existing rows mapped - { auto db = Db::open(":memory:"); CHECK(db.has_value()); if (!db) return; // Build a real pre-0003 db (schema 1..2) with rows in the old, non-contract // start_mode spelling, the way an actually-released daemon would have them. for (const auto& m : embedded_migrations()) { if (m.version > 2) break; CHECK(db->exec(m.sql).has_value()); CHECK(db->set_user_version(m.version).has_value()); } CHECK(db->exec("INSERT INTO tasks(task_id,url,save_dir,created_at,start_mode) " "VALUES('auto1','http://x','/tmp','2026-09-10T00:00:00Z','auto')") .has_value()); CHECK(db->exec("INSERT INTO tasks(task_id,url,save_dir,created_at,start_mode) " "VALUES('man1','http://x','/tmp','2026-09-10T00:00:00Z','manual')") .has_value()); CHECK(db->exec("INSERT INTO tasks(task_id,url,save_dir,created_at,start_mode) " "VALUES('q1','http://x','/tmp','2026-09-10T00:00:00Z','queue')") .has_value()); CHECK(migrate_to_head(*db).has_value()); CHECK_EQ(db->user_version(), head); auto start_mode_of = [&](const char* id) -> std::string { auto st = db->prepare("SELECT start_mode FROM tasks WHERE task_id=?1"); if (!st || !st->bind(1, std::string_view(id))) return ""; auto row = st->step(); if (!row || !*row) return ""; return std::string(st->column_text(0)); }; CHECK_EQ(start_mode_of("auto1"), std::string("now")); CHECK_EQ(start_mode_of("man1"), std::string("later")); CHECK_EQ(start_mode_of("q1"), std::string("queue")); // passes through unchanged // The bug this migration closes: 'later' — a real, documented StartMode value — // used to hit the old CHECK and fail every insert. It's accepted now, and the two // retired spellings are gone for good. CHECK(db->exec("INSERT INTO tasks(task_id,url,save_dir,created_at,start_mode) " "VALUES('later1','http://x','/tmp','2026-09-10T00:00:00Z','later')") .has_value()); CHECK(db->exec("INSERT INTO tasks(task_id,url,save_dir,created_at,start_mode) " "VALUES('bad1','http://x','/tmp','2026-09-10T00:00:00Z','auto')") .has_value() == false); CHECK(db->exec("INSERT INTO tasks(task_id,url,save_dir,created_at,start_mode) " "VALUES('bad2','http://x','/tmp','2026-09-10T00:00:00Z','manual')") .has_value() == false); } // --- re-running the migrator on an at-head DB is a no-op ------------------------ { auto db = Db::open(":memory:"); CHECK(db.has_value()); (void)migrate_to_head(*db); auto again = migrate_to_head(*db); CHECK(again.has_value()); if (again) { CHECK_EQ(again->applied, 0); CHECK_EQ(again->to_version, head); } } // --- forward-only: from every released version [0 .. head-1], reach head -------- // A "released version N" db has the real schema migrations 1..N actually built, not // just the pragma set to N — faking the pragma alone left `start >= 1` cases running // a later migration (e.g. 0002's ALTER TABLE tasks / rebuild of segments) against a // db with no tables at all. for (std::int64_t start = 0; start < head; ++start) { auto db = Db::open(":memory:"); CHECK(db.has_value()); if (!db) continue; for (const auto& m : embedded_migrations()) { if (m.version > start) break; CHECK(db->exec(m.sql).has_value()); CHECK(db->set_user_version(m.version).has_value()); } CHECK_EQ(db->user_version(), start); auto out = migrate_to_head(*db); CHECK(out.has_value()); if (out) { CHECK_EQ(out->from_version, start); CHECK_EQ(out->to_version, head); } CHECK_EQ(db->user_version(), head); } } TEST_MAIN()