//! Cloudflare D1 repository implementation (wasm32 only). //! //! [`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. use super::{DbError, DeleteResult, ListResult, QuoteRepository}; 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, /// Stored as an integer (0 = visible, 1 = hidden); converted to bool on deserialization. hidden: i64, 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, hidden: self.hidden != 0, 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::D1Database`] handle provided by the Workers runtime. /// All methods use the D1 prepared-statement API to execute SQL queries. pub struct D1Repository { /// The Cloudflare D1 database handle. pub db: worker::d1::D1Database, } // 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::D1Database) -> 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 all DDL migration statements from [`super::migrations`]. /// /// Covers `quotes`, `quote_tags`, tag index, author index, `admin_config`, /// and the `hidden` column ALTER. Safe to call multiple times — CREATE /// statements use `IF NOT EXISTS`. The ALTER TABLE error (column already /// exists) is intentionally ignored for idempotency. async fn run_migrations(&self) -> Result<(), DbError> { use super::migrations::*; for sql in &[ CREATE_QUOTES, CREATE_QUOTE_TAGS, CREATE_TAG_INDEX, CREATE_AUTHOR_INDEX, CREATE_ADMIN_CONFIG, CREATE_REPORTS, ] { self.db .exec(sql) .await .map_err(|e| DbError::Internal(e.to_string()))?; } // ALTER TABLE does not support IF NOT EXISTS — ignore the error when // the column already exists (idempotent on re-runs). let _ = self.db.exec(ALTER_QUOTES_ADD_HIDDEN).await; Ok(()) } /// List quotes with optional author/tag/date filters and 1-based pagination. /// /// Page size is fixed at 10. Uses `COLLATE NOCASE` for author filtering. /// `date_after` and `date_before` are ISO date prefix strings compared via /// `>=` / `<=` against the stored `date` column; rows where `date IS NULL` /// are excluded when either bound is set. /// 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>, date_after: Option<&str>, date_before: Option<&str>, ) -> Result { const PAGE_SIZE: u32 = 10; let page = page.max(1); // ── Build WHERE clause with positional params ────────────────────── // Always exclude hidden quotes from listing endpoints. let mut conditions: Vec = vec!["q.hidden = 0".to_owned()]; 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; } // Exclude NULL dates when any date bound is active if date_after.is_some() || date_before.is_some() { conditions.push("q.date IS NOT NULL".to_owned()); } if let Some(da) = date_after { conditions.push(format!("q.date >= ?{param_idx}")); binds.push(JsValue::from_str(da)); param_idx += 1; } if let Some(db) = date_before { conditions.push(format!("q.date <= ?{param_idx}")); binds.push(JsValue::from_str(db)); param_idx += 1; } let where_clause = 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.hidden, 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, }) } /// 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, hidden, 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> { let row = self .db .prepare( "SELECT id, text, author, source, date, hidden, created_at, updated_at \ FROM quotes WHERE hidden = 0 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))) } } } /// 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, hidden, 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, ) -> Result { // 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 => {} // exact match, proceed Some(_) => { // Check admin code fallback let admin = self.get_admin_auth_code().await?; if admin.as_deref() != Some(auth_code) { return Err(DbError::Forbidden); } } } // 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; // hidden is only updated when explicitly provided if let Some(h) = input.hidden { sets.push(format!("hidden = ?{param_idx}")); binds.push(JsValue::from_f64(if h { 1.0 } else { 0.0 })); 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, hidden, 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)) } /// Delete a quote by ID after verifying the auth code. /// /// Returns [`DeleteResult::NotFound`] if no quote has that ID, /// [`DeleteResult::Forbidden`] if neither the per-quote auth code nor the /// admin super auth code matches, 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 => { // Per-quote auth matches — fall through to delete } Some(_) => { // Check admin code as fallback let admin = self.get_admin_auth_code().await?; if admin.as_deref() != Some(auth_code) { return Ok(DeleteResult::Forbidden); } } } 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) } /// Retrieve the admin super auth code from `admin_config`. /// /// Returns `Ok(None)` if the admin code has not been seeded yet. async fn get_admin_auth_code(&self) -> Result, DbError> { #[derive(serde::Deserialize)] struct ValueRow { value: String, } self.db .prepare("SELECT value FROM admin_config WHERE key = 'admin_auth_code'") .first::(None) .await .map(|opt| opt.map(|r| r.value)) .map_err(|e| DbError::Internal(e.to_string())) } /// Insert the admin auth code if not already present. /// /// Uses `INSERT OR IGNORE` so calling it multiple times is safe. async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError> { self.db .prepare( "INSERT OR IGNORE INTO admin_config (key, value) VALUES ('admin_auth_code', ?1)", ) .bind(&[JsValue::from_str(code)]) .map_err(|e| DbError::Internal(e.to_string()))? .run() .await .map(|_| ()) .map_err(|e| DbError::Internal(e.to_string())) } /// Replace the admin auth code if `current` matches the stored value. /// /// Generates a fresh 4-word passphrase when `new_code` is `None`. /// /// The check and update are performed atomically via a single /// `UPDATE … WHERE key = 'admin_auth_code' AND value = ?current` statement. /// The result metadata's `changes` count is inspected: if zero rows were /// affected the stored code either does not exist or did not match `current`, /// and `Err(DbError::Forbidden)` is returned. If one row was affected the new /// code is returned. async fn update_admin_auth_code( &self, current: &str, new_code: Option<&str>, ) -> Result { let replacement = new_code .map(|s| s.to_owned()) .unwrap_or_else(generate_auth_code); let result = self .db .prepare( "UPDATE admin_config \ SET value = ?1 \ WHERE key = 'admin_auth_code' AND value = ?2", ) .bind(&[JsValue::from_str(&replacement), JsValue::from_str(current)]) .map_err(|e| DbError::Internal(e.to_string()))? .run() .await .map_err(|e| DbError::Internal(e.to_string()))?; let changes = result .meta() .map_err(|e| DbError::Internal(e.to_string()))? .and_then(|m| m.changes) .unwrap_or(0); if changes == 0 { return Err(DbError::Forbidden); } Ok(replacement) } /// Return whether submissions are currently locked. /// /// Reads the `submissions_locked` key from `admin_config`. /// Returns `false` if the key has not been seeded yet. async fn get_submissions_locked(&self) -> Result { #[derive(serde::Deserialize)] struct ValueRow { value: String, } let row = self .db .prepare("SELECT value FROM admin_config WHERE key = 'submissions_locked'") .first::(None) .await .map_err(|e| DbError::Internal(e.to_string()))?; Ok(row.map(|r| r.value == "1").unwrap_or(false)) } /// Persist the submissions lock state. /// /// Upserts `"1"` (locked) or `"0"` (unlocked) into the `submissions_locked` /// key in `admin_config`. async fn set_submissions_locked(&self, locked: bool) -> Result<(), DbError> { let value = if locked { "1" } else { "0" }; self.db .prepare( "INSERT INTO admin_config (key, value) VALUES ('submissions_locked', ?1) \ ON CONFLICT(key) DO UPDATE SET value = excluded.value", ) .bind(&[JsValue::from_str(value)]) .map_err(|e| DbError::Internal(e.to_string()))? .run() .await .map(|_| ()) .map_err(|e| DbError::Internal(e.to_string())) } /// Seed the `submissions_locked` key as `"0"` if not already present. /// /// Uses `INSERT OR IGNORE` so calling it multiple times is safe. async fn seed_submissions_locked(&self) -> Result<(), DbError> { self.db .prepare( "INSERT OR IGNORE INTO admin_config (key, value) \ VALUES ('submissions_locked', '0')", ) .run() .await .map(|_| ()) .map_err(|e| DbError::Internal(e.to_string())) } /// Create a moderation report for an existing quote. /// /// Checks that the quote exists via a COUNT query, then inserts a new row /// into the `reports` table. Returns `Err(DbError::NotFound)` if no quote /// with the given `quote_id` exists. async fn create_report(&self, quote_id: &str, reason: Option<&str>) -> Result<(), DbError> { // Step 1: verify the quote exists. #[derive(serde::Deserialize)] struct ExistsRow { count: i64, } let exists_row = self .db .prepare("SELECT COUNT(*) AS count FROM quotes WHERE id = ?1") .bind(&[JsValue::from_str(quote_id)]) .map_err(|e| DbError::Internal(e.to_string()))? .first::(None) .await .map_err(|e| DbError::Internal(e.to_string()))?; let exists = exists_row.map(|r| r.count > 0).unwrap_or(false); if !exists { return Err(DbError::NotFound); } // Step 2: insert the report row. let id = generate_id(); let reason_value = match reason { Some(r) => JsValue::from_str(r), None => JsValue::NULL, }; self.db .prepare("INSERT INTO reports (id, quote_id, reason) VALUES (?1, ?2, ?3)") .bind(&[ JsValue::from_str(&id), JsValue::from_str(quote_id), reason_value, ]) .map_err(|e| DbError::Internal(e.to_string()))? .run() .await .map(|_| ()) .map_err(|e| DbError::Internal(e.to_string())) } }