feat(quotesdb): implement D1Repository for Cloudflare Workers
Replace all 7 stub methods in src/bin/api/db/d1.rs with full working implementations using the Cloudflare D1 API from workers-rs 0.5. Implements: - run_migrations: executes four DDL statements via db.exec() - list_quotes: dynamic WHERE clause with positional params, COUNT query, paginated SELECT, per-quote tag fetch - get_quote: prepared statement with first::<QuoteRow>() - get_random_quote: ORDER BY RANDOM() LIMIT 1 - create_quote: INSERT + batch tag insert + read-back for timestamps - update_quote: auth check, dynamic SET clause, optional tag replacement, read-back of updated row - delete_quote: auth check, DELETE, returns DeleteResult enum Also adds helper structs (QuoteRow, AuthRow, TagRow, CountRow), fetch_tags() helper method, and unsafe Send/Sync impls required for Arc<dyn QuoteRepository + Send + Sync> on single-threaded wasm32. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
cd3afa1920
commit
c52eb820d2
@ -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<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::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<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::Database) -> 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 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<ListResult, DbError> {
|
||||
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<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;
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_quote(&self, _id: &str) -> Result<Option<Quote>, 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<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> {
|
||||
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::<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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<worker::d1::PreparedStatement> = 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,
|
||||
id: &str,
|
||||
input: UpdateQuoteInput,
|
||||
auth_code: &str,
|
||||
) -> Result<Quote, DbError> {
|
||||
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::<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 => return Err(DbError::Forbidden),
|
||||
Some(_) => {}
|
||||
}
|
||||
|
||||
// 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::PreparedStatement> = 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))
|
||||
}
|
||||
|
||||
async fn delete_quote(&self, _id: &str, _auth_code: &str) -> Result<DeleteResult, DbError> {
|
||||
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<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 => 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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue