The daemon's persistent state. SQLite in WAL mode, foreign keys on,
5 s busy timeout so a writer waits rather than SQLITE_BUSY under the
RPC loop.
- store/sqlite — RAII Db/Stmt over the C API; errors returned as
DbResult<T> (std::expected), never thrown — the RPC loop must not
unwind. transaction() helper: BEGIN / fn / COMMIT, ROLLBACK on error.
- store/migrations/0001_initial.sql — the eight tables from the brief:
settings, categories, queues, tasks, segments, rules, history,
pairings. Notable choices:
* tasks columns project onto proto TaskSummary with no computation;
requested vs effective segments/buffer split per ADR 0010/0012;
pause_reason column per ADR 0013.
* segments end_byte is NOT constrained >= 0 so a whole-file
zero-length download is one row with end_byte = -1 (ADR 0010 B3a).
* pairings stores only token_sha256 — the plaintext token is
returned once from session.pair and never persisted (CLAUDE.md §4).
* indices on tasks(state), (category_id), (queue_id, queue_position),
(created_at), (completed_at) for the "1000 tasks, download.list
under 50 ms" DoD.
* six built-in categories + a Main queue seeded.
- store/migrations — runs every embedded migration past PRAGMA
user_version, each in its own transaction, forward-only. SQL files
are embedded at build time by cmake/embed_migrations.cmake.
Test veloxd.store_migrations (ASan+UBSan and TSan clean): fresh DB ->
head, all tables present, seed rows, FK cascade (segment orphan
rejected, task delete cascades), the end_byte=-1 zero-length case,
idempotent re-run, and forward-only from every released user_version.
Also: daemon/docs/proto-requests-m1.md — P1 marked landed on lane/proto
as 1.4.0 (HandlerError/HandlerResult), to be adopted in rpc/ once that
merges to main; P2 resolved.
Not linked into the running daemon yet — the store is wired to the
dispatcher when download.add/list/get get real bodies, next.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Upd9WhG9oppieig5nRDLig
119 lines
4.1 KiB
C++
119 lines
4.1 KiB
C++
// 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 <string>
|
|
|
|
#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<std::int64_t>(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());
|
|
}
|
|
|
|
// --- 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 --------
|
|
for (std::int64_t start = 0; start < head; ++start) {
|
|
auto db = Db::open(":memory:");
|
|
CHECK(db.has_value());
|
|
if (!db) continue;
|
|
CHECK(db->set_user_version(start).has_value());
|
|
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()
|