+++
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
- `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`