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
+117
View File
@@ -0,0 +1,117 @@
#include "store/settings.hpp"
#include <array>
#include <utility>
#include <nlohmann/json.hpp>
namespace velox::daemon::store {
namespace {
// Built-in defaults, mirroring Settings.schema.json / ADR 0012. Only the keys the daemon
// currently reads or is likely to need before the full settings.get handler lands; the
// rest resolve through the schema's own defaults at that layer.
constexpr std::array<std::pair<std::string_view, std::string_view>, 14> kDefaults{{
{"connection.maxSegmentsPerDownload", "8"},
{"connection.bufferBytes", "1048576"},
{"connection.maxConcurrentDownloads", "5"},
{"connection.maxActiveSegments", "32"},
{"connection.maxTotalBufferBytes", "134217728"},
{"connection.timeoutSec", "30"},
{"connection.maxRetries", "10"},
{"connection.retryBackoffSec", "5"},
{"saveTo.defaultDir", "\"~/Downloads\""},
{"saveTo.allowedRoots", "[\"~/Downloads\"]"},
{"saveTo.createSubfolderPerSite", "false"},
{"downloads.speedLimitBps", "0"},
{"downloads.speedLimitEnabled", "false"},
{"downloads.duplicatePolicy", "\"ask\""},
}};
} // namespace
std::optional<std::string_view> Settings::default_for(std::string_view key) {
for (const auto& [k, v] : kDefaults) {
if (k == key) return v;
}
return std::nullopt;
}
DbResult<std::optional<std::string>> Settings::get_raw(std::string_view key) {
auto st = db_.prepare("SELECT value FROM settings WHERE key = ?1");
if (!st) return std::unexpected(st.error());
if (auto r = st->bind(1, key); !r) return std::unexpected(r.error());
auto row = st->step();
if (!row) return std::unexpected(row.error());
if (*row) return std::optional<std::string>{st->column_text(0)};
if (auto d = default_for(key)) return std::optional<std::string>{std::string(*d)};
return std::optional<std::string>{};
}
DbResult<void> Settings::set_raw(std::string_view key, std::string_view json_text) {
auto st = db_.prepare(
"INSERT INTO settings(key, value) VALUES(?1, ?2) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value");
if (!st) return std::unexpected(st.error());
if (auto r = st->bind(1, key); !r) return std::unexpected(r.error());
if (auto r = st->bind(2, json_text); !r) return std::unexpected(r.error());
if (auto r = st->step(); !r) return std::unexpected(r.error());
return {};
}
DbResult<std::map<std::string, std::string>> Settings::overrides() {
auto st = db_.prepare("SELECT key, value FROM settings ORDER BY key");
if (!st) return std::unexpected(st.error());
std::map<std::string, std::string> out;
for (;;) {
auto row = st->step();
if (!row) return std::unexpected(row.error());
if (!*row) break;
out.emplace(st->column_text(0), st->column_text(1));
}
return out;
}
std::int64_t Settings::get_int(std::string_view key) {
auto raw = get_raw(key);
if (raw && *raw) {
auto j = nlohmann::json::parse(**raw, nullptr, false);
if (j.is_number_integer()) return j.get<std::int64_t>();
}
if (auto d = default_for(key)) {
auto j = nlohmann::json::parse(*d, nullptr, false);
if (j.is_number_integer()) return j.get<std::int64_t>();
}
return 0;
}
std::string Settings::get_string(std::string_view key) {
auto raw = get_raw(key);
if (raw && *raw) {
auto j = nlohmann::json::parse(**raw, nullptr, false);
if (j.is_string()) return j.get<std::string>();
}
if (auto d = default_for(key)) {
auto j = nlohmann::json::parse(*d, nullptr, false);
if (j.is_string()) return j.get<std::string>();
}
return {};
}
std::vector<std::string> Settings::get_string_array(std::string_view key) {
auto raw = get_raw(key);
const std::string text = (raw && *raw) ? **raw
: default_for(key) ? std::string(*default_for(key))
: std::string("[]");
auto j = nlohmann::json::parse(text, nullptr, false);
std::vector<std::string> out;
if (j.is_array()) {
for (const auto& e : j) {
if (e.is_string()) out.push_back(e.get<std::string>());
}
}
return out;
}
} // namespace velox::daemon::store