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.
vibed/quotesdb/.beans/quotesdb-9ghn--implement-db...

346 lines
12 KiB
Markdown

---
# quotesdb-9ghn
title: 'Implement DB abstraction: QuoteRepository trait + cfg-split D1/rusqlite impls'
status: completed
type: task
priority: critical
created_at: 2026-03-10T23:32:04Z
updated_at: 2026-03-10T23:32:04Z
---
<context>
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.
</context>
<goal>
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
</goal>
<implementation-plan>
## 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<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`)
```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<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:
```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<AppRepo>) -> Router { ... }
```
This avoids the trait-object/Send complexity entirely. Handlers receive `State<Arc<AppRepo>>`.
## 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<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):
```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<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`)
```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<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();
}
```
</implementation-plan>
<constraints>
- `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.
</constraints>
<related-tickets>
- 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)
</related-tickets>
<skills>
Use `superpowers:test-driven-development` — write `NativeRepository` tests before implementing query methods.
Use `superpowers:verification-before-completion` before closing.
</skills>
<validation>
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
```
</validation>
<commit>
`feat(quotesdb): implement QuoteRepository trait and cfg-split D1/rusqlite DB abstraction`
</commit>