daemon: D3 (partial) — category.upsert/remove, queue.upsert, download.remove/addBatch/provideAuth

Closes a bounded, high-value slice of the remaining D3 stubs. settings.*/rules.*/
limiter.*/schedule.*/grabber.*/media.*/capture.*/queue.reorder/download.refreshUrl/
download.update stay deferred — reasons noted individually in deferrals.md (settings.*
specifically: a real, large field<->key<->JSON-type mapping table across ~43 fields /
~50 SettingKeys, not started rather than rushed).

category.upsert/remove: store/categories.hpp gains get/upsert/remove. upsert generates
an id when absent and always ignores the payload's `builtin` (preserved from the
existing row on replace, false on create); saveDir goes through the same
fs::resolve_target canonicalize-and-root-check as download.add. remove refuses a
builtin at both layers (dispatcher's -32602 pre-check; the store's own
"DELETE ... AND builtin = 0" as defense in depth) and reassigns member tasks to
reassignTo (default "general") inside one transaction before deleting the row.

queue.upsert: store/queues.hpp gains get/upsert (set_state already existed from D4b).
Same create-generates-id pattern; a create always starts 'stopped', a replace keeps
the queue's current run state (upsert edits config, not run state — that's
queue.start/stop). Also fixed in passing: on_complete has been a real column since
migration 0001 but Queues::list/get never projected it onto Queue.onComplete.

download.remove: cancels with discard_partial=true (always drops the .veloxpart pair —
unlike download.cancel, which keeps them, the row is gone either way), deletes the
finished file only when deleteFile is true and the task was complete (best-effort),
deletes the row (segments cascade via the FK), and publishes event.task.removed
(closing the last open note under D5).

download.addBatch: on_download_add's body is now a shared add_one(), called once per
item after merging each item's unset fields against params.defaults.

download.provideAuth: forwards to EnginePort::provide_auth via a new
TaskActionPort::provide_auth. `remember`/persisting to the Secret Service is accepted
but not acted on — nothing in this build talks to libsecret yet.

Verified against real veloxd + tools/testserver: category create/replace/
remove-with-reassignment, queue create/replace-keeps-run-state, a batch add sharing
defaults.saveDir, and download.remove with deleteFile actually deleting the file and
the task then 404ing download.get with -32010. Full ctest: 39/39.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GRDjHGgpYmMoPE2UFbe7pP
This commit is contained in:
2026-09-11 17:43:43 +04:00
co-authored by Claude Sonnet 5
parent 4f6fb1029d
commit c89158ea09
11 changed files with 493 additions and 31 deletions
+127 -12
View File
@@ -1,11 +1,35 @@
#include "store/categories.hpp"
#include <sqlite3.h>
#include <cstdio>
#include <random>
#include <string>
#include <nlohmann/json.hpp>
namespace velox::daemon::store {
namespace proto = velox::proto;
namespace {
proto::Category project_row(Stmt& st) {
proto::Category c;
c.categoryId = st.column_text(0);
c.name = st.column_text(1);
c.saveDir = st.column_text(2);
auto j = nlohmann::json::parse(st.column_text(3), nullptr, false);
if (j.is_array()) {
for (const auto& e : j)
if (e.is_string()) c.extensions.push_back(e.get<std::string>());
}
c.builtin = st.column_int(4) != 0;
return c;
}
} // namespace
DbResult<std::vector<proto::Category>> Categories::list() {
auto st = db_.prepare(
"SELECT category_id, name, save_dir, extensions, builtin FROM categories "
@@ -17,20 +41,111 @@ DbResult<std::vector<proto::Category>> Categories::list() {
auto row = st->step();
if (!row) return std::unexpected(row.error());
if (!*row) break;
proto::Category c;
c.categoryId = st->column_text(0);
c.name = st->column_text(1);
c.saveDir = st->column_text(2);
auto j = nlohmann::json::parse(st->column_text(3), nullptr, false);
if (j.is_array()) {
for (const auto& e : j)
if (e.is_string()) c.extensions.push_back(e.get<std::string>());
}
c.builtin = st->column_int(4) != 0;
out.push_back(std::move(c));
out.push_back(project_row(*st));
}
return out;
}
DbResult<std::optional<proto::Category>> Categories::get(std::string_view category_id) {
auto st = db_.prepare(
"SELECT category_id, name, save_dir, extensions, builtin FROM categories "
"WHERE category_id = ?1");
if (!st) return std::unexpected(st.error());
if (auto b = st->bind(1, category_id); !b) return std::unexpected(b.error());
auto row = st->step();
if (!row) return std::unexpected(row.error());
if (!*row) return std::optional<proto::Category>{};
return std::optional<proto::Category>{project_row(*st)};
}
DbResult<proto::Category> Categories::upsert(proto::Category category) {
// A replace keeps the existing row's builtin flag; a create is never builtin. Either
// way the payload's own `builtin` is ignored — a client cannot mint or revoke it.
bool builtin = false;
if (!category.categoryId.empty()) {
auto existing = get(category.categoryId);
if (!existing) return std::unexpected(existing.error());
if (existing->has_value()) builtin = (*existing)->builtin;
} else {
// Reuse Tasks' id scheme (v4 UUID) would need a cross-module include for one
// function; a category id has no wire format requirement beyond "a string", so a
// timestamp-free random hex id keeps this module self-contained.
std::random_device rd;
std::uniform_int_distribution<std::uint64_t> d;
char buf[17];
std::snprintf(buf, sizeof(buf), "%016llx", static_cast<unsigned long long>(d(rd)));
category.categoryId = std::string(buf);
}
nlohmann::json ext = nlohmann::json::array();
for (const auto& e : category.extensions) ext.push_back(e);
auto st = db_.prepare(
"INSERT INTO categories(category_id, name, save_dir, extensions, builtin) "
"VALUES(?1,?2,?3,?4,?5) "
"ON CONFLICT(category_id) DO UPDATE SET "
"name=excluded.name, save_dir=excluded.save_dir, extensions=excluded.extensions");
if (!st) return std::unexpected(st.error());
if (auto b = st->bind(1, std::string_view(category.categoryId)); !b)
return std::unexpected(b.error());
if (auto b = st->bind(2, std::string_view(category.name)); !b) return std::unexpected(b.error());
if (auto b = st->bind(3, std::string_view(category.saveDir)); !b)
return std::unexpected(b.error());
if (auto b = st->bind(4, std::string_view(ext.dump())); !b) return std::unexpected(b.error());
if (auto b = st->bind(5, static_cast<std::int64_t>(builtin)); !b)
return std::unexpected(b.error());
if (auto r = st->step(); !r) return std::unexpected(r.error());
category.builtin = builtin;
return category;
}
DbResult<Categories::RemoveResult> Categories::remove(
std::string_view category_id, const std::optional<std::string>& reassign_to) {
RemoveResult out;
const std::string target = reassign_to.value_or("general");
// Db::transaction only threads a DbResult<void> lambda; `out` is filled in-place and
// returned once the transaction (which may still fail and roll back) succeeds.
auto txn = db_.transaction([&]() -> DbResult<void> {
{
// A builtin category is never removed, and — since it was never going to be
// removed — its tasks must not be reassigned away from it either.
auto chk = db_.prepare("SELECT builtin FROM categories WHERE category_id = ?1");
if (!chk) return std::unexpected(chk.error());
if (auto b = chk->bind(1, category_id); !b) return std::unexpected(b.error());
auto row = chk->step();
if (!row) return std::unexpected(row.error());
if (!*row) return {}; // no such category: removed stays false
if (chk->column_int(0) != 0) return {}; // builtin: removed stays false
}
{
auto sel = db_.prepare("SELECT task_id FROM tasks WHERE category_id = ?1");
if (!sel) return std::unexpected(sel.error());
if (auto b = sel->bind(1, category_id); !b) return std::unexpected(b.error());
for (;;) {
auto row = sel->step();
if (!row) return std::unexpected(row.error());
if (!*row) break;
out.reassigned_task_ids.push_back(sel->column_text(0));
}
}
if (!out.reassigned_task_ids.empty()) {
auto upd = db_.prepare("UPDATE tasks SET category_id = ?2 WHERE category_id = ?1");
if (!upd) return std::unexpected(upd.error());
if (auto b = upd->bind(1, category_id); !b) return std::unexpected(b.error());
if (auto b = upd->bind(2, std::string_view(target)); !b) return std::unexpected(b.error());
if (auto r = upd->step(); !r) return std::unexpected(r.error());
}
auto del = db_.prepare("DELETE FROM categories WHERE category_id = ?1 AND builtin = 0");
if (!del) return std::unexpected(del.error());
if (auto b = del->bind(1, category_id); !b) return std::unexpected(b.error());
if (auto r = del->step(); !r) return std::unexpected(r.error());
out.removed = sqlite3_changes(db_.raw()) > 0;
return {};
});
if (!txn) return std::unexpected(txn.error());
return out;
}
} // namespace velox::daemon::store
+24
View File
@@ -3,7 +3,13 @@
// Read access to the `categories` table, projected onto proto::Category. Owned here
// rather than duplicated per handler since category.list and download.add (rule
// matching, later) both need it.
//
// The table has no columns for Category.mimeTypes / .sortOrder (0001_initial.sql predates
// those fields); upsert() accepts them but they are not persisted — round-tripped as unset
// on the next list()/get(). Noted in daemon/docs/deferrals.md.
#include <optional>
#include <string>
#include <vector>
#include "store/sqlite.hpp"
@@ -16,6 +22,24 @@ public:
explicit Categories(Db& db) : db_(db) {}
DbResult<std::vector<velox::proto::Category>> list();
DbResult<std::optional<velox::proto::Category>> get(std::string_view category_id);
// "Omit categoryId to create; supply it to replace" (category.upsert's own words) —
// the caller (dispatcher) decides create vs replace by whether `category.categoryId`
// is empty and generates the id; this just writes the row. `builtin` is never taken
// from the payload: preserved from the existing row on a replace, always false on a
// create (a client can never mint a builtin category).
DbResult<velox::proto::Category> upsert(velox::proto::Category category);
// False for "no such category". A builtin category is never removed — the caller
// checks that (category.remove -> -32602) before calling this, since that check needs
// ErrorCode, which this module (like the rest of store/) does not depend on.
struct RemoveResult {
bool removed = false;
std::vector<std::string> reassigned_task_ids;
};
DbResult<RemoveResult> remove(std::string_view category_id,
const std::optional<std::string>& reassign_to);
private:
Db& db_;
+55 -4
View File
@@ -2,6 +2,10 @@
#include <sqlite3.h>
#include <cstdio>
#include <random>
#include <string>
#include <nlohmann/json.hpp>
namespace velox::daemon::store {
@@ -10,8 +14,8 @@ namespace proto = velox::proto;
namespace {
// One queue row (columns queue_id, name, state, max_concurrent, schedule, in that order)
// plus its member taskIds, read off the row a caller has already step()'d to.
// One queue row (columns queue_id, name, state, max_concurrent, schedule, on_complete, in
// that order) plus its member taskIds, read off the row a caller has already step()'d to.
DbResult<proto::Queue> project_row(Db& db, Stmt& st) {
proto::Queue q;
q.queueId = st.column_text(0);
@@ -22,6 +26,7 @@ DbResult<proto::Queue> project_row(Db& db, Stmt& st) {
auto j = nlohmann::json::parse(st.column_text(4), nullptr, false);
if (auto sched = proto::parse<proto::Schedule>(j, "schedule")) q.schedule = *sched;
}
if (auto oc = proto::parse_QueueOnComplete(st.column_text(5))) q.onComplete = *oc;
auto ts = db.prepare("SELECT task_id FROM tasks WHERE queue_id = ?1 ORDER BY queue_position");
if (!ts) return std::unexpected(ts.error());
@@ -41,7 +46,8 @@ DbResult<proto::Queue> project_row(Db& db, Stmt& st) {
DbResult<std::vector<proto::Queue>> Queues::list() {
auto st = db_.prepare(
"SELECT queue_id, name, state, max_concurrent, schedule FROM queues ORDER BY name");
"SELECT queue_id, name, state, max_concurrent, schedule, on_complete FROM queues "
"ORDER BY name");
if (!st) return std::unexpected(st.error());
std::vector<proto::Queue> out;
@@ -58,7 +64,8 @@ DbResult<std::vector<proto::Queue>> Queues::list() {
DbResult<std::optional<proto::Queue>> Queues::get(std::string_view queue_id) {
auto st = db_.prepare(
"SELECT queue_id, name, state, max_concurrent, schedule FROM queues WHERE queue_id = ?1");
"SELECT queue_id, name, state, max_concurrent, schedule, on_complete FROM queues "
"WHERE queue_id = ?1");
if (!st) return std::unexpected(st.error());
if (auto b = st->bind(1, queue_id); !b) return std::unexpected(b.error());
auto row = st->step();
@@ -78,4 +85,48 @@ DbResult<bool> Queues::set_state(std::string_view queue_id, std::string_view sta
return sqlite3_changes(db_.raw()) > 0;
}
DbResult<proto::Queue> Queues::upsert(proto::Queue queue) {
if (queue.queueId.empty()) {
std::random_device rd;
std::uniform_int_distribution<std::uint64_t> d;
char buf[17];
std::snprintf(buf, sizeof(buf), "%016llx", static_cast<unsigned long long>(d(rd)));
queue.queueId = std::string(buf);
}
// A create defaults to 'stopped' (never auto-runs a brand-new queue); a replace keeps
// whatever run state the queue is already in — queue.upsert edits the config, not the
// run state (that's queue.start/stop).
std::string state = "stopped";
if (auto existing = get(queue.queueId); existing && existing->has_value())
state = std::string(proto::to_string((*existing)->state));
const std::string schedule_json =
queue.schedule ? nlohmann::json(*queue.schedule).dump() : std::string();
const std::string on_complete =
std::string(proto::to_string(queue.onComplete.value_or(proto::QueueOnComplete::Nothing)));
auto st = db_.prepare(
"INSERT INTO queues(queue_id, name, state, max_concurrent, schedule, on_complete) "
"VALUES(?1,?2,?3,?4,?5,?6) "
"ON CONFLICT(queue_id) DO UPDATE SET "
"name=excluded.name, max_concurrent=excluded.max_concurrent, "
"schedule=excluded.schedule, on_complete=excluded.on_complete");
if (!st) return std::unexpected(st.error());
if (auto b = st->bind(1, std::string_view(queue.queueId)); !b) return std::unexpected(b.error());
if (auto b = st->bind(2, std::string_view(queue.name)); !b) return std::unexpected(b.error());
if (auto b = st->bind(3, std::string_view(state)); !b) return std::unexpected(b.error());
if (auto b = st->bind(4, queue.maxConcurrent); !b) return std::unexpected(b.error());
if (auto r = queue.schedule ? st->bind(5, std::string_view(schedule_json)) : st->bind_null(5); !r)
return std::unexpected(r.error());
if (auto b = st->bind(6, std::string_view(on_complete)); !b) return std::unexpected(b.error());
if (auto r = st->step(); !r) return std::unexpected(r.error());
auto stored = get(queue.queueId);
if (!stored) return std::unexpected(stored.error());
if (!stored->has_value())
return std::unexpected(DbError{0, "queue.upsert: row vanished after insert"});
return **stored;
}
} // namespace velox::daemon::store
+6
View File
@@ -27,6 +27,12 @@ public:
// id doesn't exist.
DbResult<bool> set_state(std::string_view queue_id, std::string_view state);
// "Omit queueId to create" (queue.upsert's own words) — an empty id generates one.
// taskIds is ignored (membership changes only through download.update / queue.reorder,
// per the schema's own note); a create defaults to 'stopped', a replace keeps the
// queue's current run state (queue.upsert edits config, not run state).
DbResult<velox::proto::Queue> upsert(velox::proto::Queue queue);
private:
Db& db_;
};