+++ title = "Implement DB abstraction: QuoteRepository trait + cfg-split D1/rusqlite impls" priority = 8 status = "todo" ticket_type = "task" dependencies = [] +++ Resolution of TRIAGE ticket e8a330: **SQLx is NOT compatible with Cloudflare Workers/D1.** D1 is accessed through the workers-rs JavaScript binding layer, not a TCP connection. SQLx relies on TCP connections (Postgres, MySQL, SQLite file) and cannot work in the Workers runtime. **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. Implement the database abstraction layer for `quotesdb-api`: 1. **`src/bin/api/db/mod.rs`** — `QuoteRepository` async trait + shared result types 2. **`src/bin/api/db/d1.rs`** — `D1Repository` using workers-rs D1 bindings (`wasm32` only) 3. **`src/bin/api/db/native.rs`** — `NativeRepository` using `rusqlite`/`tokio-rusqlite` (native only) 4. **`src/bin/api/db/migrations.rs`** — SQL migration strings (`CREATE TABLE IF NOT EXISTS`) 5. **`Cargo.toml`** — wire cfg-split dependencies for workers-rs and rusqlite ## 1. Cargo.toml dependency additions ```toml # 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(...) — 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`) ```rust #[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, 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; async fn get_quote(&self, id: &str) -> Result, DbError>; async fn get_random_quote(&self) -> Result, 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, DbError>; async fn delete_quote(&self, id: &str, auth_code: &str) -> Result; } ``` **Note on `?Send`**: Axum's `State>` requires `Send + Sync` on native (tokio multi-threaded). Since the trait is `?Send`, use a concrete type alias instead of a trait object: ```rust // 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) -> Router { ... } ``` This avoids the trait-object/Send complexity entirely. Handlers receive `State>`. ## 4. D1Repository (`src/bin/api/db/d1.rs`) ```rust // 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, 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::(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): ```sql SELECT tag FROM quote_tags WHERE quote_id = ?1 ``` ## 5. NativeRepository (`src/bin/api/db/native.rs`) ```rust // 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 { 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, 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`) ```rust /// 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 ```rust // 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 { 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: ```sh 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`