daemon: store/ — SQLite WAL schema + forward-only migrator (build step 3)
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
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
-- Migration 0001 — initial schema.
|
||||
--
|
||||
-- Applied when PRAGMA user_version < 1. The migrator wraps this file in one transaction
|
||||
-- and sets user_version = 1 on success. Forward-only: never edit a released migration,
|
||||
-- add 0002_*.sql instead (AGENT-DAEMON.md build step 3).
|
||||
--
|
||||
-- Conventions:
|
||||
-- * ids are lowercase UUID text, except the built-in rows below.
|
||||
-- * timestamps are RFC 3339 UTC strings ("2026-09-10T14:55:02Z") — same on the wire,
|
||||
-- so projection to TaskSummary is a copy.
|
||||
-- * JSON-valued columns hold a TEXT document; SQLite's json1 validates on read where
|
||||
-- it matters. Marked "-- json" below.
|
||||
-- * credentials NEVER live here (CLAUDE.md §4) — the Secret Service holds those.
|
||||
|
||||
-- --- settings : the whole config bag, one row per SettingKey --------------------------
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL -- json: the value as it appears in the Settings schema
|
||||
) WITHOUT ROWID;
|
||||
|
||||
-- --- categories : folder + extension routing ----------------------------------------
|
||||
CREATE TABLE categories (
|
||||
category_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
save_dir TEXT NOT NULL,
|
||||
extensions TEXT NOT NULL DEFAULT '[]', -- json array of lowercase extensions, no dot
|
||||
builtin INTEGER NOT NULL DEFAULT 0 -- 1 = cannot be deleted (category.remove -> -32602)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
INSERT INTO categories (category_id, name, save_dir, extensions, builtin) VALUES
|
||||
('general', 'General', '~/Downloads', '[]', 1),
|
||||
('programs', 'Programs', '~/Downloads/Programs', '["exe","msi","deb","rpm","dmg","appimage","iso","zip","tar","gz","xz","7z"]', 1),
|
||||
('video', 'Video', '~/Downloads/Video', '["mp4","mkv","webm","avi","mov","flv","m4v","ts"]', 1),
|
||||
('audio', 'Audio', '~/Downloads/Audio', '["mp3","flac","aac","ogg","opus","wav","m4a"]', 1),
|
||||
('documents','Documents', '~/Downloads/Documents', '["pdf","doc","docx","xls","xlsx","ppt","pptx","odt","epub"]', 1),
|
||||
('images', 'Images', '~/Downloads/Images', '["jpg","jpeg","png","gif","webp","svg","bmp","tiff"]', 1);
|
||||
|
||||
-- --- queues : ordered runs with their own concurrency cap ---------------------------
|
||||
CREATE TABLE queues (
|
||||
queue_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'stopped' -- 'running' | 'stopped'
|
||||
CHECK (state IN ('running','stopped')),
|
||||
max_concurrent INTEGER NOT NULL DEFAULT 2 CHECK (max_concurrent BETWEEN 1 AND 32),
|
||||
on_complete TEXT NOT NULL DEFAULT 'nothing' -- 'nothing'|'exit'|'shutdown'|'hangup'
|
||||
CHECK (on_complete IN ('nothing','exit','shutdown','hangup')),
|
||||
schedule TEXT -- json Schedule, or NULL for manual
|
||||
) WITHOUT ROWID;
|
||||
|
||||
INSERT INTO queues (queue_id, name, state, max_concurrent) VALUES
|
||||
('main', 'Main Queue', 'stopped', 4);
|
||||
|
||||
-- --- tasks : the download list -----------------------------------------------------
|
||||
-- Column set is chosen so a row projects onto proto TaskSummary with no computation
|
||||
-- beyond reading segments/history for the detail view.
|
||||
CREATE TABLE tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
url TEXT NOT NULL, -- as supplied
|
||||
effective_url TEXT, -- after redirects; NULL until first probe
|
||||
filename TEXT NOT NULL DEFAULT '',
|
||||
save_dir TEXT NOT NULL, -- absolute, canonicalized, inside an allowed root
|
||||
category_id TEXT REFERENCES categories(category_id) ON DELETE SET NULL,
|
||||
queue_id TEXT REFERENCES queues(queue_id) ON DELETE SET NULL,
|
||||
queue_position INTEGER, -- NULL unless queued; run order within the queue
|
||||
|
||||
state TEXT NOT NULL DEFAULT 'new'
|
||||
CHECK (state IN ('new','probing','queued','connecting','downloading','paused',
|
||||
'retry_wait','assembling','verifying','complete','failed','cancelled')),
|
||||
-- ADR 0013: why a paused task is paused. NULL unless state='paused'. 'auto' means CORE
|
||||
-- entered it (auth_required/server_file_changed/disk_full); the code is in error_code.
|
||||
pause_reason TEXT CHECK (pause_reason IN
|
||||
('user','schedule','queue_stopped','admission_reconcile','auto')),
|
||||
|
||||
size_bytes INTEGER, -- NULL when the server reported no length
|
||||
downloaded_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
resumable INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
-- Requested vs effective, per ADR 0010 / ADR 0012. Requested values come from the
|
||||
-- DownloadSpec; effective values are written by the engine as it runs.
|
||||
req_segments INTEGER, -- DownloadSpec.segments (NULL = use setting)
|
||||
eff_segments INTEGER NOT NULL DEFAULT 0,-- TaskSummary.segments (in use right now)
|
||||
req_buffer_bytes INTEGER,
|
||||
eff_buffer_bytes INTEGER, -- TaskDetail.effectiveBufferBytes
|
||||
|
||||
start_mode TEXT NOT NULL DEFAULT 'auto'
|
||||
CHECK (start_mode IN ('auto','now','queue','manual')),
|
||||
description TEXT,
|
||||
|
||||
-- Validators, kept for If-Range resume revalidation (docs/04 §5).
|
||||
etag TEXT,
|
||||
last_modified TEXT,
|
||||
content_type TEXT,
|
||||
|
||||
checksum_algo TEXT CHECK (checksum_algo IN ('md5','sha1','sha256','sha512')),
|
||||
checksum_value TEXT,
|
||||
|
||||
-- proto TaskError, flattened. Set on failed / retry_wait, and on an auto-pause.
|
||||
error_code TEXT, -- TaskErrorCode string
|
||||
error_message TEXT,
|
||||
error_http_status INTEGER,
|
||||
error_retryable INTEGER,
|
||||
error_attempt INTEGER,
|
||||
error_next_retry_at TEXT,
|
||||
|
||||
created_at TEXT NOT NULL,
|
||||
last_try_at TEXT,
|
||||
completed_at TEXT
|
||||
) STRICT;
|
||||
|
||||
-- download.list filters/sorts in the daemon (brief: never materialize 100k rows for 40).
|
||||
-- These cover the common filter columns and both default sorts.
|
||||
CREATE INDEX idx_tasks_state ON tasks(state);
|
||||
CREATE INDEX idx_tasks_category ON tasks(category_id);
|
||||
CREATE INDEX idx_tasks_queue_order ON tasks(queue_id, queue_position);
|
||||
CREATE INDEX idx_tasks_created ON tasks(created_at);
|
||||
CREATE INDEX idx_tasks_completed ON tasks(completed_at);
|
||||
|
||||
-- --- segments : per-connection byte ranges for one task ---------------------------
|
||||
-- Inclusive ranges [start_byte, end_byte], matching HTTP Range and ADR 0010. A whole-file
|
||||
-- zero-length download is one row with end_byte = start_byte - 1 = -1 (ADR 0010 B3a), so
|
||||
-- end_byte is not constrained to >= 0.
|
||||
CREATE TABLE segments (
|
||||
task_id TEXT NOT NULL REFERENCES tasks(task_id) ON DELETE CASCADE,
|
||||
idx INTEGER NOT NULL,
|
||||
start_byte INTEGER NOT NULL,
|
||||
end_byte INTEGER NOT NULL,
|
||||
completed_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
state TEXT NOT NULL DEFAULT 'connecting'
|
||||
CHECK (state IN ('connecting','downloading','stalled','complete','failed')),
|
||||
PRIMARY KEY (task_id, idx)
|
||||
) STRICT, WITHOUT ROWID;
|
||||
|
||||
-- --- rules : the routing / capture rules engine table ---------------------------
|
||||
CREATE TABLE rules (
|
||||
rule_id TEXT PRIMARY KEY,
|
||||
priority INTEGER NOT NULL, -- lower runs first; rules.list returns priority order
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
match TEXT NOT NULL, -- json: the match clause (host/ext/size/mime/...)
|
||||
action TEXT NOT NULL -- json: capture decision + category + queue + start mode
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX idx_rules_priority ON rules(priority);
|
||||
|
||||
-- --- history : completed and removed tasks, for the History view --------------------
|
||||
-- A task leaving the list (complete, or removed by the user) drops a snapshot here so the
|
||||
-- main tasks table stays the size of the active list.
|
||||
CREATE TABLE history (
|
||||
history_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
save_dir TEXT NOT NULL,
|
||||
size_bytes INTEGER,
|
||||
final_state TEXT NOT NULL, -- 'complete' | 'cancelled' | 'failed'
|
||||
category_id TEXT,
|
||||
finished_at TEXT NOT NULL,
|
||||
snapshot TEXT NOT NULL -- json: the full TaskSummary at the time it left
|
||||
);
|
||||
|
||||
CREATE INDEX idx_history_finished ON history(finished_at);
|
||||
CREATE INDEX idx_history_task ON history(task_id);
|
||||
|
||||
-- --- pairings : WebSocket transport tokens, HASHED (docs/05 §4, CLAUDE.md §4) --------
|
||||
-- The plaintext token is returned to the extension exactly once, from session.pair, and
|
||||
-- never stored. token_sha256 is the lookup key on every subsequent connect.
|
||||
CREATE TABLE pairings (
|
||||
pairing_id TEXT PRIMARY KEY,
|
||||
token_sha256 TEXT NOT NULL UNIQUE, -- hex SHA-256 of the 256-bit token
|
||||
origin TEXT NOT NULL, -- moz-extension://<uuid>, verified on the WS upgrade
|
||||
label TEXT NOT NULL DEFAULT '', -- human-readable, shown in Options -> Unpair
|
||||
created_at TEXT NOT NULL,
|
||||
last_seen_at TEXT,
|
||||
revoked_at TEXT -- non-NULL once unpaired; kept for the audit trail
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX idx_pairings_origin ON pairings(origin);
|
||||
Reference in New Issue
Block a user