diff --git a/quotesdb/src/bin/api/db/d1.rs b/quotesdb/src/bin/api/db/d1.rs index f72cdba..d1092d9 100644 --- a/quotesdb/src/bin/api/db/d1.rs +++ b/quotesdb/src/bin/api/db/d1.rs @@ -1,70 +1,500 @@ //! Cloudflare D1 repository implementation (wasm32 only). //! -//! [`D1Repository`] wraps a [`worker::d1::Database`] and provides stub -//! implementations of all [`super::QuoteRepository`] methods. Full D1 -//! support will be implemented in a future ticket. +//! [`D1Repository`] wraps a [`worker::d1::Database`] and provides full +//! implementations of all [`super::QuoteRepository`] methods using the +//! Cloudflare D1 API from workers-rs 0.5. //! //! This module is only compiled for `wasm32-unknown-unknown` targets. #![cfg(target_arch = "wasm32")] use super::{DbError, DeleteResult, ListResult, QuoteRepository}; -use quotesdb::{CreateQuoteInput, Quote, UpdateQuoteInput}; +use quotesdb::{generate_auth_code, generate_id, CreateQuoteInput, Quote, UpdateQuoteInput}; +use wasm_bindgen::JsValue; + +// ── Helper structs ──────────────────────────────────────────────────────────── + +/// Row shape for quote SELECT queries. +#[derive(Debug, serde::Deserialize)] +struct QuoteRow { + id: String, + text: String, + author: String, + source: Option, + date: Option, + created_at: String, + updated_at: String, +} + +impl QuoteRow { + /// Convert this row into a [`Quote`] by attaching a pre-fetched tags list. + fn into_quote(self, tags: Vec) -> Quote { + Quote { + id: self.id, + text: self.text, + author: self.author, + source: self.source, + date: self.date, + created_at: self.created_at, + updated_at: self.updated_at, + tags, + } + } +} + +/// Row shape for auth_code lookups. +#[derive(Debug, serde::Deserialize)] +struct AuthRow { + auth_code: String, +} + +/// Row shape for tag lookups. +#[derive(Debug, serde::Deserialize)] +struct TagRow { + tag: String, +} + +/// Row shape for COUNT(*) queries. +#[derive(Debug, serde::Deserialize)] +struct CountRow { + count: u32, +} + +// ── Repository struct ───────────────────────────────────────────────────────── /// Cloudflare D1-backed repository (wasm32 only). /// /// Wraps a [`worker::d1::Database`] handle provided by the Workers runtime. -/// All methods currently return `Err(DbError::Internal(...))` as stubs until -/// D1 query support is fully implemented. +/// All methods use the D1 prepared-statement API to execute SQL queries. pub struct D1Repository { /// The Cloudflare D1 database handle. pub db: worker::d1::Database, } +// SAFETY: wasm32-unknown-unknown is single-threaded; JS values are never sent +// across threads. Required to satisfy Arc. +unsafe impl Send for D1Repository {} +unsafe impl Sync for D1Repository {} + impl D1Repository { /// Create a new [`D1Repository`] wrapping the given D1 database handle. pub fn new(db: worker::d1::Database) -> Self { Self { db } } + + /// Fetch all tags for a quote, sorted alphabetically. + /// + /// Returns a sorted `Vec` of tag values, or an empty vec if none exist. + async fn fetch_tags(&self, id: &str) -> Result, DbError> { + let rows = self + .db + .prepare("SELECT tag FROM quote_tags WHERE quote_id = ?1 ORDER BY tag") + .bind(&[JsValue::from_str(id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .all() + .await + .map_err(|e| DbError::Internal(e.to_string()))? + .results::() + .map_err(|e| DbError::Internal(e.to_string()))?; + Ok(rows.into_iter().map(|r| r.tag).collect()) + } } +// ── QuoteRepository impl ────────────────────────────────────────────────────── + #[async_trait::async_trait(?Send)] impl QuoteRepository for D1Repository { + /// Run the four DDL migration statements from [`super::migrations`]. + /// + /// Safe to call multiple times — all statements use `IF NOT EXISTS`. async fn run_migrations(&self) -> Result<(), DbError> { - Err(DbError::Internal("D1 not yet implemented".to_string())) + use super::migrations::*; + for sql in &[ + CREATE_QUOTES, + CREATE_QUOTE_TAGS, + CREATE_TAG_INDEX, + CREATE_AUTHOR_INDEX, + ] { + self.db + .exec(sql) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + } + Ok(()) } + /// List quotes with optional author/tag filters and 1-based pagination. + /// + /// Page size is fixed at 10. Uses `COLLATE NOCASE` for author filtering. + /// Tags for each returned quote are fetched in a second query per quote to + /// avoid duplicate rows from a JOIN. async fn list_quotes( &self, - _page: u32, - _author: Option<&str>, - _tag: Option<&str>, + page: u32, + author: Option<&str>, + tag: Option<&str>, ) -> Result { - Err(DbError::Internal("D1 not yet implemented".to_string())) + const PAGE_SIZE: u32 = 10; + let page = page.max(1); + + // ── Build WHERE clause with positional params ────────────────────── + let mut conditions: Vec = Vec::new(); + let mut binds: Vec = Vec::new(); + let mut param_idx: u32 = 1; + + if let Some(a) = author { + conditions.push(format!("q.author = ?{param_idx} COLLATE NOCASE")); + binds.push(JsValue::from_str(a)); + param_idx += 1; + } + if let Some(t) = tag { + conditions.push(format!( + "q.id IN (SELECT quote_id FROM quote_tags WHERE tag = ?{param_idx})" + )); + binds.push(JsValue::from_str(t)); + param_idx += 1; + } + + let where_clause = if conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", conditions.join(" AND ")) + }; + + // ── Count total matching rows ────────────────────────────────────── + let count_sql = format!("SELECT COUNT(*) as count FROM quotes q {where_clause}"); + let count_row = self + .db + .prepare(&count_sql) + .bind(&binds) + .map_err(|e| DbError::Internal(e.to_string()))? + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))? + .ok_or_else(|| DbError::Internal("count query returned no row".to_string()))?; + + let total_count = count_row.count; + let total_pages = ((total_count + PAGE_SIZE - 1) / PAGE_SIZE).max(1); + let offset = (page - 1) * PAGE_SIZE; + + // ── Fetch the page of quotes ─────────────────────────────────────── + let list_sql = format!( + "SELECT q.id, q.text, q.author, q.source, q.date, \ + q.created_at, q.updated_at \ + FROM quotes q {where_clause} \ + ORDER BY q.created_at DESC \ + LIMIT ?{param_idx} OFFSET ?{}", + param_idx + 1 + ); + + let mut list_binds = binds.clone(); + list_binds.push(JsValue::from_f64(PAGE_SIZE as f64)); + list_binds.push(JsValue::from_f64(offset as f64)); + + let rows = self + .db + .prepare(&list_sql) + .bind(&list_binds) + .map_err(|e| DbError::Internal(e.to_string()))? + .all() + .await + .map_err(|e| DbError::Internal(e.to_string()))? + .results::() + .map_err(|e| DbError::Internal(e.to_string()))?; + + // Second pass: fetch tags for each quote + let mut quotes: Vec = Vec::with_capacity(rows.len()); + for row in rows { + let tags = self.fetch_tags(&row.id).await?; + quotes.push(row.into_quote(tags)); + } + + Ok(ListResult { + quotes, + page, + total_pages, + total_count, + }) } - async fn get_quote(&self, _id: &str) -> Result, DbError> { - Err(DbError::Internal("D1 not yet implemented".to_string())) + /// Retrieve a single quote by its primary key. + /// + /// Returns `Ok(None)` when no row matches `id`. + async fn get_quote(&self, id: &str) -> Result, DbError> { + let row = self + .db + .prepare( + "SELECT id, text, author, source, date, created_at, updated_at \ + FROM quotes WHERE id = ?1", + ) + .bind(&[JsValue::from_str(id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + match row { + None => Ok(None), + Some(r) => { + let tags = self.fetch_tags(&r.id).await?; + Ok(Some(r.into_quote(tags))) + } + } } + /// Return one quote chosen at random. + /// + /// Returns `Ok(None)` when the `quotes` table is empty. async fn get_random_quote(&self) -> Result, DbError> { - Err(DbError::Internal("D1 not yet implemented".to_string())) + let row = self + .db + .prepare( + "SELECT id, text, author, source, date, created_at, updated_at \ + FROM quotes ORDER BY RANDOM() LIMIT 1", + ) + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + match row { + None => Ok(None), + Some(r) => { + let tags = self.fetch_tags(&r.id).await?; + Ok(Some(r.into_quote(tags))) + } + } } - async fn create_quote(&self, _input: CreateQuoteInput) -> Result<(Quote, String), DbError> { - Err(DbError::Internal("D1 not yet implemented".to_string())) + /// Insert a new quote row and its associated tags. + /// + /// If `input.auth_code` is `None`, a 4-word passphrase is generated. + /// Returns the persisted [`Quote`] (without `auth_code`) and the raw + /// auth-code string so the caller can include it in the creation response. + async fn create_quote(&self, input: CreateQuoteInput) -> Result<(Quote, String), DbError> { + let id = generate_id(); + let auth_code = input.auth_code.unwrap_or_else(generate_auth_code); + + // Insert the quote row + self.db + .prepare( + "INSERT INTO quotes (id, text, author, source, date, auth_code) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + ) + .bind(&[ + JsValue::from_str(&id), + JsValue::from_str(&input.text), + JsValue::from_str(&input.author), + input + .source + .as_deref() + .map(JsValue::from_str) + .unwrap_or(JsValue::NULL), + input + .date + .as_deref() + .map(JsValue::from_str) + .unwrap_or(JsValue::NULL), + JsValue::from_str(&auth_code), + ]) + .map_err(|e| DbError::Internal(e.to_string()))? + .run() + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + // Batch insert tags + if !input.tags.is_empty() { + let tag_stmts: Vec = input + .tags + .iter() + .map(|tag| { + self.db + .prepare("INSERT OR IGNORE INTO quote_tags (quote_id, tag) VALUES (?1, ?2)") + .bind(&[JsValue::from_str(&id), JsValue::from_str(tag)]) + .map_err(|e| DbError::Internal(e.to_string())) + }) + .collect::, _>>()?; + self.db + .batch(tag_stmts) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + } + + // Read back the row to get server-generated timestamps + let row = self + .db + .prepare( + "SELECT id, text, author, source, date, created_at, updated_at \ + FROM quotes WHERE id = ?1", + ) + .bind(&[JsValue::from_str(&id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))? + .ok_or_else(|| DbError::Internal("inserted row not found on read-back".to_string()))?; + + let tags = self.fetch_tags(&id).await?; + Ok((row.into_quote(tags), auth_code)) } + /// Update non-`None` fields on an existing quote. + /// + /// Verifies `auth_code` before making any changes. If `input.tags` is + /// `Some`, the entire tag set is replaced. Updates `updated_at` to the + /// current UTC time. async fn update_quote( &self, - _id: &str, - _input: UpdateQuoteInput, - _auth_code: &str, + id: &str, + input: UpdateQuoteInput, + auth_code: &str, ) -> Result { - Err(DbError::Internal("D1 not yet implemented".to_string())) + // Phase 1: fetch stored auth_code + let auth_row = self + .db + .prepare("SELECT auth_code FROM quotes WHERE id = ?1") + .bind(&[JsValue::from_str(id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + match auth_row { + None => return Err(DbError::NotFound), + Some(ref r) if r.auth_code != auth_code => return Err(DbError::Forbidden), + Some(_) => {} + } + + // Phase 2: build dynamic SET clause with positional params + let mut sets: Vec = Vec::new(); + let mut binds: Vec = Vec::new(); + let mut param_idx: u32 = 1; + + if let Some(ref text) = input.text { + sets.push(format!("text = ?{param_idx}")); + binds.push(JsValue::from_str(text)); + param_idx += 1; + } + if let Some(ref author) = input.author { + sets.push(format!("author = ?{param_idx}")); + binds.push(JsValue::from_str(author)); + param_idx += 1; + } + // source and date always updated (None clears the field) + sets.push(format!("source = ?{param_idx}")); + binds.push( + input + .source + .as_deref() + .map(JsValue::from_str) + .unwrap_or(JsValue::NULL), + ); + param_idx += 1; + + sets.push(format!("date = ?{param_idx}")); + binds.push( + input + .date + .as_deref() + .map(JsValue::from_str) + .unwrap_or(JsValue::NULL), + ); + param_idx += 1; + + sets.push("updated_at = datetime('now')".to_string()); + + let update_sql = format!( + "UPDATE quotes SET {} WHERE id = ?{param_idx}", + sets.join(", ") + ); + binds.push(JsValue::from_str(id)); + + self.db + .prepare(&update_sql) + .bind(&binds) + .map_err(|e| DbError::Internal(e.to_string()))? + .run() + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + // Phase 3: replace tags if provided + if let Some(ref tags) = input.tags { + self.db + .prepare("DELETE FROM quote_tags WHERE quote_id = ?1") + .bind(&[JsValue::from_str(id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .run() + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + if !tags.is_empty() { + let tag_stmts: Vec = tags + .iter() + .map(|tag| { + self.db + .prepare( + "INSERT OR IGNORE INTO quote_tags (quote_id, tag) \ + VALUES (?1, ?2)", + ) + .bind(&[JsValue::from_str(id), JsValue::from_str(tag)]) + .map_err(|e| DbError::Internal(e.to_string())) + }) + .collect::, _>>()?; + self.db + .batch(tag_stmts) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + } + } + + // Phase 4: read back the updated quote + let row = self + .db + .prepare( + "SELECT id, text, author, source, date, created_at, updated_at \ + FROM quotes WHERE id = ?1", + ) + .bind(&[JsValue::from_str(id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))? + .ok_or(DbError::NotFound)?; + + let fetched_tags = self.fetch_tags(&row.id).await?; + Ok(row.into_quote(fetched_tags)) } - async fn delete_quote(&self, _id: &str, _auth_code: &str) -> Result { - Err(DbError::Internal("D1 not yet implemented".to_string())) + /// Delete a quote by ID after verifying the auth code. + /// + /// Returns [`DeleteResult::NotFound`] if no quote has that ID, + /// [`DeleteResult::Forbidden`] if the auth code does not match, + /// or [`DeleteResult::Deleted`] on success. Tags are removed automatically + /// by the `ON DELETE CASCADE` constraint on `quote_tags`. + async fn delete_quote(&self, id: &str, auth_code: &str) -> Result { + // Fetch stored auth_code + let auth_row = self + .db + .prepare("SELECT auth_code FROM quotes WHERE id = ?1") + .bind(&[JsValue::from_str(id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .first::(None) + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + match auth_row { + None => return Ok(DeleteResult::NotFound), + Some(ref r) if r.auth_code != auth_code => return Ok(DeleteResult::Forbidden), + Some(_) => {} + } + + self.db + .prepare("DELETE FROM quotes WHERE id = ?1") + .bind(&[JsValue::from_str(id)]) + .map_err(|e| DbError::Internal(e.to_string()))? + .run() + .await + .map_err(|e| DbError::Internal(e.to_string()))?; + + Ok(DeleteResult::Deleted) } }