You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

655 lines
23 KiB
Rust

//! 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<String>,
date: Option<String>,
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<String>) -> 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::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<dyn QuoteRepository + Send + Sync>.
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<String>` of tag values, or an empty vec if none exist.
async fn fetch_tags(&self, id: &str) -> Result<Vec<String>, 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::<TagRow>()
.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 five DDL migration statements from [`super::migrations`].
///
/// Covers `quotes`, `quote_tags`, tag index, author index, and
/// `admin_config`. Safe to call multiple times — all statements use
/// `IF NOT EXISTS`.
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,
] {
self.db
.exec(sql)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
}
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<ListResult, DbError> {
const PAGE_SIZE: u32 = 10;
let page = page.max(1);
// ── Build WHERE clause with positional params ──────────────────────
let mut conditions: Vec<String> = Vec::new();
let mut binds: Vec<JsValue> = 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 = 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::<CountRow>(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::<QuoteRow>()
.map_err(|e| DbError::Internal(e.to_string()))?;
// Second pass: fetch tags for each quote
let mut quotes: Vec<Quote> = 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<Option<Quote>, 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::<QuoteRow>(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<Option<Quote>, DbError> {
let row = self
.db
.prepare(
"SELECT id, text, author, source, date, created_at, updated_at \
FROM quotes ORDER BY RANDOM() LIMIT 1",
)
.first::<QuoteRow>(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<worker::d1::D1PreparedStatement> = 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::<Result<Vec<_>, _>>()?;
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::<QuoteRow>(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<Quote, DbError> {
// 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::<AuthRow>(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<String> = Vec::new();
let mut binds: Vec<JsValue> = 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<worker::d1::D1PreparedStatement> = 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::<Result<Vec<_>, _>>()?;
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::<QuoteRow>(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<DeleteResult, DbError> {
// 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::<AuthRow>(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<Option<String>, DbError> {
#[derive(serde::Deserialize)]
struct ValueRow {
value: String,
}
self.db
.prepare("SELECT value FROM admin_config WHERE key = 'admin_auth_code'")
.first::<ValueRow>(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`.
/// Returns `Err(DbError::Forbidden)` if `current` does not match the stored code.
async fn update_admin_auth_code(
&self,
current: &str,
new_code: Option<&str>,
) -> Result<String, DbError> {
let stored = self.get_admin_auth_code().await?;
if stored.as_deref() != Some(current) {
return Err(DbError::Forbidden);
}
let replacement = new_code
.map(|s| s.to_owned())
.unwrap_or_else(generate_auth_code);
self.db
.prepare(
"INSERT INTO admin_config (key, value) VALUES ('admin_auth_code', ?1) \
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
)
.bind(&[JsValue::from_str(&replacement)])
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
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<bool, DbError> {
#[derive(serde::Deserialize)]
struct ValueRow {
value: String,
}
let row = self
.db
.prepare("SELECT value FROM admin_config WHERE key = 'submissions_locked'")
.first::<ValueRow>(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()))
}
}