daemon: store/ query layer — tasks + settings (prep for the download.add vertical slice)

The read/write surface the dispatcher handlers need, so download.add
persists and download.list / download.get project real rows when the
engine lands.

- store/tasks — TaskRow (1:1 with the schema), insert / get / remove /
  set_state / count, and list(filter, sort, offset, limit) that does
  all the WHERE / ORDER BY / LIMIT in SQL (M1 DoD: a 1000-row list
  never materialised client-side). Filter covers states / category /
  queue / case-insensitive filename+url substring / date range; sort
  is a whitelisted column + direction with NULLs last, default
  newest-first; the enum spellings in a state IN (...) come from
  proto::to_string, never from user text. to_summary() projects a row
  onto proto::TaskSummary including the flattened error block when the
  task failed / retry_wait / auto-paused.
- store/settings — key -> JSON-text with a built-in default table
  mirroring Settings.schema.json / ADR 0012; get_raw / set_raw /
  overrides plus typed get_int / get_string / get_string_array for the
  governor config and saveTo.allowedRoots. Full settings.get/set wire
  projection lands with those handlers.
- veloxd_store now links velox::proto + nlohmann_json for the
  projection.

Test veloxd.store_tasks: insert/get round trip, PK duplicate rejected,
the error-block projection, list total+paging+sort+every filter,
set_state pause_reason clear-on-unpause, remove, and settings default
vs override. ASan+UBSan and TSan clean; 34 daemon/cli tests green.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Upd9WhG9oppieig5nRDLig
This commit is contained in:
2026-09-10 19:43:13 +04:00
co-authored by Claude Sonnet 5
parent ab479e7885
commit 6787784936
7 changed files with 754 additions and 1 deletions
+187
View File
@@ -0,0 +1,187 @@
// store/tasks + store/settings: insert / get / list (filter, sort, page) and the
// TaskSummary projection.
#include <string>
#include "check.hpp"
#include "store/migrations.hpp"
#include "store/settings.hpp"
#include "store/sqlite.hpp"
#include "store/tasks.hpp"
using namespace velox::daemon::store;
namespace proto = velox::proto;
namespace {
TaskRow row(std::string id, std::string name, std::string state, std::string created,
std::optional<std::int64_t> size = std::nullopt,
std::optional<std::string> cat = std::nullopt) {
TaskRow r;
r.task_id = std::move(id);
r.url = "https://example.com/" + name;
r.save_dir = "/home/u/Downloads";
r.filename = std::move(name);
r.state = std::move(state);
r.created_at = std::move(created);
r.size_bytes = size;
r.category_id = std::move(cat);
return r;
}
} // namespace
void run() {
auto db = Db::open(":memory:");
CHECK(db.has_value());
if (!db) return;
CHECK(migrate_to_head(*db).has_value());
Tasks tasks(*db);
// --- insert + get round trip ------------------------------------------------
{
auto r = row("t1", "ubuntu.iso", "downloading", "2026-09-10T10:00:00Z", 6228541440LL,
"programs");
r.downloaded_bytes = 1000000;
r.eff_segments = 8;
r.resumable = true;
CHECK(tasks.insert(r).has_value());
auto got = tasks.get("t1");
CHECK(got.has_value() && got->has_value());
if (got && *got) {
CHECK_EQ((*got)->filename, std::string("ubuntu.iso"));
CHECK_EQ((*got)->downloaded_bytes, 1000000);
CHECK((*got)->size_bytes.has_value() && *(*got)->size_bytes == 6228541440LL);
CHECK((*got)->resumable);
CHECK((*got)->category_id.value_or("") == "programs");
}
auto missing = tasks.get("nope");
CHECK(missing.has_value() && !missing->has_value());
}
// --- a duplicate id is rejected by the primary key -----------------------
CHECK(!tasks.insert(row("t1", "again", "new", "2026-09-10T10:01:00Z")).has_value());
// --- projection to TaskSummary, including the error block --------------
{
auto r = row("terr", "broken.zip", "failed", "2026-09-10T09:00:00Z");
r.error_code = "not_found";
r.error_message = "server returned 404";
r.error_http_status = 404;
r.error_retryable = false;
r.error_attempt = 3;
CHECK(tasks.insert(r).has_value());
auto got = tasks.get("terr");
CHECK(got && *got);
const auto s = to_summary(**got);
CHECK(s.state == proto::TaskState::Failed);
CHECK(s.error.has_value());
if (s.error) {
CHECK(s.error->code == proto::TaskErrorCode::NotFound);
CHECK(s.error->httpStatus.value_or(0) == 404);
CHECK(s.error->attempt.value_or(0) == 3);
}
// a non-error task has no error block
auto ok = tasks.get("t1");
CHECK(!to_summary(**ok).error.has_value());
}
// --- list: total + paging + default newest-first sort ------------------
{
for (int i = 0; i < 20; ++i) {
char id[8];
std::snprintf(id, sizeof(id), "p%02d", i);
char ts[24];
std::snprintf(ts, sizeof(ts), "2026-09-11T%02d:00:00Z", i);
CHECK(tasks.insert(row(id, std::string("f") + id, "queued", ts)).has_value());
}
auto page = tasks.list(std::nullopt, std::nullopt, 0, 5);
CHECK(page.has_value());
if (page) {
CHECK_EQ(page->total, 22); // 20 + t1 + terr
CHECK_EQ(page->rows.size(), 5u);
// newest first: p19 (11:00) before p18 ...
CHECK_EQ(page->rows.front().task_id, std::string("p19"));
}
auto page2 = tasks.list(std::nullopt, std::nullopt, 20, 500);
CHECK(page2.has_value());
if (page2) CHECK_EQ(page2->rows.size(), 2u); // the tail
}
// --- list: filter by state ---------------------------------------------
{
proto::TaskFilter f;
f.states = std::vector<proto::TaskState>{proto::TaskState::Queued};
auto page = tasks.list(f, std::nullopt, 0, 500);
CHECK(page.has_value());
if (page) {
CHECK_EQ(page->total, 20);
for (const auto& r : page->rows) CHECK_EQ(r.state, std::string("queued"));
}
}
// --- list: filter by category + case-insensitive query ---------------
{
proto::TaskFilter f;
f.categoryId = "programs";
auto page = tasks.list(f, std::nullopt, 0, 500);
CHECK(page.has_value() && page->total == 1);
proto::TaskFilter q;
q.query = "UBUNTU"; // matches "ubuntu.iso" case-insensitively
auto page2 = tasks.list(q, std::nullopt, 0, 500);
CHECK(page2.has_value() && page2->total == 1);
if (page2 && !page2->rows.empty())
CHECK_EQ(page2->rows.front().task_id, std::string("t1"));
}
// --- list: explicit sort by filename ascending ----------------------
{
proto::TaskSort s;
s.field = proto::TaskSortField::Filename;
s.direction = proto::TaskSortDirection::Asc;
auto page = tasks.list(std::nullopt, s, 0, 3);
CHECK(page.has_value());
if (page && page->rows.size() >= 2)
CHECK(page->rows[0].filename <= page->rows[1].filename);
}
// --- set_state + pause_reason, and remove -------------------------
{
CHECK(tasks.set_state("t1", "paused", std::string("user")).value_or(false));
auto g = tasks.get("t1");
CHECK(g && *g && (*g)->state == "paused" && (*g)->pause_reason.value_or("") == "user");
CHECK(tasks.set_state("t1", "downloading", std::nullopt).value_or(false));
g = tasks.get("t1");
CHECK(g && *g && !(*g)->pause_reason.has_value()); // cleared when not paused
CHECK(!tasks.set_state("ghost", "paused", std::nullopt).value_or(true));
CHECK(tasks.remove("terr").value_or(false));
CHECK(!tasks.remove("terr").value_or(true));
CHECK(tasks.count().value_or(-1) == 21);
}
// --- settings: default fallback, override, typed reads ---------------
{
Settings settings(*db);
CHECK_EQ(settings.get_int("connection.maxConcurrentDownloads"), 5); // built-in default
CHECK(settings.set_raw("connection.maxConcurrentDownloads", "9").has_value());
CHECK_EQ(settings.get_int("connection.maxConcurrentDownloads"), 9); // override wins
CHECK_EQ(settings.get_int("connection.maxActiveSegments"), 32);
auto roots = settings.get_string_array("saveTo.allowedRoots");
CHECK_EQ(roots.size(), 1u);
if (!roots.empty()) CHECK_EQ(roots.front(), std::string("~/Downloads"));
auto ov = settings.overrides();
CHECK(ov.has_value() && ov->count("connection.maxConcurrentDownloads") == 1);
}
}
TEST_MAIN()