12 KiB
| title | status | type | priority | created_at | updated_at |
|---|---|---|---|---|---|
| Implement DB abstraction: QuoteRepository trait + cfg-split D1/rusqlite impls | completed | task | critical | 2026-03-10T23:32:04Z | 2026-03-10T23:32:04Z |
Chosen approach: cfg(target_arch)-based split
#[cfg(target_arch = "wasm32")]→ workers-rs D1 bindings (worker::d1::D1Database)#[cfg(not(target_arch = "wasm32"))]→rusqlite+tokio-rusqlite(native dev/test)
cargo test on the native host automatically selects the rusqlite path. No feature flags,
no wrangler dev required for integration tests. The design doc's "Query layer: SQLx" is
superseded by this approach.
This decision also resolves TRIAGE tickets a91260 and 2ab7a8 (workers-rs native test binaries):
the cfg(target_arch) split handles the test environment automatically.
src/bin/api/db/mod.rs—QuoteRepositoryasync trait + shared result typessrc/bin/api/db/d1.rs—D1Repositoryusing workers-rs D1 bindings (wasm32only)src/bin/api/db/native.rs—NativeRepositoryusingrusqlite/tokio-rusqlite(native only)src/bin/api/db/migrations.rs— SQL migration strings (CREATE TABLE IF NOT EXISTS)Cargo.toml— wire cfg-split dependencies for workers-rs and rusqlite
1. Cargo.toml dependency additions
# Dependencies always present (both targets)
async-trait = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# API only — WASM/Workers target
[target.'cfg(target_arch = "wasm32")'.dependencies]
worker = { version = "0.7", features = ["d1", "axum"] }
# API only — native target (local dev and cargo test)
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tokio = { version = "1", features = ["full"] }
axum = "0.8"
rusqlite = { version = "0.31", features = ["bundled"] }
tokio-rusqlite = "0.5"
rusqlite with features = ["bundled"] compiles SQLite in — no system SQLite dependency.
2. Module file layout
src/bin/api/
├── main.rs # cfg-split: workers-rs event handler OR native tokio::main
├── router.rs # build_router<R: QuoteRepository>(...) — shared for both targets
├── handlers/ # Axum route handlers — generic over repo type
└── db/
├── mod.rs # QuoteRepository trait + shared types (DbError, ListResult, etc.)
├── d1.rs # D1Repository — cfg(target_arch = "wasm32")
├── native.rs # NativeRepository — cfg(not(target_arch = "wasm32"))
└── migrations.rs # SQL migration strings
3. QuoteRepository trait (src/bin/api/db/mod.rs)
#[cfg(target_arch = "wasm32")]
mod d1;
#[cfg(not(target_arch = "wasm32"))]
mod native;
pub mod migrations;
#[cfg(target_arch = "wasm32")]
pub use d1::D1Repository;
#[cfg(not(target_arch = "wasm32"))]
pub use native::NativeRepository;
pub struct ListResult {
pub quotes: Vec<crate::Quote>,
pub page: u32,
pub total_pages: u32,
pub total_count: u32,
}
pub enum DeleteResult { Deleted, NotFound, Forbidden }
#[derive(Debug, thiserror::Error)]
pub enum DbError {
#[error("database error: {0}")]
Internal(String),
#[error("not found")]
NotFound,
#[error("forbidden")]
Forbidden,
}
/// Async repository interface for all quote operations.
///
/// `?Send` is required because `D1Database` wraps JS values and is not `Send`.
/// Both implementations satisfy this bound.
#[async_trait::async_trait(?Send)]
pub trait QuoteRepository {
/// Run CREATE TABLE IF NOT EXISTS migrations. Call once on startup.
async fn run_migrations(&self) -> Result<(), DbError>;
async fn list_quotes(
&self, page: u32, author: Option<&str>, tag: Option<&str>,
) -> Result<ListResult, DbError>;
async fn get_quote(&self, id: &str) -> Result<Option<crate::Quote>, DbError>;
async fn get_random_quote(&self) -> Result<Option<crate::Quote>, DbError>;
/// Creates a quote. Generates an auth_code if not provided.
/// Returns (quote, auth_code).
async fn create_quote(&self, input: crate::CreateQuoteInput) -> Result<(crate::Quote, String), DbError>;
async fn update_quote(
&self, id: &str, auth_code: &str, input: crate::UpdateQuoteInput,
) -> Result<Option<crate::Quote>, DbError>;
async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError>;
}
Note on ?Send: Axum's State<Arc<dyn QuoteRepository>> requires Send + Sync on native
(tokio multi-threaded). Since the trait is ?Send, use a concrete type alias instead of
a trait object:
// In router.rs — concrete type alias, no dyn needed
#[cfg(target_arch = "wasm32")]
pub type AppRepo = db::D1Repository;
#[cfg(not(target_arch = "wasm32"))]
pub type AppRepo = db::NativeRepository;
pub fn build_router(repo: Arc<AppRepo>) -> Router { ... }
This avoids the trait-object/Send complexity entirely. Handlers receive State<Arc<AppRepo>>.
4. D1Repository (src/bin/api/db/d1.rs)
// Only compiled for wasm32 — imports from worker crate are safe here
#![cfg(target_arch = "wasm32")]
use worker::d1::D1Database;
use super::{DbError, DeleteResult, ListResult, QuoteRepository};
pub struct D1Repository { db: D1Database }
impl D1Repository {
pub fn new(db: D1Database) -> Self { Self { db } }
}
#[async_trait::async_trait(?Send)]
impl QuoteRepository for D1Repository {
async fn run_migrations(&self) -> Result<(), DbError> {
self.db.prepare(super::migrations::CREATE_QUOTES)
.run().await.map_err(|e| DbError::Internal(e.to_string()))?;
self.db.prepare(super::migrations::CREATE_QUOTE_TAGS)
.run().await.map_err(|e| DbError::Internal(e.to_string()))?;
Ok(())
}
async fn get_quote(&self, id: &str) -> Result<Option<crate::Quote>, DbError> {
let row = self.db
.prepare("SELECT q.id, q.text, q.author, q.source, q.date, q.created_at, q.updated_at \
FROM quotes q WHERE q.id = ?1")
.bind(&[id.into()])
.map_err(|e| DbError::Internal(e.to_string()))?
.first::<serde_json::Value>(None).await
.map_err(|e| DbError::Internal(e.to_string()))?;
// Deserialize and attach tags via separate query
// ...
Ok(row.map(|v| serde_json::from_value(v).unwrap()))
}
// ... remaining methods
}
Tags require a separate query per quote (or a GROUP_CONCAT aggregation):
SELECT tag FROM quote_tags WHERE quote_id = ?1
5. NativeRepository (src/bin/api/db/native.rs)
// Only compiled for non-wasm32
#![cfg(not(target_arch = "wasm32"))]
use tokio_rusqlite::Connection;
use super::{DbError, DeleteResult, ListResult, QuoteRepository};
pub struct NativeRepository { conn: Connection }
impl NativeRepository {
pub async fn new(db_path: &str) -> Result<Self, DbError> {
let conn = Connection::open(db_path).await
.map_err(|e| DbError::Internal(e.to_string()))?;
Ok(Self { conn })
}
}
#[async_trait::async_trait(?Send)]
impl QuoteRepository for NativeRepository {
async fn run_migrations(&self) -> Result<(), DbError> {
self.conn.call(|conn| {
conn.execute_batch(&format!(
"PRAGMA foreign_keys = ON; {}; {};",
super::migrations::CREATE_QUOTES,
super::migrations::CREATE_QUOTE_TAGS,
))?;
Ok(())
}).await.map_err(|e| DbError::Internal(e.to_string()))
}
async fn get_quote(&self, id: &str) -> Result<Option<crate::Quote>, DbError> {
let id = id.to_string();
self.conn.call(move |conn| {
let mut stmt = conn.prepare(
"SELECT id, text, author, source, date, created_at, updated_at \
FROM quotes WHERE id = ?1")?;
// map_row to Quote struct, then fetch tags separately
// ...
Ok(None) // placeholder
}).await.map_err(|e| DbError::Internal(e.to_string()))
}
// ... remaining methods
}
6. Migrations (src/bin/api/db/migrations.rs)
/// Creates the quotes table if it does not already exist.
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 DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)";
/// Creates the quote_tags join table with cascade delete.
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)
)";
7. main.rs cfg-split entry points
// src/bin/api/main.rs
// ── WASM / Workers entry point ──────────────────────────────────────────────
#[cfg(target_arch = "wasm32")]
use worker::*;
#[cfg(target_arch = "wasm32")]
#[event(fetch)]
pub async fn main(req: Request, env: Env, ctx: Context) -> Result<Response> {
let db = env.d1("DB")?;
let repo = std::sync::Arc::new(db::D1Repository::new(db));
router::build_router(repo).call(req, env, ctx).await
}
// ── Native entry point (local dev + cargo test server) ──────────────────────
#[cfg(not(target_arch = "wasm32"))]
#[tokio::main]
async fn main() {
let db_path = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "./quotesdb.sqlite".to_string());
let repo = std::sync::Arc::new(
db::NativeRepository::new(&db_path).await.expect("failed to open DB")
);
repo.run_migrations().await.expect("failed to run migrations");
let app = router::build_router(repo);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
- `async_trait::async_trait(?Send)` required — `D1Database` wraps JS values and is NOT `Send`.
- Use concrete type alias (`AppRepo`) in handlers/router instead of `dyn QuoteRepository` to avoid
the Send + Sync trait object constraint on native Axum.
- `rusqlite` must use `features = ["bundled"]` — no system SQLite dependency.
- Tags are stored in a separate table; always fetch them with a second query per quote.
- The `auth_code` column must be included in DB SELECT for update/delete auth checks but
NEVER returned in public GET responses.
- Foreign keys must be explicitly enabled in rusqlite: `PRAGMA foreign_keys = ON`.
- `tokio-rusqlite` v0.5 uses `spawn_blocking` internally — safe to use from async handlers.
- Resolves TRIAGE: e8a330 (SQLx + workers-rs + D1 compatibility)
- Also resolves TRIAGE: a91260 (workers-rs native test binaries) and 2ab7a8 (test harness approach)
- Supersedes: a5049d (DB connection module — SQLx approach invalidated)
- Informs: 1f5bb5 (Cargo.toml — cfg-split deps), 6e829e (api main.rs — cfg-split entry point), 9b581f (test harness)
Use `superpowers:test-driven-development` — write `NativeRepository` tests before implementing query methods.
Use `superpowers:verification-before-completion` before closing.
Run in order from the `quotesdb/` directory:
cargo fmt
cargo check # verifies native build (rusqlite path)
cargo clippy
cargo test # tests use NativeRepository automatically
# Also verify WASM target compiles (workers-rs D1 path):
cargo check --target wasm32-unknown-unknown
`feat(quotesdb): implement QuoteRepository trait and cfg-split D1/rusqlite DB abstraction`