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>main
parent
efa23f1c7c
commit
ea6fa981fc
File diff suppressed because it is too large
Load Diff
@ -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>;
|
||||
}
|
||||
Loading…
Reference in New Issue