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.
346 lines
12 KiB
Markdown
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>
|