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.

12 KiB

+++ title = "Implement DB abstraction: QuoteRepository trait + cfg-split D1/rusqlite impls" priority = 8 status = "done" 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.rsQuoteRepository async trait + shared result types
  2. src/bin/api/db/d1.rsD1Repository using workers-rs D1 bindings (wasm32 only)
  3. src/bin/api/db/native.rsNativeRepository 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

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