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:
@@ -33,6 +33,8 @@ add_library(veloxd_store STATIC
|
||||
src/store/sqlite.cpp
|
||||
src/store/migrations.cpp
|
||||
src/store/pairings.cpp
|
||||
src/store/settings.cpp
|
||||
src/store/tasks.cpp
|
||||
${_mig_hdr}
|
||||
)
|
||||
add_library(velox::daemon_store ALIAS veloxd_store)
|
||||
@@ -42,7 +44,7 @@ target_include_directories(veloxd_store
|
||||
)
|
||||
target_compile_features(veloxd_store PUBLIC cxx_std_23)
|
||||
target_compile_options(veloxd_store PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
||||
target_link_libraries(veloxd_store PUBLIC SQLite::SQLite3 PRIVATE OpenSSL::Crypto)
|
||||
target_link_libraries(veloxd_store PUBLIC SQLite::SQLite3 velox::proto nlohmann_json::nlohmann_json PRIVATE OpenSSL::Crypto)
|
||||
|
||||
# --- veloxd_fs — the saveDir/filename path-traversal boundary (security) -----------
|
||||
# daemon/docs/safepath-adversarial.md is the spec; safepath_test.cpp is that table.
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
// Read/write access to the `settings` table (key -> JSON-text value). The table starts
|
||||
// empty; a key with no row falls back to the built-in default that mirrors
|
||||
// Settings.schema.json. Typed helpers cover what the daemon reads internally (governor
|
||||
// config, allowed roots); the full settings.get / settings.set projection onto the wire
|
||||
// `Settings` object lands with those handlers.
|
||||
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include "store/sqlite.hpp"
|
||||
|
||||
namespace velox::daemon::store {
|
||||
|
||||
class Settings {
|
||||
public:
|
||||
explicit Settings(Db& db) : db_(db) {}
|
||||
|
||||
// Raw JSON text for one key: the stored row, or the built-in default, or nullopt if
|
||||
// the key is unknown to the daemon entirely.
|
||||
DbResult<std::optional<std::string>> get_raw(std::string_view key);
|
||||
|
||||
// Replace one key's value. `json_text` must be a valid JSON document; the caller
|
||||
// (settings.set handler) validates it against the schema first.
|
||||
DbResult<void> set_raw(std::string_view key, std::string_view json_text);
|
||||
|
||||
// Every stored override (not merged with defaults).
|
||||
DbResult<std::map<std::string, std::string>> overrides();
|
||||
|
||||
// Typed convenience over get_raw + the defaults. A malformed stored value falls back
|
||||
// to the default rather than throwing.
|
||||
std::int64_t get_int(std::string_view key);
|
||||
std::string get_string(std::string_view key);
|
||||
std::vector<std::string> get_string_array(std::string_view key);
|
||||
|
||||
// The built-in default JSON for `key`, or nullopt if unknown.
|
||||
static std::optional<std::string_view> default_for(std::string_view key);
|
||||
|
||||
private:
|
||||
Db& db_;
|
||||
};
|
||||
|
||||
} // namespace velox::daemon::store
|
||||
@@ -0,0 +1,309 @@
|
||||
#include "store/tasks.hpp"
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace velox::daemon::store {
|
||||
|
||||
namespace proto = velox::proto;
|
||||
|
||||
namespace {
|
||||
|
||||
// Column order shared by get() and list() — the row reader indexes into this.
|
||||
constexpr const char* kCols =
|
||||
"task_id, url, save_dir, filename, state, start_mode, created_at, "
|
||||
"effective_url, category_id, queue_id, description, pause_reason, "
|
||||
"etag, last_modified, content_type, last_try_at, completed_at, "
|
||||
"checksum_algo, checksum_value, "
|
||||
"size_bytes, downloaded_bytes, resumable, "
|
||||
"req_segments, eff_segments, req_buffer_bytes, eff_buffer_bytes, queue_position, "
|
||||
"error_code, error_message, error_http_status, error_retryable, error_attempt, "
|
||||
"error_next_retry_at";
|
||||
|
||||
DbResult<void> bind_opt(Stmt& s, int i, const std::optional<std::string>& v) {
|
||||
return v ? s.bind(i, std::string_view(*v)) : s.bind_null(i);
|
||||
}
|
||||
DbResult<void> bind_opt(Stmt& s, int i, const std::optional<std::int64_t>& v) {
|
||||
return v ? s.bind(i, *v) : s.bind_null(i);
|
||||
}
|
||||
|
||||
std::optional<std::string> col_opt_text(Stmt& s, int i) {
|
||||
if (s.column_is_null(i)) return std::nullopt;
|
||||
return s.column_text(i);
|
||||
}
|
||||
std::optional<std::int64_t> col_opt_int(Stmt& s, int i) {
|
||||
if (s.column_is_null(i)) return std::nullopt;
|
||||
return s.column_int(i);
|
||||
}
|
||||
|
||||
TaskRow read_row(Stmt& s) {
|
||||
TaskRow r;
|
||||
r.task_id = s.column_text(0);
|
||||
r.url = s.column_text(1);
|
||||
r.save_dir = s.column_text(2);
|
||||
r.filename = s.column_text(3);
|
||||
r.state = s.column_text(4);
|
||||
r.start_mode = s.column_text(5);
|
||||
r.created_at = s.column_text(6);
|
||||
r.effective_url = col_opt_text(s, 7);
|
||||
r.category_id = col_opt_text(s, 8);
|
||||
r.queue_id = col_opt_text(s, 9);
|
||||
r.description = col_opt_text(s, 10);
|
||||
r.pause_reason = col_opt_text(s, 11);
|
||||
r.etag = col_opt_text(s, 12);
|
||||
r.last_modified = col_opt_text(s, 13);
|
||||
r.content_type = col_opt_text(s, 14);
|
||||
r.last_try_at = col_opt_text(s, 15);
|
||||
r.completed_at = col_opt_text(s, 16);
|
||||
r.checksum_algo = col_opt_text(s, 17);
|
||||
r.checksum_value = col_opt_text(s, 18);
|
||||
r.size_bytes = col_opt_int(s, 19);
|
||||
r.downloaded_bytes = s.column_int(20);
|
||||
r.resumable = s.column_int(21) != 0;
|
||||
r.req_segments = col_opt_int(s, 22);
|
||||
r.eff_segments = s.column_int(23);
|
||||
r.req_buffer_bytes = col_opt_int(s, 24);
|
||||
r.eff_buffer_bytes = col_opt_int(s, 25);
|
||||
r.queue_position = col_opt_int(s, 26);
|
||||
r.error_code = col_opt_text(s, 27);
|
||||
r.error_message = col_opt_text(s, 28);
|
||||
r.error_http_status = col_opt_int(s, 29);
|
||||
if (!s.column_is_null(30)) r.error_retryable = s.column_int(30) != 0;
|
||||
r.error_attempt = col_opt_int(s, 31);
|
||||
r.error_next_retry_at = col_opt_text(s, 32);
|
||||
return r;
|
||||
}
|
||||
|
||||
// TaskSort.field -> a whitelisted column. Anything not backed by a stored column (live
|
||||
// speed, eta) sorts by recency instead of erroring.
|
||||
const char* sort_column(proto::TaskSortField f) {
|
||||
switch (f) {
|
||||
case proto::TaskSortField::Filename: return "filename";
|
||||
case proto::TaskSortField::SizeBytes: return "size_bytes";
|
||||
case proto::TaskSortField::State: return "state";
|
||||
case proto::TaskSortField::LastTryAt: return "last_try_at";
|
||||
case proto::TaskSortField::QueuePosition: return "queue_position";
|
||||
case proto::TaskSortField::Description: return "description";
|
||||
case proto::TaskSortField::CreatedAt:
|
||||
case proto::TaskSortField::EtaSeconds:
|
||||
case proto::TaskSortField::SpeedBps:
|
||||
default: return "created_at";
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
DbResult<void> Tasks::insert(const TaskRow& r) {
|
||||
auto st = db_.prepare(
|
||||
"INSERT INTO tasks("
|
||||
"task_id, url, save_dir, filename, state, start_mode, created_at, "
|
||||
"effective_url, category_id, queue_id, description, pause_reason, "
|
||||
"etag, last_modified, content_type, last_try_at, completed_at, "
|
||||
"checksum_algo, checksum_value, size_bytes, downloaded_bytes, resumable, "
|
||||
"req_segments, eff_segments, req_buffer_bytes, eff_buffer_bytes, queue_position, "
|
||||
"error_code, error_message, error_http_status, error_retryable, error_attempt, "
|
||||
"error_next_retry_at) VALUES("
|
||||
"?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?22,"
|
||||
"?23,?24,?25,?26,?27,?28,?29,?30,?31,?32,?33)");
|
||||
if (!st) return std::unexpected(st.error());
|
||||
|
||||
auto chk = [](DbResult<void> r) { return r.has_value(); };
|
||||
bool ok = chk(st->bind(1, std::string_view(r.task_id))) &&
|
||||
chk(st->bind(2, std::string_view(r.url))) &&
|
||||
chk(st->bind(3, std::string_view(r.save_dir))) &&
|
||||
chk(st->bind(4, std::string_view(r.filename))) &&
|
||||
chk(st->bind(5, std::string_view(r.state))) &&
|
||||
chk(st->bind(6, std::string_view(r.start_mode))) &&
|
||||
chk(st->bind(7, std::string_view(r.created_at))) &&
|
||||
chk(bind_opt(*st, 8, r.effective_url)) && chk(bind_opt(*st, 9, r.category_id)) &&
|
||||
chk(bind_opt(*st, 10, r.queue_id)) && chk(bind_opt(*st, 11, r.description)) &&
|
||||
chk(bind_opt(*st, 12, r.pause_reason)) && chk(bind_opt(*st, 13, r.etag)) &&
|
||||
chk(bind_opt(*st, 14, r.last_modified)) && chk(bind_opt(*st, 15, r.content_type)) &&
|
||||
chk(bind_opt(*st, 16, r.last_try_at)) && chk(bind_opt(*st, 17, r.completed_at)) &&
|
||||
chk(bind_opt(*st, 18, r.checksum_algo)) &&
|
||||
chk(bind_opt(*st, 19, r.checksum_value)) && chk(bind_opt(*st, 20, r.size_bytes)) &&
|
||||
chk(st->bind(21, r.downloaded_bytes)) &&
|
||||
chk(st->bind(22, static_cast<std::int64_t>(r.resumable))) &&
|
||||
chk(bind_opt(*st, 23, r.req_segments)) && chk(st->bind(24, r.eff_segments)) &&
|
||||
chk(bind_opt(*st, 25, r.req_buffer_bytes)) &&
|
||||
chk(bind_opt(*st, 26, r.eff_buffer_bytes)) &&
|
||||
chk(bind_opt(*st, 27, r.queue_position)) && chk(bind_opt(*st, 28, r.error_code)) &&
|
||||
chk(bind_opt(*st, 29, r.error_message)) &&
|
||||
chk(bind_opt(*st, 30, r.error_http_status)) &&
|
||||
chk(r.error_retryable ? st->bind(31, static_cast<std::int64_t>(*r.error_retryable))
|
||||
: st->bind_null(31)) &&
|
||||
chk(bind_opt(*st, 32, r.error_attempt)) &&
|
||||
chk(bind_opt(*st, 33, r.error_next_retry_at));
|
||||
if (!ok) return std::unexpected(DbError{0, "failed to bind a task column"});
|
||||
|
||||
if (auto r2 = st->step(); !r2) return std::unexpected(r2.error());
|
||||
return {};
|
||||
}
|
||||
|
||||
DbResult<std::optional<TaskRow>> Tasks::get(std::string_view task_id) {
|
||||
auto st = db_.prepare(std::string("SELECT ") + kCols + " FROM tasks WHERE task_id = ?1");
|
||||
if (!st) return std::unexpected(st.error());
|
||||
if (auto r = st->bind(1, task_id); !r) return std::unexpected(r.error());
|
||||
auto row = st->step();
|
||||
if (!row) return std::unexpected(row.error());
|
||||
if (!*row) return std::optional<TaskRow>{};
|
||||
return std::optional<TaskRow>{read_row(*st)};
|
||||
}
|
||||
|
||||
DbResult<Tasks::Page> Tasks::list(const std::optional<proto::TaskFilter>& filter,
|
||||
const std::optional<proto::TaskSort>& sort, std::int64_t offset,
|
||||
std::int64_t limit) {
|
||||
std::string where;
|
||||
std::vector<std::string> params; // bound 1:1 with the '?' placeholders, in order
|
||||
auto clause = [&](std::string c) {
|
||||
where += where.empty() ? " WHERE " : " AND ";
|
||||
where += std::move(c);
|
||||
};
|
||||
|
||||
if (filter) {
|
||||
if (filter->states && !filter->states->empty()) {
|
||||
std::string in;
|
||||
for (const auto st : *filter->states) {
|
||||
in += in.empty() ? "" : ",";
|
||||
in += "'";
|
||||
in += proto::to_string(st); // an enum spelling, never user input
|
||||
in += "'";
|
||||
}
|
||||
clause("state IN (" + in + ")");
|
||||
}
|
||||
if (filter->categoryId) {
|
||||
clause("category_id = ?");
|
||||
params.push_back(*filter->categoryId);
|
||||
}
|
||||
if (filter->queueId) {
|
||||
clause("queue_id = ?");
|
||||
params.push_back(*filter->queueId);
|
||||
}
|
||||
if (filter->query) {
|
||||
clause("(instr(lower(filename), lower(?)) > 0 OR instr(lower(url), lower(?)) > 0)");
|
||||
params.push_back(*filter->query);
|
||||
params.push_back(*filter->query);
|
||||
}
|
||||
if (filter->addedAfter) {
|
||||
clause("created_at >= ?");
|
||||
params.push_back(*filter->addedAfter);
|
||||
}
|
||||
if (filter->addedBefore) {
|
||||
clause("created_at < ?");
|
||||
params.push_back(*filter->addedBefore);
|
||||
}
|
||||
}
|
||||
|
||||
std::int64_t total = 0;
|
||||
{
|
||||
auto st = db_.prepare("SELECT count(*) FROM tasks" + where);
|
||||
if (!st) return std::unexpected(st.error());
|
||||
for (std::size_t i = 0; i < params.size(); ++i)
|
||||
if (auto r = st->bind(static_cast<int>(i + 1), std::string_view(params[i])); !r)
|
||||
return std::unexpected(r.error());
|
||||
auto row = st->step();
|
||||
if (!row) return std::unexpected(row.error());
|
||||
if (*row) total = st->column_int(0);
|
||||
}
|
||||
|
||||
// Default sort is newest-first; an explicit sort is a whitelisted column + direction.
|
||||
// NULLs sort last in both directions so an unsized / unqueued task never floats up.
|
||||
std::string order = "created_at DESC";
|
||||
if (sort) {
|
||||
const char* col = sort_column(sort->field);
|
||||
const bool desc = sort->direction == proto::TaskSortDirection::Desc;
|
||||
order = std::string(col) + " IS NULL, " + col + (desc ? " DESC" : " ASC");
|
||||
}
|
||||
|
||||
const std::int64_t lim = limit > 0 ? limit : 500;
|
||||
const std::int64_t off = offset > 0 ? offset : 0;
|
||||
|
||||
Page page;
|
||||
page.total = total;
|
||||
{
|
||||
auto st = db_.prepare(std::string("SELECT ") + kCols + " FROM tasks" + where +
|
||||
" ORDER BY " + order + " LIMIT ? OFFSET ?");
|
||||
if (!st) return std::unexpected(st.error());
|
||||
int n = 1;
|
||||
for (const auto& p : params)
|
||||
if (auto r = st->bind(n++, std::string_view(p)); !r) return std::unexpected(r.error());
|
||||
if (auto r = st->bind(n++, lim); !r) return std::unexpected(r.error());
|
||||
if (auto r = st->bind(n++, off); !r) return std::unexpected(r.error());
|
||||
for (;;) {
|
||||
auto row = st->step();
|
||||
if (!row) return std::unexpected(row.error());
|
||||
if (!*row) break;
|
||||
page.rows.push_back(read_row(*st));
|
||||
}
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
DbResult<bool> Tasks::set_state(std::string_view task_id, std::string_view state,
|
||||
const std::optional<std::string>& pause_reason) {
|
||||
auto st = db_.prepare(
|
||||
"UPDATE tasks SET state = ?2, pause_reason = ?3 WHERE task_id = ?1");
|
||||
if (!st) return std::unexpected(st.error());
|
||||
if (auto r = st->bind(1, task_id); !r) return std::unexpected(r.error());
|
||||
if (auto r = st->bind(2, state); !r) return std::unexpected(r.error());
|
||||
const bool paused = state == "paused";
|
||||
if (auto r = paused && pause_reason ? st->bind(3, std::string_view(*pause_reason))
|
||||
: st->bind_null(3);
|
||||
!r)
|
||||
return std::unexpected(r.error());
|
||||
if (auto r = st->step(); !r) return std::unexpected(r.error());
|
||||
return sqlite3_changes(db_.raw()) > 0;
|
||||
}
|
||||
|
||||
DbResult<bool> Tasks::remove(std::string_view task_id) {
|
||||
auto st = db_.prepare("DELETE FROM tasks WHERE task_id = ?1");
|
||||
if (!st) return std::unexpected(st.error());
|
||||
if (auto r = st->bind(1, task_id); !r) return std::unexpected(r.error());
|
||||
if (auto r = st->step(); !r) return std::unexpected(r.error());
|
||||
return sqlite3_changes(db_.raw()) > 0;
|
||||
}
|
||||
|
||||
DbResult<std::int64_t> Tasks::count() {
|
||||
auto st = db_.prepare("SELECT count(*) FROM tasks");
|
||||
if (!st) return std::unexpected(st.error());
|
||||
auto row = st->step();
|
||||
if (!row) return std::unexpected(row.error());
|
||||
return (*row) ? st->column_int(0) : 0;
|
||||
}
|
||||
|
||||
proto::TaskSummary to_summary(const TaskRow& r) {
|
||||
proto::TaskSummary s;
|
||||
s.taskId = r.task_id;
|
||||
s.filename = r.filename;
|
||||
s.saveDir = r.save_dir;
|
||||
s.url = r.url;
|
||||
s.effectiveUrl = r.effective_url;
|
||||
s.sizeBytes = r.size_bytes;
|
||||
s.downloadedBytes = r.downloaded_bytes;
|
||||
if (auto st = proto::parse_TaskState(r.state)) s.state = *st;
|
||||
s.speedBps = 0;
|
||||
s.resumable = r.resumable;
|
||||
s.segments = r.eff_segments;
|
||||
s.categoryId = r.category_id;
|
||||
s.queueId = r.queue_id;
|
||||
s.queuePosition = r.queue_position;
|
||||
s.description = r.description;
|
||||
s.createdAt = r.created_at;
|
||||
s.lastTryAt = r.last_try_at;
|
||||
s.completedAt = r.completed_at;
|
||||
|
||||
if (r.error_code) {
|
||||
proto::TaskError e;
|
||||
if (auto c = proto::parse_TaskErrorCode(*r.error_code)) e.code = *c;
|
||||
e.message = r.error_message.value_or("");
|
||||
e.httpStatus = r.error_http_status;
|
||||
e.retryable = r.error_retryable.value_or(false);
|
||||
e.attempt = r.error_attempt;
|
||||
e.nextRetryAt = r.error_next_retry_at;
|
||||
s.error = std::move(e);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace velox::daemon::store
|
||||
@@ -0,0 +1,89 @@
|
||||
#pragma once
|
||||
|
||||
// Read/write access to the `tasks` table, plus the projection onto the wire TaskSummary.
|
||||
// download.list does its filtering, sorting and paging here (M1 DoD: a 1000-row list under
|
||||
// 50 ms, never materialised client-side).
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "store/sqlite.hpp"
|
||||
#include "velox_proto.hpp"
|
||||
|
||||
namespace velox::daemon::store {
|
||||
|
||||
// One row of `tasks`, 1:1 with the schema. std::optional maps a NULL column.
|
||||
struct TaskRow {
|
||||
std::string task_id;
|
||||
std::string url;
|
||||
std::string save_dir;
|
||||
std::string filename;
|
||||
std::string state = "new";
|
||||
std::string start_mode = "auto";
|
||||
std::string created_at;
|
||||
|
||||
std::optional<std::string> effective_url;
|
||||
std::optional<std::string> category_id;
|
||||
std::optional<std::string> queue_id;
|
||||
std::optional<std::string> description;
|
||||
std::optional<std::string> pause_reason;
|
||||
std::optional<std::string> etag;
|
||||
std::optional<std::string> last_modified;
|
||||
std::optional<std::string> content_type;
|
||||
std::optional<std::string> last_try_at;
|
||||
std::optional<std::string> completed_at;
|
||||
std::optional<std::string> checksum_algo;
|
||||
std::optional<std::string> checksum_value;
|
||||
|
||||
std::optional<std::int64_t> size_bytes;
|
||||
std::int64_t downloaded_bytes = 0;
|
||||
bool resumable = false;
|
||||
|
||||
std::optional<std::int64_t> req_segments;
|
||||
std::int64_t eff_segments = 0;
|
||||
std::optional<std::int64_t> req_buffer_bytes;
|
||||
std::optional<std::int64_t> eff_buffer_bytes;
|
||||
std::optional<std::int64_t> queue_position;
|
||||
|
||||
std::optional<std::string> error_code;
|
||||
std::optional<std::string> error_message;
|
||||
std::optional<std::int64_t> error_http_status;
|
||||
std::optional<bool> error_retryable;
|
||||
std::optional<std::int64_t> error_attempt;
|
||||
std::optional<std::string> error_next_retry_at;
|
||||
};
|
||||
|
||||
class Tasks {
|
||||
public:
|
||||
explicit Tasks(Db& db) : db_(db) {}
|
||||
|
||||
DbResult<void> insert(const TaskRow& row);
|
||||
DbResult<std::optional<TaskRow>> get(std::string_view task_id);
|
||||
|
||||
struct Page {
|
||||
std::int64_t total = 0; // rows matching the filter, ignoring paging
|
||||
std::vector<TaskRow> rows;
|
||||
};
|
||||
DbResult<Page> list(const std::optional<velox::proto::TaskFilter>& filter,
|
||||
const std::optional<velox::proto::TaskSort>& sort, std::int64_t offset,
|
||||
std::int64_t limit);
|
||||
|
||||
// Move a task to `state`; `pause_reason` is written only when state == "paused"
|
||||
// (cleared otherwise). Returns false if there is no such task.
|
||||
DbResult<bool> set_state(std::string_view task_id, std::string_view state,
|
||||
const std::optional<std::string>& pause_reason);
|
||||
|
||||
DbResult<bool> remove(std::string_view task_id);
|
||||
DbResult<std::int64_t> count();
|
||||
|
||||
private:
|
||||
Db& db_;
|
||||
};
|
||||
|
||||
// Project a row onto the wire type. `state` and `error.code` strings are assumed valid
|
||||
// (the CHECK constraints and the state machine keep them so).
|
||||
velox::proto::TaskSummary to_summary(const TaskRow& row);
|
||||
|
||||
} // namespace velox::daemon::store
|
||||
@@ -19,3 +19,4 @@ veloxd_test(ws_server LIBS veloxd_rpc)
|
||||
veloxd_test(sched_window LIBS veloxd_sched)
|
||||
veloxd_test(sched_governor LIBS veloxd_sched)
|
||||
veloxd_test(safepath LIBS veloxd_fs)
|
||||
veloxd_test(store_tasks LIBS veloxd_store)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user