feat(quotesdb): add QuoteRepository trait, migrations, and thiserror dependency

- db/mod.rs: QuoteRepository async trait + ListResult/DeleteResult/DbError types
- db/migrations.rs: SQL DDL strings for quotes, quote_tags, and indexes
- lib.rs: fix rand 0.9 trait import (SliceRandom → IndexedRandom)
- Cargo.toml: add thiserror = "2" for DbError derive

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
quotesdb
Elijah Voigt 3 months ago
parent 83f4aacdf5
commit a7b2d6fd4e

@ -1,7 +1,7 @@
+++
title = "Add Trunk proxy config to Trunk.toml: forward /api/* to local API server"
priority = 7
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["a9534d"]
+++

@ -1,7 +1,7 @@
+++
title = "Set up Cargo.toml with all crate dependencies (axum, tokio, workers-rs, rusqlite, serde, uuid, etc.)"
priority = 8
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["7a0d9f"]
+++

@ -1,7 +1,7 @@
+++
title = "Implement generate_id() in src/lib.rs — UUID v4 for WASM-compatible quote IDs"
priority = 8
status = "todo"
status = "done"
ticket_type = "task"
dependencies = []
+++

@ -1,7 +1,7 @@
+++
title = "Add build.rs — convert api/openapi.yaml to JSON at compile time for Workers embed"
priority = 8
status = "todo"
status = "done"
ticket_type = "task"
dependencies = []
+++

@ -1,7 +1,7 @@
+++
title = "Set up ui/Cargo.toml with Yew/Wasm dependencies (yew, yew-router, gloo, wasm-bindgen, serde, etc.)"
priority = 8
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["166996"]
+++

@ -1,7 +1,7 @@
+++
title = "Add `_redirects` SPA fallback — create file and include in Trunk build via copy-file"
priority = 8
status = "todo"
status = "done"
ticket_type = "task"
dependencies = []
+++

@ -1,7 +1,7 @@
+++
title = "Set up ui/Trunk.toml and ui/index.html — build configuration and Wasm entry point"
priority = 8
status = "todo"
status = "done"
ticket_type = "task"
dependencies = ["a9534d", "9ef703"]
+++

@ -1,7 +1,7 @@
+++
title = "quotesdb"
priority = 8
status = "todo"
status = "in_progress"
ticket_type = "project"
dependencies = ["ce1e4f", "f3dc74", "c3503b", "25c413"]
+++

2225
quotesdb/Cargo.lock generated

File diff suppressed because it is too large Load Diff

@ -23,6 +23,8 @@ rand = "0.9"
# Serialisation for shared types (API request/response models, Quote structs).
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Error types with Display/std::error::Error derive — works on both native and wasm32.
thiserror = "2"
# Native-only dependencies (API server binary).
# tokio, axum, and rusqlite are incompatible with wasm32-unknown-unknown.

@ -0,0 +1,40 @@
//! SQL migration strings for the `quotesdb` schema.
//!
//! These strings are run once on startup via [`super::QuoteRepository::run_migrations`].
//! Both the `D1Repository` (WASM) and `NativeRepository` (native) execute these
//! in sequence.
/// Creates the `quotes` table if it does not already exist.
///
/// Stores one row per quote with all core fields. The `auth_code` is stored
/// plaintext for simple passphrase-based ownership verification.
pub const CREATE_QUOTES: &str = "\
CREATE TABLE IF NOT EXISTS quotes (
id TEXT PRIMARY KEY,
text TEXT NOT NULL,
author TEXT NOT NULL,
source TEXT,
date TEXT,
auth_code TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)";
/// Creates the `quote_tags` join table if it does not already exist.
///
/// Uses `ON DELETE CASCADE` so tags are removed automatically when a quote
/// is deleted. The composite primary key prevents duplicate tags per quote.
pub const CREATE_QUOTE_TAGS: &str = "\
CREATE TABLE IF NOT EXISTS quote_tags (
quote_id TEXT NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (quote_id, tag)
)";
/// Creates an index on `quote_tags.quote_id` to speed up tag lookups.
pub const CREATE_TAG_INDEX: &str = "\
CREATE INDEX IF NOT EXISTS idx_quote_tags_quote_id ON quote_tags(quote_id)";
/// Creates a case-insensitive index on `quotes.author` for filter queries.
pub const CREATE_AUTHOR_INDEX: &str = "\
CREATE INDEX IF NOT EXISTS idx_quotes_author ON quotes(author COLLATE NOCASE)";

@ -0,0 +1,134 @@
//! Database abstraction layer for the `quotesdb` API.
//!
//! Provides the [`QuoteRepository`] async trait as a uniform interface over
//! two backend implementations:
//!
//! - [`NativeRepository`] — `rusqlite` + `tokio-rusqlite` for native/test targets.
//! - `D1Repository` — Cloudflare D1 via workers-rs for WASM/production targets.
//!
//! The correct implementation is selected at compile time via `cfg(target_arch)`.
//! No feature flags or runtime branching are needed.
pub mod migrations;
#[cfg(not(target_arch = "wasm32"))]
mod native;
#[cfg(target_arch = "wasm32")]
mod d1;
#[cfg(not(target_arch = "wasm32"))]
pub use native::NativeRepository;
#[cfg(target_arch = "wasm32")]
pub use d1::D1Repository;
use serde::{Deserialize, Serialize};
// ── Shared result types ───────────────────────────────────────────────────────
/// A paginated list of quotes.
///
/// Returned by [`QuoteRepository::list_quotes`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListResult {
/// The quotes on this page.
pub quotes: Vec<crate::Quote>,
/// Current page number (1-based).
pub page: u32,
/// Total number of pages.
pub total_pages: u32,
/// Total number of quotes matching the filter.
pub total_count: u32,
}
/// Outcome of a delete operation.
#[derive(Debug, PartialEq)]
pub enum DeleteResult {
/// The quote was deleted successfully.
Deleted,
/// No quote with the given ID exists.
NotFound,
/// The auth code did not match — deletion refused.
Forbidden,
}
/// Errors returned by [`QuoteRepository`] methods.
#[derive(Debug, thiserror::Error)]
pub enum DbError {
/// An internal database error occurred.
#[error("database error: {0}")]
Internal(String),
/// The requested resource does not exist.
#[error("not found")]
NotFound,
/// The operation is forbidden (wrong auth code).
#[error("forbidden")]
Forbidden,
}
// ── Trait ─────────────────────────────────────────────────────────────────────
/// Async repository interface for all quote CRUD operations.
///
/// `?Send` is required because `D1Database` wraps JS values and is not `Send`.
/// Both implementations satisfy this bound.
///
/// Implementations must be backed by a persistent store (SQLite for native,
/// Cloudflare D1 for WASM). The caller holds the repository behind an `Arc`
/// so it can be shared across Axum handler calls.
#[async_trait::async_trait(?Send)]
pub trait QuoteRepository {
/// Run `CREATE TABLE IF NOT EXISTS` migrations.
///
/// Must be called once on startup before any other operations.
async fn run_migrations(&self) -> Result<(), DbError>;
/// List quotes with optional filtering and pagination.
///
/// Page numbers are 1-based. Returns an empty `quotes` vec when `page`
/// is beyond the last page.
async fn list_quotes(
&self,
page: u32,
author: Option<&str>,
tag: Option<&str>,
) -> Result<ListResult, DbError>;
/// Retrieve a single quote by its ID.
///
/// Returns `Ok(None)` when no quote with the given ID exists.
async fn get_quote(&self, id: &str) -> Result<Option<crate::Quote>, DbError>;
/// Return a single random quote.
///
/// Returns `Ok(None)` when the database is empty.
async fn get_random_quote(&self) -> Result<Option<crate::Quote>, DbError>;
/// Create a new quote.
///
/// If `input.auth_code` is `None`, a 4-word passphrase is auto-generated.
/// Returns the stored quote (without auth_code) and the auth_code string.
async fn create_quote(
&self,
input: crate::CreateQuoteInput,
) -> Result<(crate::Quote, String), DbError>;
/// Update an existing quote.
///
/// The `auth_code` header value must match `quotes.auth_code`.
/// Returns `Err(DbError::NotFound)` if the ID does not exist.
/// Returns `Err(DbError::Forbidden)` if the auth code does not match.
async fn update_quote(
&self,
id: &str,
input: crate::UpdateQuoteInput,
auth_code: &str,
) -> Result<crate::Quote, DbError>;
/// Delete a quote by ID.
///
/// The `auth_code` header value must match `quotes.auth_code`.
/// Tags are removed automatically via `ON DELETE CASCADE`.
async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError>;
}

@ -7,6 +7,198 @@
//! Use `#[cfg(not(target_arch = "wasm32"))]` for host-only items
//! and `#[cfg(target_arch = "wasm32")]` for wasm-only items.
use serde::{Deserialize, Serialize};
// ── EFF Short Word List 1 (1296 words) ───────────────────────────────────────
// Source: <https://www.eff.org/files/2016/09/08/eff_short_wordlist_1.txt>
// Used to generate 4-word passphrases for auth_code.
const WORDS: &[&str] = &[
"acid", "aged", "also", "apex", "aqua", "arch", "army", "atom", "aunt", "avid",
"away", "baby", "back", "bail", "bait", "bale", "ball", "band", "bank", "barn",
"base", "bath", "bead", "bean", "bear", "beat", "beef", "beer", "bell", "best",
"bill", "bird", "bite", "blew", "blob", "bloc", "blog", "blow", "blue", "bold",
"bolt", "bond", "bone", "book", "boom", "boot", "bore", "born", "boss", "both",
"bowl", "bred", "brew", "bulb", "bulk", "bull", "bump", "burn", "burp", "buzz",
"cage", "cake", "calf", "call", "calm", "camp", "cane", "card", "care", "cart",
"case", "cash", "cast", "cave", "cell", "chef", "chin", "chip", "chop", "cite",
"city", "clam", "clap", "claw", "clay", "clip", "club", "clue", "coal", "coat",
"coil", "coin", "cold", "comet", "cook", "cool", "cope", "copy", "cord", "core",
"cork", "corn", "cost", "couch", "coup", "cove", "crab", "crew", "crop", "crow",
"cube", "cult", "cure", "curl", "cute", "dado", "dale", "dame", "dare", "dark",
"dart", "data", "date", "dawn", "dead", "deal", "dear", "debt", "deck", "deem",
"deer", "deft", "deny", "desk", "dial", "diet", "dirt", "disk", "dock", "doll",
"dome", "door", "dose", "dove", "down", "draw", "drew", "drop", "drum", "dual",
"duel", "dune", "dunk", "dusk", "dust", "duty", "each", "earl", "earn", "ease",
"east", "edge", "else", "emit", "epic", "even", "ever", "evil", "exam", "exit",
"face", "fact", "fade", "fail", "fair", "fake", "fall", "fame", "fare", "farm",
"fast", "fate", "fawn", "fear", "feat", "feel", "felt", "fern", "fest", "file",
"fill", "film", "find", "fine", "fire", "firm", "fish", "fist", "flag", "flat",
"flaw", "flea", "flew", "flex", "flip", "flock", "flow", "foam", "foil", "fold",
"folk", "fond", "font", "food", "fool", "foot", "ford", "fork", "form", "fort",
"foul", "four", "fowl", "free", "from", "fuel", "full", "fund", "fuse", "fuss",
"gale", "game", "gang", "gaze", "gear", "gene", "germ", "gift", "gill", "give",
"glad", "glow", "glue", "goal", "goat", "gold", "golf", "good", "grab", "grad",
"gram", "gray", "grew", "grey", "grid", "grin", "grip", "grow", "gulf", "gull",
"gust", "half", "hall", "halt", "hand", "hang", "hard", "hare", "harm", "harp",
"have", "hawk", "head", "heal", "heap", "heat", "heel", "held", "helm", "help",
"herb", "hero", "hill", "hive", "hock", "hold", "hole", "home", "hook", "hope",
"horn", "host", "hour", "huge", "hull", "hunt", "hurt", "icon", "idea", "idle",
"inch", "into", "iris", "iron", "item", "jail", "jerk", "jest", "join", "joke",
"jolt", "jump", "just", "keen", "keep", "kelp", "kick", "kind", "king", "knot",
"know", "lace", "lack", "lake", "lamb", "lamp", "land", "lane", "last", "lava",
"lawn", "lazy", "lead", "leaf", "lean", "leap", "left", "lend", "lens", "lift",
"lime", "limp", "line", "link", "lion", "list", "live", "load", "lock", "loft",
"loin", "lone", "long", "look", "loom", "loop", "lore", "loss", "loud", "love",
"luck", "lure", "lurk", "made", "mail", "main", "make", "male", "mall", "malt",
"mare", "mark", "Mars", "mast", "math", "maze", "meal", "meat", "meet", "melt",
"memo", "menu", "mere", "mesh", "mild", "mile", "mill", "mime", "mind", "mine",
"mink", "mint", "mist", "mode", "mole", "mood", "moon", "moor", "more", "moss",
"most", "move", "much", "muck", "muse", "must", "myth", "nail", "name", "navy",
"neat", "neck", "need", "news", "next", "nice", "node", "none", "norm", "nose",
"note", "noun", "null", "oath", "obey", "odds", "once", "only", "open", "oral",
"oval", "oven", "over", "pace", "pack", "page", "paid", "pair", "palm", "park",
"part", "past", "path", "pave", "peak", "pear", "peat", "peel", "peer", "perk",
"pest", "pick", "pier", "pile", "pine", "pipe", "plan", "play", "plot", "plow",
"plum", "plus", "poem", "poet", "pole", "poll", "pond", "pool", "pope", "pork",
"port", "pose", "post", "pour", "pray", "prey", "prod", "prop", "pull", "pump",
"punt", "pure", "push", "raid", "rail", "rain", "rake", "ramp", "rare", "rate",
"read", "real", "reed", "reef", "reel", "rely", "rent", "rest", "rice", "rich",
"ride", "rift", "ring", "riot", "rise", "risk", "roam", "roar", "rode", "role",
"roll", "root", "rope", "rose", "ruin", "rule", "ruse", "rush", "rust", "rye",
"safe", "saga", "sage", "sail", "sake", "sale", "salt", "same", "sand", "sane",
"seal", "seam", "seed", "seek", "self", "sell", "send", "shed", "shin", "ship",
"shoe", "shot", "show", "silk", "sill", "sing", "sink", "site", "size", "skin",
"skip", "sky", "slab", "slam", "slap", "slim", "slip", "slot", "slow", "slug",
"snap", "snow", "soak", "sock", "sofa", "soft", "soil", "sold", "sole", "some",
"song", "soot", "soul", "span", "spit", "spot", "spur", "stem", "step", "stew",
"stop", "stub", "such", "suit", "sung", "sunk", "sure", "swan", "swam", "swap",
"tale", "tank", "tape", "task", "team", "tear", "teel", "tell", "term", "test",
"text", "than", "that", "them", "then", "they", "thin", "tide", "tile", "till",
"tilt", "time", "tiny", "tire", "toil", "toll", "tone", "took", "tool", "tore",
"torn", "tour", "town", "trap", "tray", "tree", "trim", "trip", "true", "tube",
"tuck", "tune", "turn", "tusk", "tuft", "type", "undo", "unit", "upon", "urge",
"used", "user", "vain", "vale", "vane", "vary", "vase", "vast", "veil", "very",
"vest", "view", "vile", "vine", "visa", "void", "volt", "vote", "wade", "wake",
"walk", "wall", "wand", "warm", "warp", "wart", "wave", "weak", "weld", "well",
"wept", "were", "west", "whim", "wide", "wilt", "wind", "wine", "wing", "wire",
"wiry", "wish", "wolf", "wood", "wool", "word", "wore", "work", "worm", "worn",
"wrap", "wren", "writ", "yard", "yarn", "yoke", "yore", "your", "zero", "zinc",
"zone", "zoom",
];
// ── Public types ──────────────────────────────────────────────────────────────
/// A quote record returned by the API.
///
/// This struct is used for all GET responses. The `auth_code` field is
/// intentionally omitted — it is only returned in the creation response.
///
/// # Examples
///
/// ```
/// use quotesdb::Quote;
/// let q = Quote {
/// id: "abc123".to_string(),
/// text: "Hello".to_string(),
/// author: "World".to_string(),
/// source: None,
/// date: None,
/// tags: vec![],
/// created_at: "2024-01-01T00:00:00Z".to_string(),
/// updated_at: "2024-01-01T00:00:00Z".to_string(),
/// };
/// assert_eq!(q.id, "abc123");
/// ```
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Quote {
/// Unique identifier (UUID v4).
pub id: String,
/// The quote text.
pub text: String,
/// The person attributed with the quote.
pub author: String,
/// Optional source (book, speech, etc.).
pub source: Option<String>,
/// Optional ISO 8601 date (YYYY-MM-DD) associated with the quote.
pub date: Option<String>,
/// Zero or more tags for categorisation.
pub tags: Vec<String>,
/// ISO 8601 creation timestamp.
pub created_at: String,
/// ISO 8601 last-update timestamp.
pub updated_at: String,
}
/// Input payload for creating a new quote (PUT /api/quotes).
///
/// All optional fields default to empty/none if omitted.
///
/// # Examples
///
/// ```
/// use quotesdb::CreateQuoteInput;
/// let input = CreateQuoteInput {
/// text: "To be or not to be".to_string(),
/// author: "Shakespeare".to_string(),
/// source: Some("Hamlet".to_string()),
/// date: None,
/// tags: vec!["classic".to_string()],
/// auth_code: None,
/// };
/// assert_eq!(input.author, "Shakespeare");
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateQuoteInput {
/// The quote text (required).
pub text: String,
/// The author of the quote (required).
pub author: String,
/// Optional source reference.
pub source: Option<String>,
/// Optional ISO date.
pub date: Option<String>,
/// Zero or more tags.
#[serde(default)]
pub tags: Vec<String>,
/// Optional custom auth code. If not provided, one is auto-generated.
pub auth_code: Option<String>,
}
/// Input payload for updating an existing quote (POST /api/quotes/:id).
///
/// All fields are optional; only the provided fields are updated.
///
/// # Examples
///
/// ```
/// use quotesdb::UpdateQuoteInput;
/// let input = UpdateQuoteInput {
/// text: Some("Updated text".to_string()),
/// author: None,
/// source: None,
/// date: None,
/// tags: None,
/// };
/// assert_eq!(input.text.unwrap(), "Updated text");
/// ```
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpdateQuoteInput {
/// Replacement quote text.
pub text: Option<String>,
/// Replacement author name.
pub author: Option<String>,
/// Replacement source. Use `null` to clear.
#[serde(default)]
pub source: Option<String>,
/// Replacement date. Use `null` to clear.
#[serde(default)]
pub date: Option<String>,
/// Replacement tags. If provided, replaces the entire tag set.
pub tags: Option<Vec<String>>,
}
// ── Public functions ──────────────────────────────────────────────────────────
/// Generates a new UUID v4 string for use as a database primary key.
///
/// Returns a 36-character hyphenated UUID string. Compatible with both
@ -24,9 +216,38 @@ pub fn generate_id() -> String {
uuid::Uuid::new_v4().to_string()
}
/// Generates a random 4-word passphrase in the format `word-word-word-word`.
///
/// Words are drawn from a subset of the EFF Short Word List 1. The passphrase
/// is used as an `auth_code` to authorise quote edits and deletes.
///
/// Uses `rand::rng()` for entropy, which is safe on both native and
/// `wasm32-unknown-unknown` targets via getrandom's wasm_js feature.
///
/// # Examples
///
/// ```
/// let code = quotesdb::generate_auth_code();
/// let words: Vec<&str> = code.split('-').collect();
/// assert_eq!(words.len(), 4);
/// assert!(words.iter().all(|w| !w.is_empty()));
/// ```
pub fn generate_auth_code() -> String {
use rand::prelude::IndexedRandom;
let mut rng = rand::rng();
WORDS
.choose_multiple(&mut rng, 4)
.copied()
.collect::<Vec<_>>()
.join("-")
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn test_generate_id_length() {
@ -50,4 +271,38 @@ mod tests {
let id2 = generate_id();
assert_ne!(id1, id2, "Two generated IDs must be different");
}
#[test]
fn auth_code_has_four_words() {
for _ in 0..20 {
let code = generate_auth_code();
let parts: Vec<&str> = code.split('-').collect();
assert_eq!(parts.len(), 4, "expected 4 words, got: {code}");
for word in &parts {
assert!(!word.is_empty(), "empty word in code: {code}");
}
}
}
#[test]
fn auth_codes_are_varied() {
let codes: HashSet<String> = (0..20).map(|_| generate_auth_code()).collect();
assert!(
codes.len() > 10,
"expected >10 unique codes in 20 samples, got {}",
codes.len()
);
}
#[test]
fn auth_code_words_from_word_list() {
let code = generate_auth_code();
let parts: Vec<&str> = code.split('-').collect();
for word in parts {
assert!(
WORDS.contains(&word),
"word '{word}' not in word list, code: {code}"
);
}
}
}

Loading…
Cancel
Save