diff --git a/flake.lock b/flake.lock index 32e04e8..0197e99 100644 --- a/flake.lock +++ b/flake.lock @@ -51,11 +51,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1772173633, - "narHash": "sha256-MOH58F4AIbCkh6qlQcwMycyk5SWvsqnS/TCfnqDlpj4=", + "lastModified": 1772419343, + "narHash": "sha256-QU3Cd5DJH7dHyMnGEFfPcZDaCAsJQ6tUD+JuUsYqnKU=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "c0f3d81a7ddbc2b1332be0d8481a672b4f6004d6", + "rev": "93178f6a00c22fcdee1c6f5f9ab92f2072072ea9", "type": "github" }, "original": { diff --git a/quotesdb/.nbd/tickets/00aff0.md b/quotesdb/.nbd/tickets/00aff0.md new file mode 100644 index 0000000..4455782 --- /dev/null +++ b/quotesdb/.nbd/tickets/00aff0.md @@ -0,0 +1,343 @@ ++++ +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` + \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/00d6d7.md b/quotesdb/.nbd/tickets/00d6d7.md new file mode 100644 index 0000000..9247c7d --- /dev/null +++ b/quotesdb/.nbd/tickets/00d6d7.md @@ -0,0 +1,95 @@ ++++ +title = "Add Trunk proxy config to Trunk.toml: forward /api/* to local API server" +priority = 7 +status = "todo" +ticket_type = "task" +dependencies = ["a9534d"] ++++ + + +The `quotesdb` project uses Trunk to build and serve the Yew (Wasm) frontend. During `trunk serve`, the UI +runs on `localhost:8080` while the API runs separately on `localhost:3000`. Without a proxy, the browser +would make cross-origin requests from `:8080` to `:3000`, requiring CORS headers. + +Triage a9534d resolved this: use Trunk's built-in `[[proxy]]` to forward `/api/*` requests to the API server. +No CORS configuration is required anywhere — the proxy makes all API calls appear same-origin to the browser. + + + +**Chosen approach (triage a9534d):** Trunk proxy. + +Rationale: +- Mirrors the production architecture: in production, Cloudflare routes `/api/*` to the Worker at the same domain as the Pages site. +- Frontend uses relative URLs (`/api/quotes`, not `http://localhost:3000/api/quotes`) — the same URLs work in both dev and production without any configuration. +- Zero CORS configuration needed in the API or the frontend. Tower-http CORS middleware is not required. +- Standard, well-supported pattern for SPA development with a separate API backend. + + + +Add a `[[proxy]]` section to `Trunk.toml` in the `quotesdb/` root: + +```toml +[build] +target = "index.html" + +[[proxy]] +rewrite = "/api" +backend = "http://localhost:3000" +``` + +This configuration means: +- Requests from the browser to `http://localhost:8080/api/quotes` are forwarded to `http://localhost:3000/api/quotes`. +- The `/api` prefix is preserved in the forwarded URL (Trunk appends the matched path to `backend`). +- `trunk serve` handles the proxying automatically — no manual setup required by developers. +- The API server port `3000` matches the plain Axum `cargo run` dev server (see ticket 6e829e). + + + +Local development workflow after this change: + +```sh +# Terminal 1 — start the API server +cd quotesdb +cargo run + +# Terminal 2 — start the UI dev server with proxy +cd quotesdb +trunk serve +# Browser opens at http://localhost:8080 +# API calls go to /api/* (proxied transparently to localhost:3000) +``` + +No environment variables, no hardcoded URLs, no CORS headers needed. + + + +In production (Cloudflare Pages + Workers), the same `/api/*` path prefix is used. Cloudflare +can route `example.com/api/*` to the Worker and `example.com/*` to Pages via a Custom Domain +or a Worker route rule. This is configured in infra/. The frontend code does not change. + + + +- API port must be `3000` — this must be consistent with however ticket 6e829e configures the local Axum server. +- If the API port changes, update `Trunk.toml` accordingly and document the change. +- Do not use `trunk.serve.proxy` (legacy format) — use `[[proxy]]` table array format. +- This ticket's change is 3 lines in `Trunk.toml`. Keep it minimal. + + + +From the `quotesdb/` directory (requires `cargo run` running in another terminal): + +```sh +trunk serve & +curl http://localhost:8080/api/quotes # should proxy to http://localhost:3000/api/quotes +``` + +At minimum, verify `trunk build` succeeds: + +```sh +trunk build +``` + + + +`chore(quotesdb): add Trunk proxy config to forward /api/* to local API server` + \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/03bb91.md b/quotesdb/.nbd/tickets/03bb91.md index 8773597..a13b2d9 100644 --- a/quotesdb/.nbd/tickets/03bb91.md +++ b/quotesdb/.nbd/tickets/03bb91.md @@ -3,7 +3,7 @@ title = "Implement 4-word passphrase auth_code generator (must work in WASM/work priority = 7 status = "todo" ticket_type = "task" -dependencies = ["f3dc74", "1f5bb5", "6ed325"] +dependencies = ["1f5bb5", "6ed325"] +++ @@ -11,21 +11,138 @@ The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`. -Auth codes are 4-word passphrases (e.g. `ocean-table-purple-storm`) assigned to quotes on creation. They are stored plaintext and used to authorise updates and deletes. The generator must compile and run in both the native host environment and the `wasm32-unknown-unknown` target (workers-rs). +Auth codes are 4-word passphrases (e.g. `ocean-table-purple-storm`) assigned to quotes on creation. They are stored plaintext and used to authorise updates and deletes. + +**TRIAGE 6ed325 resolved:** Use a custom embedded word list (EFF Short Word List 1) with `rand::rngs::OsRng` from `rand 0.10`. OsRng does not use thread-local storage and is safe on wasm32. Entropy on WASM comes from `getrandom 0.4` with the `wasm_js` feature, which calls `crypto.getRandomValues()` — available in both browsers and Cloudflare Workers. -Implement a `generate_auth_code() -> String` function in `src/lib.rs` that produces a random 4-word passphrase. Place it in shared lib code so both the API (generation) and UI (display) can reference the type. The chosen word list crate must support `no_std` or at minimum compile for `wasm32-unknown-unknown`. +Implement a `generate_auth_code() -> String` function in `src/lib.rs` that produces a random 4-word passphrase. Place it in shared lib code so both the API (generation) and UI (display) can reference it. + + +## 1. Cargo.toml changes (covered by ticket 1f5bb5, listed here for reference) + +```toml +[dependencies] +rand = "0.10" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +# Enables OsRng entropy via Web Crypto API (crypto.getRandomValues()) +# Required by both rand (OsRng) and uuid (v4) on wasm32 targets +getrandom = { version = "0.4", features = ["wasm_js"] } +``` + +## 2. Embed the EFF Short Word List 1 in src/lib.rs + +The EFF Short Word List 1 contains 1296 common English words designed for memorable passphrases. +Source: https://www.eff.org/files/2016/09/08/eff_short_wordlist_1.txt + +Generate the Rust const array (run from shell, paste output into src/lib.rs): + +```sh +curl -s 'https://www.eff.org/files/2016/09/08/eff_short_wordlist_1.txt' \ + | awk '{print $2}' \ + | awk 'NR % 8 == 1 {printf " "} {printf "\"%s\", ", $0} NR % 8 == 0 {print ""}' \ + | sed '$a\' | tr -d '\n' | sed 's/, $//' +``` + +Place the word list as a module-level constant: + +```rust +/// EFF Short Word List 1 — 1296 common English words designed for memorable passphrases. +/// Source: +const WORDS: &[&str] = &[ + "acid", "acorn", "acre", "acts", "afar", "affix", "aged", "agent", + "agile", "aging", "agony", "ahead", "aide", "aids", "aim", "ajar", + // ... (full 1296-word list, generated via shell command above) +]; +``` + +## 3. Implement generate_auth_code() in src/lib.rs + +```rust +use rand::rngs::OsRng; +use rand::seq::SliceRandom; + +/// Generates a random 4-word passphrase in the format `word-word-word-word`. +/// +/// Words are drawn from the EFF Short Word List 1 (1296 common English words). +/// The passphrase is used as an `auth_code` to authorize quote edits and deletes. +/// +/// Uses `rand::rngs::OsRng` for entropy, which is safe on both native and +/// `wasm32-unknown-unknown` targets. On WASM (Cloudflare Workers), entropy +/// is sourced via `crypto.getRandomValues()` through `getrandom`'s `wasm_js` feature. +/// +/// # Examples +/// +/// ``` +/// let code = quotesdb::generate_auth_code(); +/// let words: Vec<&str> = code.split('-').collect(); +/// assert_eq!(words.len(), 4); +/// assert!(words.iter().all(|w| !w.is_empty())); +/// ``` +pub fn generate_auth_code() -> String { + WORDS + .choose_multiple(&mut OsRng, 4) + .copied() + .collect::>() + .join("-") +} +``` + +## 4. Unit tests (src/lib.rs tests module or src/tests.rs) + +```rust +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + /// Verify format: exactly 4 non-empty words from WORDS, joined by hyphens. + #[test] + fn auth_code_has_four_valid_words() { + for _ in 0..100 { + let code = generate_auth_code(); + let parts: Vec<&str> = code.split('-').collect(); + assert_eq!(parts.len(), 4, "expected 4 words, got: {code}"); + for word in &parts { + assert!(!word.is_empty(), "empty word in code: {code}"); + assert!( + WORDS.contains(word), + "word '{word}' not in word list, code: {code}" + ); + } + } + } + + /// Verify randomness: 20 samples should produce at least 10 distinct codes. + #[test] + fn auth_codes_are_varied() { + let codes: HashSet = (0..20).map(|_| generate_auth_code()).collect(); + assert!( + codes.len() > 10, + "expected >10 unique codes in 20 samples, got {}", + codes.len() + ); + } +} +``` + + + -- Resolve TRIAGE ticket 6ed325 (passphrase crate selection) before choosing the dependency. -- Must compile for both host (`cargo check`) and `wasm32` (`trunk build`). -- Do not use `std::fs` or thread-based RNG in shared code — use a WASM-compatible RNG (e.g. `getrandom` with the `js` feature). +- `generate_auth_code()` must live in `src/lib.rs` (shared code, not bin-specific) +- Use `rand::rngs::OsRng` — do NOT use `rand::thread_rng()` (thread-local, unsafe on WASM) +- Do not use `std::fs`, thread-based RNG, or any crate that requires file-system access +- All public items must have rustdoc comments with doc-examples (per project style) +- `getrandom = { version = "0.4", features = ["wasm_js"] }` must be in the wasm32 cfg section only, + not in `[dependencies]`, to avoid pulling wasm-bindgen into native builds -Use `superpowers:test-driven-development` — write a unit test that generates 100 codes and verifies each matches `word-word-word-word` format. +Use `superpowers:test-driven-development` — write the unit tests (step 4) before implementing (step 3). Use `superpowers:verification-before-completion` before closing. diff --git a/quotesdb/.nbd/tickets/04f865.md b/quotesdb/.nbd/tickets/04f865.md index f1db407..440d8bb 100644 --- a/quotesdb/.nbd/tickets/04f865.md +++ b/quotesdb/.nbd/tickets/04f865.md @@ -3,7 +3,7 @@ title = "Implement ui/src/main.rs — Yew app shell, BrowserRouter, route defini priority = 8 status = "todo" ticket_type = "task" -dependencies = ["c3503b", "93515e", "dc3d2b"] +dependencies = ["93515e", "dc3d2b", "0fbdd5"] +++ diff --git a/quotesdb/.nbd/tickets/05f8ae.md b/quotesdb/.nbd/tickets/05f8ae.md index c5b2f75..9629cdc 100644 --- a/quotesdb/.nbd/tickets/05f8ae.md +++ b/quotesdb/.nbd/tickets/05f8ae.md @@ -1,9 +1,9 @@ +++ -title = "Implement PUT /api/quotes — create quote, generate NanoID, generate auth_code if not provided, return 201 with auth_code" +title = "Implement PUT /api/quotes — create quote, generate UUID v4 ID, generate auth_code if not provided, return 201 with auth_code" priority = 5 status = "todo" ticket_type = "task" -dependencies = ["f3dc74", "a5049d", "d792e2", "03bb91", "175382", "6f2e18"] +dependencies = ["a5049d", "d792e2", "03bb91", "175382", "7a0d9f"] +++ @@ -20,7 +20,7 @@ Response 201: `{ quote: {...}, auth_code: "word-word-word-word" }` Implement the `PUT /api/quotes` handler: 1. Deserialise and validate the request body (text and author are required) -2. Generate a NanoID for the quote ID +2. Generate a UUID v4 ID for the quote by calling `generate_id()` from `src/lib.rs` 3. Generate an auth_code if not provided in the request 4. INSERT the quote into the `quotes` table 5. INSERT any tags into `quote_tags` @@ -29,7 +29,7 @@ Implement the `PUT /api/quotes` handler: - Return 422 if `text` or `author` is missing or empty. -- NanoID generation must be WASM-compatible (see TRIAGE ticket 6f2e18). +- Use `generate_id()` from `src/lib.rs` for the quote ID — returns a UUID v4 string (36 chars). TRIAGE 6f2e18 resolved this: nanoid is not WASM-safe; uuid v4 is used instead. See ticket 7a0d9f. - Use the shared `generate_auth_code()` function from `src/lib.rs`. - Tag insertion must use the shared `replace_tags_for_quote()` logic (ticket 175382). @@ -51,5 +51,5 @@ cargo test -`feat(quotesdb): implement PUT /api/quotes — create quote with NanoID and auth_code` +`feat(quotesdb): implement PUT /api/quotes — create quote with UUID v4 and auth_code` diff --git a/quotesdb/.nbd/tickets/07cafb.md b/quotesdb/.nbd/tickets/07cafb.md index f7e7c81..e20b86f 100644 --- a/quotesdb/.nbd/tickets/07cafb.md +++ b/quotesdb/.nbd/tickets/07cafb.md @@ -1,9 +1,9 @@ +++ title = "[TRIAGE] D1 binding chicken-and-egg — D1 ID not known until after apply, but Worker needs it at plan time" priority = 8 -status = "todo" +status = "done" ticket_type = "task" -dependencies = ["25c413"] +dependencies = [] +++ @@ -20,6 +20,43 @@ D1 binding chicken-and-egg: the D1 database ID is not known until after `tofu ap 3. **OpenTofu `depends_on`** — express the dependency explicitly and let OpenTofu plan the two resources in the correct order. May work if the Cloudflare provider handles the reference correctly. + +**Option 3 (attribute reference) — and there is no chicken-and-egg problem.** + +This is a common Terraform/OpenTofu misconception. Writing `database_id = cloudflare_d1_database.db.id` in the Worker resource creates an **implicit dependency** via the attribute reference. OpenTofu: +1. Sees that `cloudflare_workers_script.api` depends on `cloudflare_d1_database.db` (via the `.id` reference) +2. Plans D1 creation first; shows Worker `database_id` as `(known after apply)` — this is **expected and correct** +3. During `tofu apply`: creates D1 first → gets its ID from state → creates Worker with that ID + +No two-phase apply, no `data` source, no explicit `depends_on`. A single `tofu apply` provisions both resources in the correct order. + +Confirmed from Cloudflare provider v4 source: +- D1 resource: `cloudflare_d1_database` — outputs `id` (String) +- Worker resource: `cloudflare_workers_script` (plural) — `d1_database_binding` block with `database_id` and `name` fields +- This also confirms the answer to TRIAGE efee79: resource name is `cloudflare_workers_script` + +Concrete HCL: +```hcl +resource "cloudflare_d1_database" "db" { + account_id = var.cloudflare_account_id + name = "quotesdb" +} + +resource "cloudflare_workers_script" "api" { + account_id = var.cloudflare_account_id + name = "quotesdb-api" + content = filebase64("../target/wasm32-unknown-unknown/release/api.wasm") + + d1_database_binding { + name = "DB" + database_id = cloudflare_d1_database.db.id # (known after apply) — resolved automatically + } +} +``` + +API Worker CI/CD deploy ticket: 57fe5e + + 1. Research the options above and choose the best approach for this project. 2. Update the `infra/worker.tf` and `infra/d1.tf` resources with the chosen approach. Update ticket a23489 and d0da0b with any constraints. @@ -27,5 +64,5 @@ D1 binding chicken-and-egg: the D1 database ID is not known until after `tofu ap -`chore(quotesdb): resolve triage — d1-binding-chickenandegg-d1-id-not-known-until-after-apply-b` +`chore(quotesdb): resolve triage — d1-binding-standard-attribute-reference-no-chicken-and-egg` diff --git a/quotesdb/.nbd/tickets/07feaa.md b/quotesdb/.nbd/tickets/07feaa.md index a007a16..8dc9d00 100644 --- a/quotesdb/.nbd/tickets/07feaa.md +++ b/quotesdb/.nbd/tickets/07feaa.md @@ -1,9 +1,9 @@ +++ title = "[TRIAGE] OpenTofu state backend — local file (gitignored) vs Terraform Cloud vs Cloudflare R2?" priority = 9 -status = "todo" +status = "done" ticket_type = "task" -dependencies = ["25c413"] +dependencies = [] +++ @@ -21,9 +21,19 @@ OpenTofu state backend: should the `.tfstate` file be stored locally (gitignored -1. Research the options above and choose the best approach for this project. -2. Set the chosen backend in `infra/terraform.tf`. Update `infra/.gitignore` if using local state. Document the decision in `infra/README.md`. -3. Mark this ticket done with a note on the chosen approach in the body or a comment. +**Decision: Local file backend (gitignored).** + +Rationale: +- `quotesdb` is a solo developer project — no team, no CI/CD pipeline that needs shared state. +- Infrastructure is small (1 Worker + 1 D1 + 1 Pages project). If state is lost, all resources can be recovered via `tofu import`. +- Terraform Cloud: unnecessary HashiCorp account dependency with no benefit for solo use. +- Cloudflare R2: chicken-and-egg problem — the R2 bucket itself must be manually bootstrapped before it can serve as the OpenTofu backend, adding setup complexity and extra credential scope. +- Local file: zero extra accounts, zero extra credentials, immediate to set up. + +Implementation (see ticket 2d1371): +- `infra/terraform.tf`: use default local backend (no `backend` block needed — local is the OpenTofu default). +- `infra/.gitignore`: ignore `*.tfstate`, `*.tfstate.backup`, `.terraform/`, `.terraform.lock.hcl`. +- `infra/README.md`: document that state is local and how to recover with `tofu import` if lost. diff --git a/quotesdb/.nbd/tickets/08af7a.md b/quotesdb/.nbd/tickets/08af7a.md index 3b39c45..17123c6 100644 --- a/quotesdb/.nbd/tickets/08af7a.md +++ b/quotesdb/.nbd/tickets/08af7a.md @@ -3,7 +3,7 @@ title = "Write api/README.md, api/docs/PLANNING.md, api/docs/ARCHITECTURE.md" priority = 3 status = "todo" ticket_type = "task" -dependencies = ["f3dc74", "a6bce1"] +dependencies = ["a6bce1"] +++ diff --git a/quotesdb/.nbd/tickets/0bc655.md b/quotesdb/.nbd/tickets/0bc655.md index fb14d5c..8a9ce3c 100644 --- a/quotesdb/.nbd/tickets/0bc655.md +++ b/quotesdb/.nbd/tickets/0bc655.md @@ -1,9 +1,9 @@ +++ title = "[TRIAGE] Auth code storage strategy — localStorage persistence vs component-only state?" priority = 7 -status = "todo" +status = "done" ticket_type = "task" -dependencies = ["c3503b"] +dependencies = [] +++ @@ -21,9 +21,33 @@ Auth code storage strategy for the UI: should the auth code be stored in localSt -1. Research the options above and choose the best approach for this project. -2. Update the `AuthModal` component (ticket f850c6) with the chosen strategy. If localStorage is chosen, implement a clear-on-delete path. -3. Mark this ticket done with a note on the chosen approach in the body or a comment. +**Chosen approach: Option 3 — session storage per quote ID.** + +The auth code is stored in `sessionStorage` under the key `auth_code_{quote_id}`. It is +automatically cleared when the browser tab closes. No manual clear-on-delete is strictly +required, but is implemented as good practice (after a successful DELETE, the code is no +longer useful and should not linger). + +Why not localStorage (option 2): the app explicitly tells users to store their auth code +externally ("Store this — it cannot be recovered later"). localStorage is indefinite and +has a wider XSS exposure window; session storage provides the same in-session convenience +without the long-term risk. + +Why not component state (option 1): the code would be lost on every page navigation or +reload, making the edit/delete flow frustrating in practice. + +Session storage covers the primary use case — "I just created this quote and want to edit +it" — without adding unnecessary persistence complexity. + +Implementation ticket: **5379eb** — creates `src/bin/ui/storage.rs` with `get_auth_code`, +`set_auth_code`, `clear_auth_code` utilities wrapping `web_sys::window().session_storage()`, +plus the `initial_value: Option` prop addition to `AuthModal` and the parent-component +integration pattern (read on modal open, write on success, clear on 403 or DELETE). + +Tickets updated: +- **f850c6** (AuthModal): triage dependency replaced with 5379eb; goal updated with + `initial_value` prop; constraints updated with resolved storage approach. +- **c3503b** (UI sub-project): 0bc655 removed, 5379eb added. diff --git a/quotesdb/.nbd/tickets/0d84fa.md b/quotesdb/.nbd/tickets/0d84fa.md index 95eef9f..0b80486 100644 --- a/quotesdb/.nbd/tickets/0d84fa.md +++ b/quotesdb/.nbd/tickets/0d84fa.md @@ -1,9 +1,9 @@ +++ title = "[TRIAGE] HTTP client selection for integration tests — reqwest vs hyper vs ureq (tokio vs blocking)" priority = 7 -status = "todo" +status = "done" ticket_type = "task" -dependencies = ["ce1e4f"] +dependencies = [] +++ @@ -21,9 +21,22 @@ HTTP client for integration tests: should we use reqwest (async, tokio), hyper ( -1. Research the options above and choose the best approach for this project. -2. Add the chosen crate to `[dev-dependencies]` in `Cargo.toml`. Update ticket 5f5ba0. -3. Mark this ticket done with a note on the chosen approach in the body or a comment. +**Chosen approach: Option 1 — `reqwest` with `#[tokio::test]`.** + +The API server is Axum + Tokio. `reqwest` is the idiomatic async HTTP client in this stack: +- `#[tokio::test]` + `reqwest` is the standard Rust integration-testing pattern for Axum services. +- `features = ["json"]` enables ergonomic `.json()` request bodies and `.json::()` response + deserialization — essential for testing JSON API endpoints. +- Dev-dependency only: the weight of the crate does not affect the production binary size. + +Options 2 (hyper) and 3 (ureq) were ruled out: +- hyper 1.x has a complex, low-level API that adds boilerplate with no test-writing benefit. +- ureq is synchronous; using it with an async Axum server would require spawning a background + thread for the server in every test, adding avoidable setup complexity. + +Implementation ticket **5f5ba0** (already exists and is correctly specified) captures all +necessary work: adds `reqwest = { version = "0.12", features = ["json"] }`, `tokio`, `serde_json`, +and `tempfile` to `[dev-dependencies]` in `Cargo.toml`. No new ticket is required. diff --git a/quotesdb/.nbd/tickets/0d987f.md b/quotesdb/.nbd/tickets/0d987f.md index 06ebf19..030abe2 100644 --- a/quotesdb/.nbd/tickets/0d987f.md +++ b/quotesdb/.nbd/tickets/0d987f.md @@ -3,7 +3,7 @@ title = "Implement shared QuoteCard component — displays text, author, source, priority = 5 status = "todo" ticket_type = "task" -dependencies = ["c3503b", "93515e"] +dependencies = ["93515e", "0fbdd5"] +++ diff --git a/quotesdb/.nbd/tickets/0fbdd5.md b/quotesdb/.nbd/tickets/0fbdd5.md new file mode 100644 index 0000000..7ada38f --- /dev/null +++ b/quotesdb/.nbd/tickets/0fbdd5.md @@ -0,0 +1,89 @@ ++++ +title = "Write src/bin/ui/style.css — full stylesheet for all UI pages and components" +priority = 6 +status = "todo" +ticket_type = "task" +dependencies = ["dc3d2b"] ++++ + + +CSS approach resolved in triage 5e3e37: **plain CSS** — a single `src/bin/ui/style.css` file linked from `index.html` via ``. + +All UI component tickets use BEM-style class names defined in this file. No CSS-in-Rust crates, no CDN Tailwind. + + + +Write `src/bin/ui/style.css` covering all pages and components in the Yew UI. + + + +BEM-style semantic class names. Blocks and elements use lowercase hyphenated names. + +| Component / Page | Block class | Notable element classes | +|---|---|---| +| Global | `body`, `main`, `nav` | — | +| Navigation | `nav` | `nav__link`, `nav__brand` | +| QuoteCard | `quote-card` | `quote-card__text`, `quote-card__author`, `quote-card__meta`, `quote-card__tags`, `quote-card__tag` | +| Home page | `page-home` | `page-home__random`, `page-home__cta` | +| Browse page | `page-browse` | `page-browse__filters`, `page-browse__list` | +| Quote detail page | `page-quote` | `page-quote__actions` | +| Author page | `page-author` | `page-author__header` | +| Submit page | `page-submit` | `page-submit__form`, `page-submit__success` | +| Pagination | `pagination` | `pagination__btn`, `pagination__info` | +| Tag filter | `tag-filter` | `tag-filter__input`, `tag-filter__list`, `tag-filter__tag` | +| Auth modal | `auth-modal` | `auth-modal__overlay`, `auth-modal__dialog`, `auth-modal__input`, `auth-modal__actions` | +| Error display | `error-display` | `error-display__message` | +| Form elements | `form` | `form__field`, `form__label`, `form__input`, `form__textarea`, `form__error` | +| Buttons | `btn` | `btn--primary`, `btn--secondary`, `btn--danger` | +| Auth code reveal | `auth-reveal` | `auth-reveal__code`, `auth-reveal__note` | +| Loading | `loading` | — | +| Empty state | `empty-state` | `empty-state__message` | + + + + +- Clean, minimal typography-focused design appropriate for a quotes site. +- Readable body font (system-ui or serif stack for quote text). +- Max-width container centered on page: ~720px for readability. +- Accessible colour contrast (WCAG AA minimum). +- Responsive: readable on mobile without horizontal scroll. +- No external font imports — use system fonts. +- Light theme only (no dark mode required). + + + +In Yew components, use class names as string literals: + +```rust +html! { +
+
{ "e.text }
+ { "e.author } +
+} +``` + +For conditional classes use the `classes!` macro: + +```rust +html! { + +} +``` +
+ + +After writing the CSS, verify it is picked up by Trunk: + +```sh +trunk build +``` + +Inspect the generated `dist/` directory to confirm the CSS file is bundled. + + + +`style(quotesdb): add UI stylesheet with BEM component classes` + diff --git a/quotesdb/.nbd/tickets/166996.md b/quotesdb/.nbd/tickets/166996.md index 4e3714b..7ea2ed2 100644 --- a/quotesdb/.nbd/tickets/166996.md +++ b/quotesdb/.nbd/tickets/166996.md @@ -1,11 +1,10 @@ +++ title = "[TRIAGE] Yew version selection and yew-router compatibility (0.21+?)" priority = 9 -status = "todo" +status = "done" ticket_type = "task" -dependencies = ["c3503b"] +dependencies = [] +++ - This is a triage decision ticket. It must be resolved before dependent implementation tickets can proceed. @@ -14,18 +13,20 @@ This is a triage decision ticket. It must be resolved before dependent implement Yew version selection: which version of Yew and yew-router should be used, and are they compatible with each other and the Nix dev shell? - -1. **Yew 0.21 + yew-router 0.18** — latest stable as of early 2026. Check crates.io for current versions. -2. **Yew 0.20** — previous stable, more documentation available. -3. **Check nixpkgs** — the Nix dev shell may pin a specific version via rust-overlay. - - -1. Research the options above and choose the best approach for this project. -2. Pin the chosen versions in `Cargo.toml`. Update ticket 93515e. Document the version in `docs/ARCHITECTURE.md`. -3. Mark this ticket done with a note on the chosen approach in the body or a comment. +**Decision: yew = "0.22", yew-router = "0.19"** + +Research findings (2026-03-02): +- yew latest stable: 0.22.1 +- yew-router latest stable: 0.19.0 +- yew-router 0.19 requires `yew ^0.22.0` — confirmed compatible +- Both crates use `wasm-bindgen ^0.2`, compatible with `wasm-bindgen-cli 0.2.108` in the Nix dev shell + +Actions taken: +- Updated ticket 93515e with explicit version constraints and serde placement guidance +- Documented chosen versions in `docs/ARCHITECTURE.md` under "Key Dependency Versions" `chore(quotesdb): resolve triage — yew-version-selection-and-yewrouter-compatibility-021` - +
\ No newline at end of file diff --git a/quotesdb/.nbd/tickets/175382.md b/quotesdb/.nbd/tickets/175382.md index a5508f6..18067fd 100644 --- a/quotesdb/.nbd/tickets/175382.md +++ b/quotesdb/.nbd/tickets/175382.md @@ -3,7 +3,7 @@ title = "Implement tag join logic — fetch tags per quote, insert/replace tags priority = 5 status = "todo" ticket_type = "task" -dependencies = ["f3dc74", "a5049d"] +dependencies = ["a5049d"] +++ diff --git a/quotesdb/.nbd/tickets/182210.md b/quotesdb/.nbd/tickets/182210.md index 907287d..e0d1910 100644 --- a/quotesdb/.nbd/tickets/182210.md +++ b/quotesdb/.nbd/tickets/182210.md @@ -1,9 +1,9 @@ +++ title = "[TRIAGE] Cloudflare Workers WASM size limit — free tier 1MB limit may require paid plan for Rust binary" priority = 9 -status = "todo" +status = "done" ticket_type = "task" -dependencies = ["f3dc74"] +dependencies = [] +++ @@ -20,11 +20,25 @@ Cloudflare Workers WASM size limit: the free tier has a 1MB Worker script size l 3. **Split the Worker** — serve static assets from Pages and keep the Worker API-only (fewer dependencies). - -1. Research the options above and choose the best approach for this project. -2. Check the compiled `api` binary size with `trunk build --release` and `ls -lh`. Update the infra plan accordingly. -3. Mark this ticket done with a note on the chosen approach in the body or a comment. - + +**The 1 MB limit in this ticket is outdated.** The current Cloudflare Workers limits (as of 2026) are: +- Workers Free: **3 MB after gzip compression**, 64 MB before compression +- Workers Paid: **10 MB after gzip**, 64 MB before compression + +**Chosen approach: Free tier + binary size optimisation (no paid plan required).** + +Rationale: +- The API Worker only handles API routes — no Yew/UI code is bundled into it (UI is on Pages). +- The database layer uses `workers-rs` native D1 bindings (not SQLx) per ticket e8a330 — this eliminates a heavy dependency. +- The `Cargo.toml` release profile already has `opt-level = "z"`, `lto = true`, `strip = true`, `codegen-units = 1`. +- `wrangler` applies `wasm-opt` automatically during deploy, further reducing WASM size. +- A simple CRUD API with these optimisations gzips well under 3 MB. +- Verification command: `wrangler deploy --outdir bundled/ --dry-run` (shows `gzip: X KiB`). + +**Implementation ticket created: see ticket for binary size verification after Cargo.toml dependencies are added (1f5bb5).** + +If the binary somehow exceeds 3 MB, fallback options are: further dependency pruning, or Workers Paid at $5/month. + `chore(quotesdb): resolve triage — cloudflare-workers-wasm-size-limit-free-tier-1mb-limit-may-r` diff --git a/quotesdb/.nbd/tickets/1a274d.md b/quotesdb/.nbd/tickets/1a274d.md index 1942b1f..38526b1 100644 --- a/quotesdb/.nbd/tickets/1a274d.md +++ b/quotesdb/.nbd/tickets/1a274d.md @@ -3,7 +3,7 @@ title = "Implement Home page (/) — fetch and display random quote, 'Browse all priority = 5 status = "todo" ticket_type = "task" -dependencies = ["c3503b", "04f865", "1e6a09", "0d987f", "fc2f51"] +dependencies = ["04f865", "1e6a09", "0d987f", "fc2f51", "0fbdd5"] +++ diff --git a/quotesdb/.nbd/tickets/1ba523.md b/quotesdb/.nbd/tickets/1ba523.md index be433a8..cdf4858 100644 --- a/quotesdb/.nbd/tickets/1ba523.md +++ b/quotesdb/.nbd/tickets/1ba523.md @@ -3,7 +3,7 @@ title = "Implement Submit page (/submit) — quote creation form, display return priority = 5 status = "todo" ticket_type = "task" -dependencies = ["c3503b", "04f865", "1e6a09", "fc2f51"] +dependencies = ["04f865", "1e6a09", "fc2f51", "0fbdd5"] +++ diff --git a/quotesdb/.nbd/tickets/1e6a09.md b/quotesdb/.nbd/tickets/1e6a09.md index f8070a0..f78ad1c 100644 --- a/quotesdb/.nbd/tickets/1e6a09.md +++ b/quotesdb/.nbd/tickets/1e6a09.md @@ -3,7 +3,7 @@ title = "Implement API client module — typed fetch wrappers for all quotesdb-a priority = 7 status = "todo" ticket_type = "task" -dependencies = ["c3503b", "93515e"] +dependencies = ["93515e"] +++ @@ -24,11 +24,21 @@ Implement `src/bin/ui/api.rs` (or `src/bin/ui/client.rs`) with async functions f Each function sets the appropriate headers (including `X-Auth-Code` where needed) and deserialises the response.
+ +CORS/proxy resolved in triage a9534d: **Trunk proxy, relative URLs**. + +- Use **relative URLs** (`/api/quotes`, `/api/quotes/random`, etc.) — no base URL configuration needed. +- In local dev, Trunk's `[[proxy]]` in `Trunk.toml` forwards `/api/*` to `localhost:3000` transparently. +- In production, Cloudflare routes `/api/*` to the Worker at the same domain. +- Do NOT use `window.location.origin` as a base URL — relative paths work everywhere. +- Do NOT add any CORS headers in the frontend — no cross-origin requests occur. + + -- Use `gloo::net::http` or `web_sys::fetch` for HTTP requests (not reqwest — not available in WASM). -- Resolve TRIAGE ticket a9534d (CORS and Trunk proxy config) — during `trunk serve`, the API URL may differ. +- Use `gloo::net::http` for HTTP requests (not reqwest — not available in WASM). +- All API paths are relative: `/api/quotes`, `/api/quotes/{id}`, `/api/quotes/random`. - All functions must be `async` and return `Result` with a meaningful error type. -- The base URL should be configurable (env var at compile time or from `window.location.origin`). +- Do NOT configure a base URL — relative URLs are sufficient and correct. @@ -41,4 +51,4 @@ trunk build `feat(quotesdb): implement typed API client module for all quotesdb-api endpoints` - + \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/1f5bb5.md b/quotesdb/.nbd/tickets/1f5bb5.md index 9775fcd..adfd191 100644 --- a/quotesdb/.nbd/tickets/1f5bb5.md +++ b/quotesdb/.nbd/tickets/1f5bb5.md @@ -1,9 +1,9 @@ +++ -title = "Set up api/Cargo.toml with all crate dependencies (axum, tokio, workers-rs, sqlx, serde, nanoid, etc.)" +title = "Set up Cargo.toml with all crate dependencies (axum, tokio, workers-rs, rusqlite, serde, uuid, etc.)" priority = 8 status = "todo" ticket_type = "task" -dependencies = ["f3dc74", "a91260"] +dependencies = ["7a0d9f"] +++ @@ -17,10 +17,20 @@ Create `Cargo.toml` for the `quotesdb` crate with all API-side dependencies. Inc -- `workers-rs` and Axum are API-only — gate them under `[target.'cfg(not(target_arch = "wasm32"))'.dependencies]` +- `workers-rs` (`worker` crate) is WASM/Workers-only — gate under `[target.'cfg(target_arch = "wasm32")'.dependencies]` +- `tokio`, `axum`, `rusqlite`, `tokio-rusqlite` are native-only — gate under `[target.'cfg(not(target_arch = "wasm32"))'.dependencies]` - Yew, wasm-bindgen, and web-sys are UI-only — gate under `[target.'cfg(target_arch = "wasm32")'.dependencies]` + (The single-crate structure means API-WASM and UI-WASM deps share the same cfg section — use + separate feature flags or bin-specific cfg if they conflict) +- Do NOT include `sqlx` — it is incompatible with the Workers target (TRIAGE e8a330 resolved) - The `[profile.release]` block must set `opt-level = "z"`, `lto = true`, `strip = true`, `codegen-units = 1` -- Resolve TRIAGE tickets 6ed325 (passphrase crate) and 6f2e18 (NanoID crate) before finalising those dependency choices +- **ID generation (TRIAGE 6f2e18 resolved):** Use `uuid = { version = "1", features = ["v4", "serde"] }` in `[dependencies]`. See ticket 7a0d9f. +- **Passphrase generation (TRIAGE 6ed325 resolved):** Use `rand = "0.10"` in `[dependencies]`. Use `rand::rngs::OsRng` (not `thread_rng`). See ticket 03bb91. +- **WASM entropy (both ID + passphrase):** Add `getrandom = { version = "0.4", features = ["wasm_js"] }` under `[target.'cfg(target_arch = "wasm32")'.dependencies]`. This is required by both `uuid` (v4 feature) and `rand` (OsRng) on wasm32. The `wasm_js` feature (renamed from `js` in getrandom 0.2) enables `crypto.getRandomValues()` for Cloudflare Workers and browsers. Do NOT use getrandom 0.2 or the old `js` feature name. +- See ticket 00aff0 for the full list of DB-related dependencies (rusqlite, tokio-rusqlite, async-trait) +- **OpenAPI spec (TRIAGE 2ec8b1 resolved):** Add a `[build-dependencies]` section with + `serde_json = "1"` and `serde_yaml = "0.9"`. These are used by `build.rs` (ticket 8892d5) + to convert `api/openapi.yaml` to JSON at compile time. They must NOT appear in `[dependencies]`. diff --git a/quotesdb/.nbd/tickets/25c413.md b/quotesdb/.nbd/tickets/25c413.md index 239fc76..b5b3db9 100644 --- a/quotesdb/.nbd/tickets/25c413.md +++ b/quotesdb/.nbd/tickets/25c413.md @@ -3,7 +3,7 @@ title = "quotesdb/infra" priority = 7 status = "todo" ticket_type = "project" -dependencies = [] +dependencies = ["07feaa", "5c0c64", "fc9bfd", "07cafb", "e2bd9b", "efee79", "2d1371", "d0da0b", "a23489", "ae886f", "ae6a82", "657836", "75489a", "71b1d4", "d5839a", "3781c9", "5137d7", "57fe5e"] +++ diff --git a/quotesdb/.nbd/tickets/28e7d9.md b/quotesdb/.nbd/tickets/28e7d9.md index 1c52efb..fbe66a3 100644 --- a/quotesdb/.nbd/tickets/28e7d9.md +++ b/quotesdb/.nbd/tickets/28e7d9.md @@ -3,7 +3,7 @@ title = "Implement GET /api/ — serve OpenAPI spec as JSON" priority = 5 status = "todo" ticket_type = "task" -dependencies = ["f3dc74", "1f5bb5", "2ec8b1"] +dependencies = ["1f5bb5", "8892d5"] +++ @@ -15,13 +15,35 @@ The `GET /api/` endpoint serves the OpenAPI 3.1.0 specification as JSON. This en -Implement the `GET /api/` handler that returns the OpenAPI spec as `application/json`. The spec can be embedded at compile time using `include_str!("../../../api/openapi.yaml")` (or equivalent path) and parsed/re-serialised as JSON, or generated programmatically. +Implement the `GET /api/` handler that returns the OpenAPI spec as `application/json`. + +Strategy resolved in TRIAGE 2ec8b1: **compile-time embed via `build.rs`** (ticket 8892d5). +The `build.rs` converts `api/openapi.yaml` to JSON at build time and writes it to +`$OUT_DIR/openapi.json`. The handler serves this as a static `&str`: + +```rust +// Embedded at compile time by build.rs — no runtime parsing, no serde_yaml in binary. +const OPENAPI_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/openapi.json")); + +pub async fn get_openapi_spec() -> impl IntoResponse { + ( + [(axum::http::header::CONTENT_TYPE, "application/json")], + OPENAPI_JSON, + ) +} +``` + +Register the route in the Axum router as `GET /api/`. -- Resolve TRIAGE ticket 2ec8b1 (OpenAPI spec serving strategy) before choosing compile-time embed vs runtime load. - The response `Content-Type` must be `application/json`. -- The spec at `api/openapi.yaml` is the source of truth — validate it with `redocly lint api/openapi.yaml` after any changes. +- Do NOT use `serde_yaml` in this handler — the YAML→JSON conversion is done by `build.rs` + (ticket 8892d5). The handler only serves a pre-built static string. +- Do NOT use `OnceLock` or lazy parsing — `OPENAPI_JSON` is a `const &str` embedded at + compile time; no initialisation is needed. +- The spec at `api/openapi.yaml` is the source of truth — validate with + `redocly lint api/openapi.yaml` after any changes. diff --git a/quotesdb/.nbd/tickets/2ab7a8.md b/quotesdb/.nbd/tickets/2ab7a8.md index 79aac35..df89959 100644 --- a/quotesdb/.nbd/tickets/2ab7a8.md +++ b/quotesdb/.nbd/tickets/2ab7a8.md @@ -1,9 +1,9 @@ +++ title = "[TRIAGE] Test harness: how to import and start quotesdb-api in tests (workers-rs vs native build target)" priority = 9 -status = "todo" +status = "done" ticket_type = "task" -dependencies = ["ce1e4f"] +dependencies = [] +++ @@ -20,11 +20,26 @@ Test harness: how do we import and start the quotesdb-api binary in integration 3. **Wrangler dev** — run `wrangler dev` in the background and point tests at it. Complex setup, slower CI. - -1. Research the options above and choose the best approach for this project. -2. Update ticket 9b581f (test harness) and ticket 6e829e (api main.rs) with the chosen approach. -3. Mark this ticket done with a note on the chosen approach in the body or a comment. - + +**Option 1 variant: `cfg(target_arch = "wasm32")` split — no feature flag, no separate binary.** + +The `cfg(target_arch)` approach in Cargo.toml means that when `cargo test` runs on the native +host, the workers-rs crate is never pulled in (it is a `[target.'cfg(target_arch = "wasm32")'.dependencies]` +entry). The native Axum server path compiles automatically. + +Integration tests in `tests/` start the server by calling a `spawn_test_server()` helper that: +1. Opens an in-memory or temp-file rusqlite DB (via `NativeRepository`) +2. Calls `router::build_router(repo)` to get the Axum `Router` +3. Binds to a random port with `tokio::net::TcpListener::bind("127.0.0.1:0")` +4. Spawns the server with `tokio::spawn(axum::serve(listener, app))` +5. Returns `(base_url, shutdown_handle)` + +No wrangler dev, no separate binary, no feature flags. Standard `cargo test` workflow. +Resolved as part of TRIAGE e8a330 and a91260 (cfg-split architecture decision). + +See implementation ticket 00aff0 for the DB abstraction details and ticket 9b581f for the +test harness implementation. + `chore(quotesdb): resolve triage — test-harness-how-to-import-and-start-quotesdbapi-in-tests-wo` diff --git a/quotesdb/.nbd/tickets/2c5a57.md b/quotesdb/.nbd/tickets/2c5a57.md index d113892..3599526 100644 --- a/quotesdb/.nbd/tickets/2c5a57.md +++ b/quotesdb/.nbd/tickets/2c5a57.md @@ -3,7 +3,7 @@ title = "Implement pagination component — prev/next buttons, current page indi priority = 5 status = "todo" ticket_type = "task" -dependencies = ["c3503b", "93515e"] +dependencies = ["93515e", "0fbdd5"] +++ diff --git a/quotesdb/.nbd/tickets/2ce22e.md b/quotesdb/.nbd/tickets/2ce22e.md index 8d1db33..e55cb32 100644 --- a/quotesdb/.nbd/tickets/2ce22e.md +++ b/quotesdb/.nbd/tickets/2ce22e.md @@ -3,7 +3,7 @@ title = "Implement GET /api/quotes/random — random row query (must be register priority = 5 status = "todo" ticket_type = "task" -dependencies = ["f3dc74", "a5049d", "d792e2", "175382"] +dependencies = ["a5049d", "d792e2", "175382"] +++ diff --git a/quotesdb/.nbd/tickets/2d1371.md b/quotesdb/.nbd/tickets/2d1371.md index d796935..5e14515 100644 --- a/quotesdb/.nbd/tickets/2d1371.md +++ b/quotesdb/.nbd/tickets/2d1371.md @@ -3,7 +3,7 @@ title = "Set up infra/ OpenTofu project — providers.tf, terraform.tf, .gitigno priority = 8 status = "todo" ticket_type = "task" -dependencies = ["25c413", "07feaa"] +dependencies = ["07feaa"] +++ @@ -13,15 +13,16 @@ Infrastructure is managed with OpenTofu using the Cloudflare provider. Configura Bootstrap the OpenTofu project in `infra/`: 1. Create `infra/providers.tf` — declare the Cloudflare provider with the required version -2. Create `infra/terraform.tf` — configure the OpenTofu backend (resolve TRIAGE ticket 07feaa for state backend choice) -3. Create `infra/.gitignore` — ignore `*.tfstate`, `*.tfstate.backup`, `.terraform/` +2. `infra/terraform.tf` — **use the local file backend** (07feaa resolved: local file is the correct choice for this solo project). The local backend is OpenTofu's default, so no explicit `backend` block is needed in `terraform.tf`. The file only needs the `required_providers` block (already partially present in `main.tf` — move it to `terraform.tf` and remove from `main.tf`). +3. Create `infra/.gitignore` — ignore `*.tfstate`, `*.tfstate.backup`, `.terraform/`, `.terraform.lock.hcl` 4. Run `tofu init` to initialise the provider -- Resolve TRIAGE ticket 07feaa (state backend: local file vs Terraform Cloud vs R2) before creating `terraform.tf`. +- State backend is **local file** (resolved by 07feaa). No `backend` block is required — omitting it uses the local default. - The Cloudflare provider requires an API token — document the expected environment variable (`CLOUDFLARE_API_TOKEN`) in a comment in `providers.tf`, do not hardcode it. - Every `resource` and `data` block must have a comment explaining its purpose (per CLAUDE.md). +- Note: `infra/main.tf` currently contains the `terraform` block — move it to `infra/terraform.tf` during this task. diff --git a/quotesdb/.nbd/tickets/2ec8b1.md b/quotesdb/.nbd/tickets/2ec8b1.md index 954640c..84f3cff 100644 --- a/quotesdb/.nbd/tickets/2ec8b1.md +++ b/quotesdb/.nbd/tickets/2ec8b1.md @@ -1,9 +1,9 @@ +++ title = "[TRIAGE] OpenAPI spec serving strategy — embed YAML at compile time vs load at runtime" priority = 7 -status = "todo" +status = "done" ticket_type = "task" -dependencies = ["f3dc74"] +dependencies = [] +++ @@ -21,9 +21,32 @@ OpenAPI spec serving strategy: should the spec be embedded at compile time (incl -1. Research the options above and choose the best approach for this project. -2. Update ticket 28e7d9 (GET /api/ handler) with the chosen approach. If using utoipa, update `Cargo.toml`. -3. Mark this ticket done with a note on the chosen approach in the body or a comment. +**Chosen approach: Option 1 — compile-time embed, via `build.rs` (not raw `include_str!` of YAML).** + +The refined implementation uses a `build.rs` script rather than embedding the raw YAML and +parsing it at runtime. Specifically: + +- `build.rs` reads `api/openapi.yaml`, parses it to `serde_json::Value` with `serde_yaml`, + writes compact JSON to `$OUT_DIR/openapi.json`, and emits + `cargo:rerun-if-changed=api/openapi.yaml` so the conversion re-runs on every spec change. +- The `GET /api/` handler serves the result as: + `const OPENAPI_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/openapi.json"));` +- `serde_yaml` is a `[build-dependencies]` entry only — it never enters the Workers binary, + keeping binary size minimal. +- Zero runtime overhead: no `OnceLock`, no lazy parsing, no heap allocation for the spec. + +Options 2 (runtime load) and 3 (utoipa) were ruled out: +- Option 2 is impossible on Cloudflare Workers — there is no filesystem at runtime. +- Option 3 (utoipa) would require annotating all 7 handlers with macros and migrating away + from the hand-written `api/openapi.yaml` spec, which is already complete and validated. + The added complexity is not justified for a project of this size. + +Tickets updated: +- **8892d5** (new): implements `build.rs` and adds `[build-dependencies]` to `Cargo.toml`. +- **28e7d9**: updated with the concrete `include_str!(concat!(env!("OUT_DIR"), "/openapi.json"))` + handler pattern; now depends on 8892d5 instead of this triage ticket. +- **1f5bb5**: updated with the `[build-dependencies]` constraint. +- **f3dc74** (API sub-project): 8892d5 added as dependency. diff --git a/quotesdb/.nbd/tickets/33ed29.md b/quotesdb/.nbd/tickets/33ed29.md index c67fea0..c09a0c6 100644 --- a/quotesdb/.nbd/tickets/33ed29.md +++ b/quotesdb/.nbd/tickets/33ed29.md @@ -1,9 +1,9 @@ +++ title = "[TRIAGE] Local dev config: Turso (file SQLite) vs D1 binding selection strategy" priority = 8 -status = "todo" +status = "done" ticket_type = "task" -dependencies = ["f3dc74"] +dependencies = [] +++ @@ -20,12 +20,37 @@ Local dev config: should the API use Turso (file-backed SQLite via libsql) or a 3. **Plain SQLite via sqlx** — use sqlx's SQLite driver with a local file. No Turso dependency needed for dev. - -1. Research the options above and choose the best approach for this project. -2. Update ticket a5049d (database connection module) and ticket af56a7 (local dev docs) with the chosen strategy. -3. Mark this ticket done with a note on the chosen approach in the body or a comment. + +**Chosen approach: rusqlite with a local SQLite file — a variant of Option 3, but using rusqlite instead of sqlx.** + +This decision is a direct consequence of TRIAGE e8a330 (already resolved): SQLx is NOT compatible +with Cloudflare Workers/D1 at all. The chosen architecture is `cfg(target_arch = "wasm32")` compile-time split: +- `wasm32` (production) → workers-rs `D1Database` bindings +- native (local dev + tests) → `rusqlite` + `tokio-rusqlite` with a local `.sqlite` file + +Rationale for rusqlite over Turso: +- No additional dependency or service (Turso = libsql client + server/cloud) +- `rusqlite` with `features = ["bundled"]` compiles SQLite in — zero system dependencies +- `cargo run` just works without any account, credentials, or external tooling +- `cargo test` works the same way — tests use the rusqlite path automatically + +Rationale for rusqlite over wrangler D1 local: +- No wrangler, no Cloudflare account required for local dev or CI +- Eliminates a major developer friction point +- Integration tests use `NativeRepository` (rusqlite) directly without spawning wrangler + +Selection mechanism: **compile-time** via `cfg(target_arch = "wasm32")`, not runtime env var. +The `DATABASE_URL` env var controls the SQLite file path (default: `./quotesdb.sqlite`). + +Port note: Native API server binds to `localhost:3000` (Trunk UI dev server uses `localhost:8080`). +Port conflict found and fixed in ticket 00aff0 (was 8080, corrected to 3000). + +Updated: +- Ticket 00aff0 (DB abstraction): corrected native server port 8080 → 3000 +- Ticket af56a7 (local dev docs): updated title and body to reflect rusqlite approach +- Ticket 9c9546 (new): create `.env.example` documenting `DATABASE_URL` `chore(quotesdb): resolve triage — local-dev-config-turso-file-sqlite-vs-d1-binding-selection-s` - + \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/372790.md b/quotesdb/.nbd/tickets/372790.md index 6bbeaed..c16a757 100644 --- a/quotesdb/.nbd/tickets/372790.md +++ b/quotesdb/.nbd/tickets/372790.md @@ -3,7 +3,7 @@ title = "Write ui/README.md, ui/docs/PLANNING.md, ui/docs/ARCHITECTURE.md" priority = 3 status = "todo" ticket_type = "task" -dependencies = ["c3503b", "1a274d", "1ba523", "5f1112", "b3ef98", "5cdbd9"] +dependencies = ["1a274d", "1ba523", "5f1112", "b3ef98", "5cdbd9"] +++ diff --git a/quotesdb/.nbd/tickets/3781c9.md b/quotesdb/.nbd/tickets/3781c9.md new file mode 100644 index 0000000..46e3cb4 --- /dev/null +++ b/quotesdb/.nbd/tickets/3781c9.md @@ -0,0 +1,85 @@ ++++ +title = "Verify API worker gzipped binary size is within CF Workers free tier (3 MB limit)" +priority = 5 +status = "todo" +ticket_type = "task" +dependencies = ["1f5bb5"] ++++ + + +Resolved from TRIAGE ticket 182210. The original concern (1 MB Workers free tier limit) was based on +outdated information. The actual current limit is **3 MB after gzip** for the free tier (10 MB paid). + +This ticket verifies that the API worker stays within that limit once all Cargo.toml dependencies +are pinned (ticket 1f5bb5). No structural changes are expected — the release profile and architecture +already make this highly likely. + +Key facts: +- Worker size limit: Free = 3 MB (gzipped), Paid = 10 MB (gzipped) +- `Cargo.toml` release profile already has `opt-level = "z"`, `lto = true`, `strip = true`, `codegen-units = 1` +- `wrangler` applies `wasm-opt -Oz` automatically during build +- The API Worker contains only API code (no Yew/UI); UI runs on Cloudflare Pages +- Database layer uses `workers-rs` D1 bindings (not SQLx) — avoids a heavy dep + + + + +## Step 1 — Build the release worker bundle + +From the `quotesdb/` directory, after ticket 1f5bb5 has added all Cargo.toml dependencies: + +```sh +wrangler deploy --outdir bundled/ --dry-run +``` + +This produces output like: + +``` +Total Upload: 523.41 KiB / gzip: 147.23 KiB +``` + +The `gzip:` figure is the size that counts against the 3 MB free tier limit. + +## Step 2 — Evaluate the result + +| Gzip size | Action | +|-----------|--------| +| < 2 MB | No action needed. Note the size in this ticket. | +| 2–3 MB | Note the size. Add a comment to track future dep additions carefully. | +| > 3 MB | See remediation steps below. | + +## Step 3 (conditional) — Remediation if > 3 MB + +Try in order: + +1. **Audit dependencies** — run `cargo bloat --release --crates` to identify the largest contributors. + Remove or replace heavy crates (e.g., swap `chrono` for `time`, avoid full `tokio` features). + +2. **Explicit `wasm-opt` pass** — if `wrangler` is not applying `wasm-opt` for some reason: + ```sh + wasm-opt -Oz -o output.wasm input.wasm + ``` + +3. **Workers Paid plan** — if the binary genuinely cannot be reduced below 3 MB, upgrade to the + Workers Paid plan ($5/month, 10 MB limit). Update `infra/` resources accordingly and document + the decision in `docs/ARCHITECTURE.md`. + +## Step 4 — Document outcome + +Record the final gzipped size in `docs/ARCHITECTURE.md` under the API section, and close this ticket. + + + + + +```sh +# From quotesdb/ directory: +wrangler deploy --outdir bundled/ --dry-run +# Confirm "gzip: X KiB" is < 3 MB (3072 KiB) +``` + + + + +`chore(quotesdb): verify api worker binary size within cf workers 3mb free tier limit` + diff --git a/quotesdb/.nbd/tickets/4a4c26.md b/quotesdb/.nbd/tickets/4a4c26.md index 488f0ae..43054da 100644 --- a/quotesdb/.nbd/tickets/4a4c26.md +++ b/quotesdb/.nbd/tickets/4a4c26.md @@ -3,7 +3,7 @@ title = "Test suite: PUT /api/quotes — create (auto auth_code, custom auth_cod priority = 5 status = "todo" ticket_type = "task" -dependencies = ["ce1e4f", "9b581f", "05f8ae"] +dependencies = ["9b581f", "05f8ae"] +++ diff --git a/quotesdb/.nbd/tickets/5137d7.md b/quotesdb/.nbd/tickets/5137d7.md new file mode 100644 index 0000000..8458a70 --- /dev/null +++ b/quotesdb/.nbd/tickets/5137d7.md @@ -0,0 +1,119 @@ ++++ +title = "Write .gitea/workflows/deploy-ui.yml — Gitea Actions workflow to build and deploy UI to Cloudflare Pages" +priority = 4 +status = "todo" +ticket_type = "task" +dependencies = ["ae886f", "dc3d2b"] ++++ + + +Build strategy resolved in triage fc9bfd: pre-built artifact + Gitea Actions + `wrangler pages deploy`. + +The Gitea instance at `gitea.elijah.run` runs Gitea Actions (GitHub Actions-compatible YAML). The workflow must: +1. Trigger on push to the `quotesdb` branch +2. Build the Yew/Wasm UI with `trunk build --release` +3. Deploy the `dist/` output to Cloudflare Pages via `wrangler pages deploy` + +The Cloudflare Pages project (`quotesdb-ui`) is created by OpenTofu (ticket ae886f) and must exist before this workflow can successfully deploy. + + + +Create `.gitea/workflows/deploy-ui.yml` at the repository root (not inside `quotesdb/`). + + + +```yaml +# .gitea/workflows/deploy-ui.yml +# Builds the quotesdb Yew/Wasm UI with Trunk and deploys to Cloudflare Pages. +# Triggered on push to the quotesdb integration branch. +# Requires repository secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID. + +name: Deploy quotesdb UI + +on: + push: + branches: + - quotesdb + paths: + - "quotesdb/**" + +jobs: + deploy-ui: + runs-on: ubuntu-latest + defaults: + run: + working-directory: quotesdb + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain with wasm32 target + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Cache Rust build artifacts + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + quotesdb/target + key: ${{ runner.os }}-cargo-ui-${{ hashFiles("quotesdb/Cargo.lock") }} + restore-keys: | + ${{ runner.os }}-cargo-ui- + + - name: Install Trunk + run: | + curl -fsSL https://github.com/trunk-rs/trunk/releases/latest/download/trunk-x86_64-unknown-linux-gnu.tar.gz \ + | tar -xz -C ~/.cargo/bin + + - name: Build UI with Trunk + run: trunk build --release + + - name: Deploy to Cloudflare Pages + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy dist/ --project-name quotesdb-ui --branch main +``` + + + +The following repository secrets must be configured in Gitea (Settings → Secrets): + +| Secret | Description | +|--------|-------------| +| `CLOUDFLARE_API_TOKEN` | Cloudflare API token with Pages:Edit and Account:Read permissions | +| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID (visible in the Cloudflare dashboard URL) | + +Documentation for secrets is tracked in ticket 71b1d4. + + + +- The workflow file lives at the **repository root** (`.gitea/workflows/`), not inside `quotesdb/`. Gitea Actions discovers workflows from the repo root. +- `working-directory: quotesdb` ensures all `run` steps execute from the project directory. +- `paths: ["quotesdb/**"]` limits deploys to pushes that actually change the UI project, avoiding spurious rebuilds. +- Trunk downloads the latest release binary from GitHub; pin to a specific version for reproducibility once stable. +- `wrangler-action@v3` handles `npx wrangler` invocation internally — no separate Node.js/wrangler install needed. +- `--branch main` tells Pages this deployment is for the production branch (matches `production_branch = "quotesdb"` in OpenTofu — adjust if Pages branch naming differs). + + + +- The Cloudflare Pages project (`quotesdb-ui`) must already exist (created by OpenTofu ticket ae886f) before the first deploy succeeds. +- `trunk build --release` must succeed locally before this workflow is useful; verify with `trunk build` first. +- Do not commit `CLOUDFLARE_API_TOKEN` or any secrets to the repository. + + + +After creating the workflow file: +1. Push to the `quotesdb` branch +2. Confirm the Gitea Actions run succeeds (Actions tab in Gitea UI) +3. Confirm the deployment appears in the Cloudflare Pages dashboard under `quotesdb-ui` + + + +`ci(quotesdb): add Gitea Actions workflow to build and deploy UI to Cloudflare Pages` + diff --git a/quotesdb/.nbd/tickets/5379eb.md b/quotesdb/.nbd/tickets/5379eb.md new file mode 100644 index 0000000..beda484 --- /dev/null +++ b/quotesdb/.nbd/tickets/5379eb.md @@ -0,0 +1,114 @@ ++++ +title = "Implement auth code session storage — utility module and AuthModal pre-fill integration" +priority = 7 +status = "todo" +ticket_type = "task" +dependencies = [] ++++ + + +Resolved from TRIAGE ticket 0bc655. The auth code (4-word passphrase) that authorises edit and +delete operations must be available to the UI without forcing the user to re-enter it on every +interaction within a browsing session. + +Chosen strategy: **session storage per quote ID**. The code is stored in the browser's +`sessionStorage` under the key `auth_code_{id}` when first entered. It is automatically cleared +when the tab closes. No explicit clear-on-delete is required (session storage is short-lived by +design), but it is good practice and should be included. + +Options considered: +- localStorage: ruled out — indefinite persistence is unnecessary; the app tells users to store + the code externally anyway, and localStorage has a wider XSS exposure window. +- Component state only: ruled out — code is lost on any page navigation or reload, making the + edit/delete flow unusable in practice. + + + +**Part 1 — Storage utility (`src/bin/ui/storage.rs`)** + +Create a module with three public functions that wrap the browser's `sessionStorage` API: + +```rust +use web_sys::window; + +/// Retrieve the stored auth code for a given quote ID, if any. +pub fn get_auth_code(quote_id: &str) -> Option { + let storage = window()?.session_storage().ok()??; + storage.get_item(&format!("auth_code_{quote_id}")).ok()? +} + +/// Persist the auth code for a quote ID in sessionStorage. +pub fn set_auth_code(quote_id: &str, code: &str) { + if let Some(Ok(storage)) = window().map(|w| w.session_storage()) { + if let Some(storage) = storage { + let _ = storage.set_item(&format!("auth_code_{quote_id}"), code); + } + } +} + +/// Remove the auth code for a quote ID from sessionStorage (call after DELETE). +pub fn clear_auth_code(quote_id: &str) { + if let Some(Ok(storage)) = window().map(|w| w.session_storage()) { + if let Some(storage) = storage { + let _ = storage.remove_item(&format!("auth_code_{quote_id}")); + } + } +} +``` + +Expose this module from the UI binary root: add `mod storage;` to `src/bin/ui/main.rs`. + +**Part 2 — AuthModal pre-fill** + +Update the `AuthModal` component (ticket f850c6) to accept an `initial_value: Option` +prop. Pre-populate the `` value from this prop when the modal opens. The parent +component is responsible for reading from storage and passing the value in. + +```rust +#[derive(Properties, PartialEq)] +pub struct AuthModalProps { + pub on_submit: Callback, + pub on_cancel: Callback<()>, + pub initial_value: Option, // pre-fill if auth code is already stored +} +``` + +**Part 3 — SingleQuotePage integration** + +In the SingleQuotePage (or whichever component renders edit/delete for a quote), integrate +storage around the `AuthModal`: + +- Before opening the modal: read `storage::get_auth_code("e.id)` and pass it as + `initial_value` to `AuthModal`. +- After a successful **edit** (POST /api/quotes/:id returns 200): call + `storage::set_auth_code("e.id, &submitted_code)`. +- After a successful **delete** (DELETE /api/quotes/:id returns 204): call + `storage::clear_auth_code("e.id)`. +- If the API returns 403 (wrong code): do NOT store the code; clear any existing stored value + with `storage::clear_auth_code("e.id)` so a stale code is not re-offered. + + + +- The storage utility must compile only for `wasm32-unknown-unknown` — `web_sys::window()` is + not available on the host target. Gate the module under `#[cfg(target_arch = "wasm32")]` or + ensure it is only imported by the `ui` binary, which is always compiled for wasm32. +- `web_sys` must be available with the `Window`, `Storage` features — confirm these are included + in the `web_sys` dependency in `Cargo.toml` (ticket 93515e covers UI Cargo.toml setup). +- Do NOT use `gloo-storage` — it wraps localStorage by default and the API difference matters. + Use `web_sys` directly as shown above. +- The key pattern is `auth_code_{quote_id}` (underscore separator, not slash or dot). +- Session storage is tab-scoped: no cross-tab contamination is possible — no additional + scoping by domain or user is needed. + + + +```sh +trunk build +``` + + + +`feat(quotesdb): implement auth code session storage utility and AuthModal pre-fill` + + +quotesdb/ui diff --git a/quotesdb/.nbd/tickets/57fe5e.md b/quotesdb/.nbd/tickets/57fe5e.md new file mode 100644 index 0000000..3a66e3a --- /dev/null +++ b/quotesdb/.nbd/tickets/57fe5e.md @@ -0,0 +1,124 @@ ++++ +title = "Write .gitea/workflows/deploy-api.yml — Gitea Actions workflow to build and deploy API Worker via OpenTofu" +priority = 4 +status = "todo" +ticket_type = "task" +dependencies = ["a23489", "2d1371"] ++++ + + +The API Worker is a workers-rs Wasm binary deployed to Cloudflare Workers. The OpenTofu resource (`infra/worker.tf`) reads the compiled Wasm via `filebase64("../target/wasm32-unknown-unknown/release/api.wasm")` and uploads it on `tofu apply`. This means the CI workflow must compile the Wasm before running `tofu apply`. + +Counterpart to ticket 5137d7 (UI deploy via wrangler pages deploy). + + + +Create `.gitea/workflows/deploy-api.yml` at the repository root. The workflow must: +1. Compile the `api` binary for `wasm32-unknown-unknown` +2. Run `tofu apply` from `quotesdb/infra/` to upload the Worker and provision/update all infra + +Triggered on push to `quotesdb` branch when files under `quotesdb/src/bin/api/` or `quotesdb/infra/` change. + + + +```yaml +# .gitea/workflows/deploy-api.yml +# Builds the quotesdb API Worker Wasm binary and applies OpenTofu infra. +# Triggered on push to the quotesdb integration branch when API or infra files change. +# Requires repository secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, TF_STATE_* (if using remote state). + +name: Deploy quotesdb API + +on: + push: + branches: + - quotesdb + paths: + - "quotesdb/src/bin/api/**" + - "quotesdb/src/lib.rs" + - "quotesdb/infra/**" + - "quotesdb/Cargo.toml" + - "quotesdb/Cargo.lock" + +jobs: + deploy-api: + runs-on: ubuntu-latest + defaults: + run: + working-directory: quotesdb + + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain with wasm32 target + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Cache Rust build artifacts + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + quotesdb/target + key: ${{ runner.os }}-cargo-api-${{ hashFiles("quotesdb/Cargo.lock") }} + restore-keys: | + ${{ runner.os }}-cargo-api- + + - name: Build API Worker Wasm binary + run: cargo build --release --target wasm32-unknown-unknown --bin api + + - name: Install OpenTofu + uses: opentofu/setup-opentofu@v1 + + - name: OpenTofu init + working-directory: quotesdb/infra + run: tofu init + + - name: OpenTofu apply + working-directory: quotesdb/infra + run: tofu apply -auto-approve +``` + + + +The following repository secrets must be configured in Gitea (Settings → Secrets): + +| Secret | Description | +|--------|-------------| +| `CLOUDFLARE_API_TOKEN` | Cloudflare API token with Workers:Edit, D1:Edit, Account:Read permissions | +| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID | + +Remote state credentials (if applicable) — see ticket 71b1d4. + + + +- `opentofu/setup-opentofu@v1` is the official GitHub/Gitea Action for OpenTofu installation. +- The `env:` block at job level makes credentials available to both `tofu init` and `tofu apply` via the Cloudflare provider environment variable convention. +- The Wasm binary at `target/wasm32-unknown-unknown/release/api.wasm` is read by `filebase64()` in `infra/worker.tf` at apply time — the file must exist before `tofu apply` runs. +- `tofu apply -auto-approve` is safe in CI because the plan is deterministic and the repo is the source of truth. +- OpenTofu state: the `infra/` directory needs a configured backend. If using local state, the state file must be committed or a remote backend (e.g. Cloudflare R2) configured. See ticket 2d1371. +- The `paths` filter ensures the workflow only triggers when API code or infra config changes, avoiding spurious runs on UI-only pushes. + + + +- The Cloudflare infra (D1, Worker script resource) must be defined (ticket a23489, d0da0b) and `infra/` must be initialised (ticket 2d1371) before this workflow is useful. +- Do not commit Cloudflare credentials or OpenTofu state files containing secrets. + + + +After creating the workflow file: +1. Push to the `quotesdb` branch with a change to `src/bin/api/` +2. Confirm the Gitea Actions run succeeds +3. Confirm the Worker appears/updates in the Cloudflare Workers dashboard + + + +`ci(quotesdb): add Gitea Actions workflow to build and deploy API Worker via OpenTofu` + diff --git a/quotesdb/.nbd/tickets/580e66.md b/quotesdb/.nbd/tickets/580e66.md index 736fa9b..98276e4 100644 --- a/quotesdb/.nbd/tickets/580e66.md +++ b/quotesdb/.nbd/tickets/580e66.md @@ -1,9 +1,9 @@ +++ title = "[TRIAGE] Database migration strategy for Cloudflare Workers (startup vs wrangler d1 execute)" priority = 8 -status = "todo" +status = "done" ticket_type = "task" -dependencies = ["f3dc74"] +dependencies = [] +++ @@ -20,10 +20,25 @@ Database migration strategy for Cloudflare Workers: how should the `quotes` and 3. **SQLx migrate! macro** — embed migrations in the binary and run them at startup. Depends on SQLx compatibility with workers-rs (see TRIAGE e8a330). + +**Option 2: `wrangler d1 execute` as a separate CI/CD step.** + +- Option 3 (SQLx) is ruled out — TRIAGE e8a330 established that SQLx is incompatible with workers-rs/D1. +- Option 1 (startup migration from the Workers handler) is impractical: Workers spin up per-request via V8 isolates. Running DDL before every request adds latency and is fragile. +- Option 2 is the canonical Cloudflare-recommended approach. It is idempotent (`CREATE TABLE IF NOT EXISTS`), keeps the Workers handler free of DDL overhead, and integrates cleanly into CI/CD as a post-`tofu apply` step. + +**Production:** `wrangler d1 execute quotesdb --file infra/schema.sql --remote` — run once after first `tofu apply`, and again for each incremental migration file. + +**Local dev / tests:** `NativeRepository::run_migrations()` (ticket 00aff0) runs `execute_batch` via rusqlite on native startup. No manual wrangler step needed. + +This decision is co-resolved with TRIAGE 5c0c64, which asked the same question from the OpenTofu angle. Both arrive at the same answer. + + -1. Research the options above and choose the best approach for this project. -2. Update ticket a5049d (database connection + migrations) with the chosen strategy. -3. Mark this ticket done with a note on the chosen approach in the body or a comment. +- Co-resolved with TRIAGE 5c0c64. +- Ticket a5049d updated with chosen strategy. +- Ticket bb1514 created: implementation plan for `infra/schema.sql`. +- Ticket 75489a updated: documents the wrangler workflow. diff --git a/quotesdb/.nbd/tickets/5c0c64.md b/quotesdb/.nbd/tickets/5c0c64.md index 58457cc..c714484 100644 --- a/quotesdb/.nbd/tickets/5c0c64.md +++ b/quotesdb/.nbd/tickets/5c0c64.md @@ -1,9 +1,9 @@ +++ title = "[TRIAGE] D1 migrations in OpenTofu — null_resource local-exec vs separate wrangler step vs manual" priority = 9 -status = "todo" +status = "done" ticket_type = "task" -dependencies = ["25c413"] +dependencies = [] +++ @@ -20,10 +20,28 @@ D1 migrations in OpenTofu: how do we apply the SQL schema to a newly created D1 3. **API startup migration** — the API runs `CREATE TABLE IF NOT EXISTS` on startup. Works but risks schema drift in production. + +**Option 2: Separate wrangler step.** + +- **null_resource local-exec rejected:** `null_resource` provisioners are an OpenTofu anti-pattern. They don't re-run unless tainted, aren't tracked in state, require wrangler installed on the CI runner at `tofu apply` time, and break idempotency. The convenience of a single command is not worth the coupling. + +- **API startup migration rejected:** Cloudflare Workers spin up per-request via V8 isolates. There is no persistent startup phase. Running DDL (`CREATE TABLE IF NOT EXISTS`) before every request adds latency and is fragile. The Workers fetch handler (D1Repository, wasm32 path) does NOT run migrations. This is only viable for the native/local dev path (rusqlite), where `NativeRepository::run_migrations()` is called once at `main()` startup. + +- **Separate wrangler step chosen:** This is Cloudflare's canonical approach. The schema SQL lives at `infra/schema.sql` (ticket bb1514). After `tofu apply`, run once: + ```sh + wrangler d1 execute quotesdb --file infra/schema.sql --remote + ``` + Idempotent with `CREATE TABLE IF NOT EXISTS`. Integrates cleanly into CI/CD as a post-apply step. Keeps OpenTofu focused on infrastructure, not data. + +**Note:** TRIAGE 580e66 asks the same question from the Workers runtime angle and arrives at the same answer. Both are now resolved together. + + -1. Research the options above and choose the best approach for this project. -2. Update ticket d0da0b (D1 resource), ticket a5049d (migrations module), and ticket 75489a (migration workflow docs) with the chosen strategy. -3. Mark this ticket done with a note on the chosen approach in the body or a comment. +- Ticket d0da0b updated: constraint clarified (wrangler step, no null_resource). +- Ticket a5049d updated: migration strategy constraint updated (580e66 also resolved). +- Ticket 75489a updated: dependency on bb1514 added; goal updated to reference infra/schema.sql. +- Ticket 580e66 resolved as co-decided. +- New ticket bb1514 created: full implementation plan for `infra/schema.sql`. diff --git a/quotesdb/.nbd/tickets/5cdbd9.md b/quotesdb/.nbd/tickets/5cdbd9.md index 3c2cbe9..3229d6c 100644 --- a/quotesdb/.nbd/tickets/5cdbd9.md +++ b/quotesdb/.nbd/tickets/5cdbd9.md @@ -3,7 +3,7 @@ title = "Implement Browse page (/browse) — paginated quote list with author/ta priority = 5 status = "todo" ticket_type = "task" -dependencies = ["c3503b", "04f865", "1e6a09", "0d987f", "2c5a57", "d3d502", "fc2f51"] +dependencies = ["04f865", "1e6a09", "0d987f", "2c5a57", "d3d502", "fc2f51", "0fbdd5"] +++ diff --git a/quotesdb/.nbd/tickets/5d9f5a.md b/quotesdb/.nbd/tickets/5d9f5a.md index 960e067..b425539 100644 --- a/quotesdb/.nbd/tickets/5d9f5a.md +++ b/quotesdb/.nbd/tickets/5d9f5a.md @@ -3,7 +3,7 @@ title = "Implement POST /api/quotes/:id — partial update, verify X-Auth-Code h priority = 5 status = "todo" ticket_type = "task" -dependencies = ["f3dc74", "a5049d", "d792e2", "175382"] +dependencies = ["a5049d", "d792e2", "175382"] +++ diff --git a/quotesdb/.nbd/tickets/5dbb7d.md b/quotesdb/.nbd/tickets/5dbb7d.md index 1d41f95..a330e7f 100644 --- a/quotesdb/.nbd/tickets/5dbb7d.md +++ b/quotesdb/.nbd/tickets/5dbb7d.md @@ -3,7 +3,7 @@ title = "Implement GET /api/quotes/:id — fetch by NanoID, return 404 if not fo priority = 5 status = "todo" ticket_type = "task" -dependencies = ["f3dc74", "a5049d", "d792e2", "175382"] +dependencies = ["a5049d", "d792e2", "175382"] +++ diff --git a/quotesdb/.nbd/tickets/5e3e37.md b/quotesdb/.nbd/tickets/5e3e37.md index 7f53397..d134c87 100644 --- a/quotesdb/.nbd/tickets/5e3e37.md +++ b/quotesdb/.nbd/tickets/5e3e37.md @@ -1,9 +1,9 @@ +++ title = "[TRIAGE] CSS/styling approach for Wasm — plain CSS, CDN Tailwind, or Wasm-compatible crate?" priority = 8 -status = "todo" +status = "done" ticket_type = "task" -dependencies = ["c3503b"] +dependencies = [] +++ @@ -20,6 +20,22 @@ CSS/styling approach for the Yew Wasm UI: plain CSS (separate .css file), CDN Ta 3. **Stylist or yew-style** — Rust crates for CSS-in-Wasm. More idiomatic but less documentation. + +**Plain CSS** — Option 1. + +Rationale: +- CDN Tailwind's JIT scanner reads DOM/HTML for class names to generate utility CSS on-the-fly. In a Yew Wasm app, class names are Rust strings compiled into the Wasm binary — they are never present in the HTML that Tailwind's scanner reads. The result is a non-functional Tailwind build with no utility classes. +- Stylist/yew-style add a Wasm dependency, sparse documentation, and binary bloat for a 5-page app where co-location of styles provides no real benefit. +- Plain CSS + Trunk: Trunk natively bundles CSS via `` in `index.html`. Zero additional dependencies, no build complexity, easy maintainability. + +Implementation: +- CSS file: `src/bin/ui/style.css` (Trunk discovers files relative to `index.html`) +- index.html link: `` +- Naming convention: BEM-style semantic names — `quote-card`, `quote-card__text`, `quote-card__author`, `page-browse`, etc. +- Yew usage: `class={"quote-card"}` or `classes!["quote-card", conditional]` +- Dedicated implementation ticket: 0fbdd5 + + 1. Research the options above and choose the best approach for this project. 2. Update ticket dc3d2b (Trunk.toml + index.html) and all UI component tickets with the chosen CSS class strategy. @@ -27,5 +43,5 @@ CSS/styling approach for the Yew Wasm UI: plain CSS (separate .css file), CDN Ta -`chore(quotesdb): resolve triage — cssstyling-approach-for-wasm-plain-css-cdn-tailwind-or-wasmc` +`chore(quotesdb): resolve triage — css-styling-approach-plain-css` diff --git a/quotesdb/.nbd/tickets/5f1112.md b/quotesdb/.nbd/tickets/5f1112.md index 0103316..038ef88 100644 --- a/quotesdb/.nbd/tickets/5f1112.md +++ b/quotesdb/.nbd/tickets/5f1112.md @@ -3,7 +3,7 @@ title = "Implement Quote detail page (/quotes/:id) — view, edit form with auth priority = 5 status = "todo" ticket_type = "task" -dependencies = ["c3503b", "04f865", "1e6a09", "0d987f", "f850c6", "fc2f51"] +dependencies = ["04f865", "1e6a09", "0d987f", "f850c6", "fc2f51", "0fbdd5"] +++ diff --git a/quotesdb/.nbd/tickets/5f5ba0.md b/quotesdb/.nbd/tickets/5f5ba0.md index 297e341..5016f47 100644 --- a/quotesdb/.nbd/tickets/5f5ba0.md +++ b/quotesdb/.nbd/tickets/5f5ba0.md @@ -3,21 +3,36 @@ title = "Set up tests/Cargo.toml with integration test dependencies (reqwest/hyp priority = 8 status = "todo" ticket_type = "task" -dependencies = ["ce1e4f", "0d84fa"] +dependencies = ["0d84fa", "fba598"] +++ -Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment. The test harness spawns the API server on a random port and returns the base URL. +Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment. + +Triage decisions: +- 0d84fa: HTTP client → `reqwest` with `tokio::test` +- fba598: Isolation strategy → per-test temp SQLite file via `tempfile` crate -Add integration test dependencies to `Cargo.toml` under `[dev-dependencies]`. Resolve TRIAGE ticket 0d84fa (HTTP client selection) first, then add the chosen HTTP client, plus `tokio` (test runtime), `serde_json`, and any other test utilities. +Add integration test dev-dependencies to `Cargo.toml` under `[dev-dependencies]`: + +```toml +[dev-dependencies] +reqwest = { version = "0.12", features = ["json"] } +tokio = { version = "1", features = ["full"] } +serde_json = "1" +tempfile = "3" +``` + +Confirm `cargo check` passes after adding these. -- Resolve TRIAGE ticket 0d84fa (HTTP client: reqwest vs hyper vs ureq) before adding the dependency. -- Integration tests in `tests/` run on the host target only — dev-dependencies do not need WASM compatibility. +- Dev-dependencies do not need WASM compatibility — they are host-only. - Use `#[tokio::test]` for async test functions. +- `tempfile` is required by the test harness isolation strategy (ticket fba598 / 9b581f). +- `reqwest` must include the `json` feature for `.json()` request body and `.json::()` response deserialization. @@ -36,5 +51,5 @@ cargo test -`chore(quotesdb): set up integration test dependencies in Cargo.toml` +`chore(quotesdb): add integration test dev-dependencies (reqwest, tokio, serde_json, tempfile)` diff --git a/quotesdb/.nbd/tickets/657836.md b/quotesdb/.nbd/tickets/657836.md index 94a240e..1c3c7ff 100644 --- a/quotesdb/.nbd/tickets/657836.md +++ b/quotesdb/.nbd/tickets/657836.md @@ -3,7 +3,7 @@ title = "Configure custom domain quotes.elijah.run → Cloudflare Pages (DNS rec priority = 6 status = "todo" ticket_type = "task" -dependencies = ["25c413", "ae886f"] +dependencies = ["ae886f"] +++ diff --git a/quotesdb/.nbd/tickets/6e829e.md b/quotesdb/.nbd/tickets/6e829e.md index 017dfe9..3d05a61 100644 --- a/quotesdb/.nbd/tickets/6e829e.md +++ b/quotesdb/.nbd/tickets/6e829e.md @@ -3,7 +3,7 @@ title = "Set up api/src/main.rs — Cloudflare Workers entry point and Axum rout priority = 8 status = "todo" ticket_type = "task" -dependencies = ["f3dc74", "1f5bb5"] +dependencies = ["1f5bb5"] +++ diff --git a/quotesdb/.nbd/tickets/6ed325.md b/quotesdb/.nbd/tickets/6ed325.md index 6e9d707..5e6715f 100644 --- a/quotesdb/.nbd/tickets/6ed325.md +++ b/quotesdb/.nbd/tickets/6ed325.md @@ -1,9 +1,9 @@ +++ title = "[TRIAGE] 4-word passphrase crate selection for WASM target (no_std/wasm32 constraints)" priority = 9 -status = "todo" +status = "done" ticket_type = "task" -dependencies = ["f3dc74"] +dependencies = [] +++ @@ -26,6 +26,53 @@ This is a triage decision ticket. It must be resolved before dependent implement 3. Mark this ticket done with a note on the chosen approach in the body or a comment. + + +## Chosen: Option 3 — Custom embedded word list + `rand 0.10` + `getrandom 0.4` (wasm_js) + +### Research findings + +| Option | Verdict | +|---|---| +| `passphrase-wordlist` | **Does not exist** on crates.io. Eliminated. | +| `bip39` | WASM-compatible (8.8M downloads, used in web crypto wallets). Rejected: carries BIP-39 cryptocurrency semantics; word list (2048 entries) is tuned for phonetic distinctness, not general memorability; introduces unnecessary complexity. | +| Custom word list | **Chosen.** Minimal deps, full control, idiomatic Rust. | + +### Implementation approach + +**Word list:** EFF Short Word List 1 — 1296 common English words designed for memorable passphrases. +Source: `https://www.eff.org/files/2016/09/08/eff_short_wordlist_1.txt` +Generate the Rust array: `curl -s | awk '{print $2}'` + +**RNG:** `rand::rngs::OsRng` from `rand = "0.10"`. +- Does NOT use thread-local storage (safe for wasm32) +- Uses `getrandom` as its entropy backend +- For WASM targets: add `getrandom = { version = "0.4", features = ["wasm_js"] }` in the wasm32 cfg section so Cloudflare Workers (which expose `crypto.getRandomValues()`) can seed the RNG + +**Version note:** `rand 0.10` requires `getrandom ^0.4`. The `wasm_js` feature in `getrandom 0.4` replaces the old `js` feature from `getrandom 0.2`. The latest `uuid 1.21.0` also requires `getrandom ^0.4`, so both deps share one getrandom version in the dependency graph. + +### Cargo.toml changes (update ticket 1f5bb5) + +```toml +[dependencies] +rand = "0.10" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +# Enables OsRng entropy via Web Crypto API (crypto.getRandomValues()) +# Required by both rand (OsRng) and uuid (v4) on wasm32 targets +getrandom = { version = "0.4", features = ["wasm_js"] } +``` + +**Correction:** Tickets 7a0d9f and 1f5bb5 previously referenced `getrandom = "0.2", features = ["js"]` — this is outdated. uuid 1.21 and rand 0.10 both require getrandom ^0.4, which renamed the feature to `wasm_js`. Both those tickets have been updated. + +### Updated tickets + +- **03bb91** — updated with full implementation plan (code, word list steps, tests) +- **1f5bb5** — corrected getrandom version to 0.4/wasm_js; added rand 0.10 dep +- **7a0d9f** — corrected getrandom version to 0.4/wasm_js (was 0.2/js) + + + `chore(quotesdb): resolve triage — 4word-passphrase-crate-selection-for-wasm-target-nostdwasm32` diff --git a/quotesdb/.nbd/tickets/6f2e18.md b/quotesdb/.nbd/tickets/6f2e18.md index 5944fe6..5cdf6ed 100644 --- a/quotesdb/.nbd/tickets/6f2e18.md +++ b/quotesdb/.nbd/tickets/6f2e18.md @@ -1,9 +1,9 @@ +++ title = "[TRIAGE] NanoID crate WASM compatibility with workers-rs target" priority = 9 -status = "todo" +status = "done" ticket_type = "task" -dependencies = ["f3dc74"] +dependencies = [] +++ @@ -21,9 +21,31 @@ NanoID crate WASM compatibility: does the chosen nanoid crate compile for wasm32 -1. Research the options above and choose the best approach for this project. -2. Update ticket 05f8ae (PUT /api/quotes) and `Cargo.toml` (ticket 1f5bb5) with the chosen approach. -3. Mark this ticket done with a note on the chosen approach in the body or a comment. +**RESOLVED 2026-03-02 — Use UUID v4 (`uuid` crate).** + +### Decision: Option 2 — uuid v4 + +The `nanoid` crate (v0.4.0) depends on `rand ^0.8`, which uses `thread_rng()` (thread-local RNG). +Thread-local storage is unreliable in wasm32-unknown-unknown, and the underlying `getrandom` +`wasm_js` feature is explicitly discouraged in libraries by the getrandom maintainers. + +`uuid = { version = "1", features = ["v4", "serde"] }` with `getrandom = { version = "0.2", +features = ["js"] }` (wasm32 cfg section only) is the proven, zero-risk approach for +Cloudflare Workers. UUID v4 produces 36-char hyphenated IDs — slightly longer than NanoID's 21 +chars, but negligible in practice and universally supported. + +### Created ticket + +7a0d9f — "Implement generate_id() in src/lib.rs — UUID v4 for WASM-compatible quote IDs" +- `generate_id()` in `src/lib.rs` returns `uuid::Uuid::new_v4().to_string()` +- Cargo.toml adds `uuid` to `[dependencies]` and `getrandom/js` under `[target.'cfg(target_arch = "wasm32")'.dependencies]` +- Ticket 05f8ae (PUT handler) and 1f5bb5 (Cargo.toml) updated to reference this approach + +### Updated tickets + +- 05f8ae: "Generate a NanoID for the quote ID" → use `generate_id()` from lib.rs (UUID v4) +- 1f5bb5: Add `uuid = { version = "1", features = ["v4", "serde"] }` to [dependencies]; add + `getrandom = { version = "0.2", features = ["js"] }` under wasm32 cfg diff --git a/quotesdb/.nbd/tickets/71b1d4.md b/quotesdb/.nbd/tickets/71b1d4.md index 3d688c8..5912bea 100644 --- a/quotesdb/.nbd/tickets/71b1d4.md +++ b/quotesdb/.nbd/tickets/71b1d4.md @@ -3,7 +3,7 @@ title = "Document secrets management — Cloudflare API token, account ID, how t priority = 6 status = "todo" ticket_type = "task" -dependencies = ["25c413", "2d1371"] +dependencies = ["2d1371"] +++ diff --git a/quotesdb/.nbd/tickets/75489a.md b/quotesdb/.nbd/tickets/75489a.md index 8156c5a..4d5b354 100644 --- a/quotesdb/.nbd/tickets/75489a.md +++ b/quotesdb/.nbd/tickets/75489a.md @@ -3,28 +3,37 @@ title = "Document D1 schema migration workflow — how to apply SQL schema chang priority = 7 status = "todo" ticket_type = "task" -dependencies = ["25c413", "d0da0b"] +dependencies = ["d0da0b", "bb1514"] +++ Infrastructure is managed with OpenTofu using the Cloudflare provider. Configuration lives in `infra/`. Resources include a Cloudflare Worker (API), Cloudflare D1 database (bound to the worker), and a Cloudflare Pages project (UI frontend). -Cloudflare D1 uses SQL migrations. Because the Worker runs in the Cloudflare runtime (not a standard server), migrations must be applied via a separate mechanism (e.g. `wrangler d1 execute` or a startup script). This workflow must be documented. +Cloudflare D1 uses SQL migrations. Because the Worker runs in the Cloudflare runtime (not a standard server), migrations must be applied via a separate mechanism. + +TRIAGE 5c0c64 resolved: the chosen strategy is **Option 2 — separate wrangler step**. Schema SQL lives at `infra/schema.sql` (ticket bb1514). No `null_resource`, no startup migration from the Workers handler. + +For local dev/tests, `NativeRepository::run_migrations()` (ticket 00aff0) calls `execute_batch` via rusqlite — no manual step needed there. -Document the D1 schema migration workflow in `infra/README.md` or `docs/MIGRATIONS.md`: -1. How to apply the initial schema SQL to D1 (`wrangler d1 execute --file schema.sql`) -2. How to apply incremental migrations -3. How to apply migrations in CI/CD -4. Where the schema SQL file lives (e.g. `infra/schema.sql` or `src/migrations/`) -5. Cross-reference the TRIAGE decision from ticket 5c0c64 (D1 migrations strategy) +Document the D1 schema migration workflow in `infra/README.md`: +1. The canonical schema file location: `infra/schema.sql` +2. How to apply the initial schema SQL to D1 after first `tofu apply`: + `wrangler d1 execute quotesdb --file infra/schema.sql --remote` +3. How to apply incremental migrations (numbered files under `infra/migrations/`) +4. How to apply migrations in CI/CD (two-step: `tofu apply` then `wrangler d1 execute`) +5. How local dev/tests work (NativeRepository handles this automatically, no manual step) +6. Cross-reference: TRIAGE decisions from 5c0c64 and 580e66 -- Resolve TRIAGE ticket 5c0c64 before writing this doc — the strategy determines the workflow. +- TRIAGE 5c0c64 is resolved — the strategy is a separate wrangler step. Document accordingly. +- `infra/schema.sql` must exist (ticket bb1514) before writing the exact wrangler command. +- D1 resource must be defined (ticket d0da0b) to confirm the database name "quotesdb". +- Do NOT document `null_resource` or startup migrations from the Workers handler. -`docs(quotesdb): document D1 schema migration workflow` +`docs(quotesdb): document D1 schema migration workflow in infra/README.md` diff --git a/quotesdb/.nbd/tickets/75e3f0.md b/quotesdb/.nbd/tickets/75e3f0.md index c52485b..4c3bddc 100644 --- a/quotesdb/.nbd/tickets/75e3f0.md +++ b/quotesdb/.nbd/tickets/75e3f0.md @@ -3,7 +3,7 @@ title = "Write tests/README.md" priority = 3 status = "todo" ticket_type = "task" -dependencies = ["ce1e4f", "9b581f"] +dependencies = ["9b581f"] +++ diff --git a/quotesdb/.nbd/tickets/789d0f.md b/quotesdb/.nbd/tickets/789d0f.md index 15f9dc2..bc165f7 100644 --- a/quotesdb/.nbd/tickets/789d0f.md +++ b/quotesdb/.nbd/tickets/789d0f.md @@ -3,7 +3,7 @@ title = "Test suite: GET /api/ — OpenAPI spec returned as valid JSON with expe priority = 5 status = "todo" ticket_type = "task" -dependencies = ["ce1e4f", "9b581f", "28e7d9"] +dependencies = ["9b581f", "28e7d9"] +++ diff --git a/quotesdb/.nbd/tickets/7a0d9f.md b/quotesdb/.nbd/tickets/7a0d9f.md new file mode 100644 index 0000000..2d59f6d --- /dev/null +++ b/quotesdb/.nbd/tickets/7a0d9f.md @@ -0,0 +1,123 @@ ++++ +title = "Implement generate_id() in src/lib.rs — UUID v4 for WASM-compatible quote IDs" +priority = 8 +status = "todo" +ticket_type = "task" +dependencies = [] ++++ + + +Resolved from TRIAGE ticket 6f2e18. The `nanoid` crate is not suitable for wasm32-unknown-unknown +because it depends on `rand`, which relies on thread-local RNG — unavailable in WASM. The safe, +WASM-compatible choice is UUID v4 via the `uuid` crate. + +On the wasm32 target, `uuid`'s `v4` feature depends on `getrandom`, which requires the `wasm_js` feature +(renamed from `js` in getrandom 0.2; uuid 1.21+ requires getrandom ^0.4) to source entropy from the +Web Crypto API (`crypto.getRandomValues()`). This must be declared as a direct dependency in the +application's `Cargo.toml` at the wasm32 cfg section. + +UUID v4 produces 36-character hyphenated strings (e.g. `550e8400-e29b-41d4-a716-446655440000`). +The design doc originally specified NanoID (~21 chars); UUID v4 is slightly longer but universally +supported and zero-risk on the Workers target. The DB schema comment should be updated accordingly. + + + +Add a `generate_id()` public function to `src/lib.rs` that: +- Returns a new UUID v4 as a `String` +- Compiles correctly for both the native host target AND `wasm32-unknown-unknown` +- Has a rustdoc comment with a doc-example (which also serves as a doctest) + + + + +## 1. Cargo.toml changes + +Add `uuid` to the shared (all-targets) dependencies section: + +```toml +[dependencies] +uuid = { version = "1", features = ["v4", "serde"] } +``` + +Add `getrandom` with the `wasm_js` feature under the wasm32 cfg section (so native builds don't pull +in wasm-bindgen). **uuid 1.21+ requires getrandom ^0.4**; getrandom 0.4 renamed the `js` feature +to `wasm_js`. Also shared with the passphrase generator (ticket 03bb91 / TRIAGE 6ed325): + +```toml +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.4", features = ["wasm_js"] } +``` + +## 2. src/lib.rs — generate_id() + +```rust +/// Generates a new UUID v4 string for use as a database primary key. +/// +/// Returns a 36-character hyphenated UUID string. Compatible with both +/// native and `wasm32-unknown-unknown` targets (uses Web Crypto API via +/// `getrandom/wasm_js` on WASM). +/// +/// # Examples +/// +/// ``` +/// let id = quotesdb::generate_id(); +/// assert_eq!(id.len(), 36); +/// assert_eq!(id.chars().filter(|&c| c == '-').count(), 4); +/// ``` +pub fn generate_id() -> String { + uuid::Uuid::new_v4().to_string() +} +``` + +## 3. Callers + +- `PUT /api/quotes` handler (ticket 05f8ae): call `generate_id()` to produce the new quote's `id` +- No other callers at this stage + +## 4. DB schema comment update + +In `docs/plans/2026-02-27-quotesdb-design.md` and `CLAUDE.md` design reference, update the schema +comment from: + +```sql +id TEXT PRIMARY KEY, -- NanoID (~21 chars) +``` + +to: + +```sql +id TEXT PRIMARY KEY, -- UUID v4 (36 chars), generated by generate_id() +``` + + + + +- `generate_id()` must be in `src/lib.rs` (shared code, not bin-specific) +- UUID v4 is the only correct choice — do NOT use `nanoid`, `rand::thread_rng`, or any + crate that pulls in thread-local RNG primitives for WASM +- `getrandom = { version = "0.4", features = ["wasm_js"] }` must be in the wasm32 cfg section only, + not in `[dependencies]`, to avoid pulling wasm-bindgen into native builds +- Do NOT use getrandom 0.2 or the old `js` feature name — uuid 1.21+ requires getrandom ^0.4 +- All public items must have rustdoc comments (per project style) + + + +Use `superpowers:test-driven-development` — write a unit test verifying length (36) and hyphen +count (4) before implementing. +Use `superpowers:verification-before-completion` before closing. + + + +Run in order from the `quotesdb/` directory: + +```sh +cargo fmt +cargo check +cargo clippy +cargo test +``` + + + +`feat(quotesdb): add generate_id() using UUID v4 — WASM-compatible ID generation` + diff --git a/quotesdb/.nbd/tickets/886bfd.md b/quotesdb/.nbd/tickets/886bfd.md index 707e306..62625cd 100644 --- a/quotesdb/.nbd/tickets/886bfd.md +++ b/quotesdb/.nbd/tickets/886bfd.md @@ -3,7 +3,7 @@ title = "Implement GET /api/quotes — paginated list with author filter (case-i priority = 5 status = "todo" ticket_type = "task" -dependencies = ["f3dc74", "a5049d", "d792e2", "175382"] +dependencies = ["a5049d", "d792e2", "175382"] +++ diff --git a/quotesdb/.nbd/tickets/8892d5.md b/quotesdb/.nbd/tickets/8892d5.md new file mode 100644 index 0000000..c3e3fc4 --- /dev/null +++ b/quotesdb/.nbd/tickets/8892d5.md @@ -0,0 +1,102 @@ ++++ +title = "Add build.rs — convert api/openapi.yaml to JSON at compile time for Workers embed" +priority = 8 +status = "todo" +ticket_type = "task" +dependencies = [] ++++ + + +Resolved from TRIAGE ticket 2ec8b1. The `GET /api/` endpoint must serve the OpenAPI spec as JSON. + +The three strategies were: +1. Compile-time embed (chosen) +2. Runtime load from filesystem — impossible on Cloudflare Workers (no filesystem) +3. utoipa programmatic generation — significant complexity; spec already exists and is complete + +The chosen approach: a `build.rs` script reads `api/openapi.yaml`, parses it to a +`serde_json::Value`, serialises it as compact JSON, and writes the result to +`$OUT_DIR/openapi.json`. The `GET /api/` handler then serves this via: + +```rust +const OPENAPI_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/openapi.json")); +``` + +This means: +- `serde_yaml` ships only as a `[build-dependencies]` entry — it never enters the Workers binary. +- The handler is a zero-overhead static string response with no runtime parsing. +- `cargo:rerun-if-changed=api/openapi.yaml` ensures the conversion re-runs whenever the spec + is edited — no manual JSON regeneration step needed. +- `api/openapi.yaml` remains the single source of truth; the JSON output is ephemeral (in + `$OUT_DIR`, not committed to the repository). + + + +1. Create `build.rs` at the `quotesdb/` project root containing: + +```rust +use std::{env, fs, path::Path}; + +fn main() { + // Re-run this script whenever the OpenAPI spec changes. + println!("cargo:rerun-if-changed=api/openapi.yaml"); + + let yaml = + fs::read_to_string("api/openapi.yaml").expect("api/openapi.yaml not found"); + + // Parse YAML to a generic JSON value, then re-serialise as compact JSON. + // serde_yaml is a build-only dependency — it does not appear in the final binary. + let value: serde_json::Value = + serde_yaml::from_str(&yaml).expect("api/openapi.yaml is invalid YAML"); + let json = serde_json::to_string(&value).expect("JSON serialisation failed"); + + let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set"); + let out_path = Path::new(&out_dir).join("openapi.json"); + fs::write(&out_path, json).expect("failed to write openapi.json"); +} +``` + +2. Add the following to `Cargo.toml` (ticket 1f5bb5 should also include this): + +```toml +[build-dependencies] +serde_json = "1" +serde_yaml = "0.9" +``` + +3. Verify the build succeeds and `$OUT_DIR/openapi.json` is produced: + +```sh +cargo check +# $OUT_DIR is typically target/debug/build/quotesdb-*/out/openapi.json +``` + + + +- `serde_yaml` must be a `[build-dependencies]` entry only — NOT in `[dependencies]`. + Adding it to `[dependencies]` would bloat the Workers WASM binary. +- Do NOT commit `$OUT_DIR/openapi.json` — it is generated automatically at build time. +- The `build.rs` file lives at the crate root (same level as `Cargo.toml`), not in `src/`. +- `api/openapi.yaml` is the source of truth; do not create or commit an `api/openapi.json`. + + + +Ticket 28e7d9 (GET /api/ handler) depends on this ticket. The handler uses +`include_str!(concat!(env!("OUT_DIR"), "/openapi.json"))` to serve the spec — see 28e7d9 +for the Axum handler implementation. + + + +```sh +cargo fmt +cargo check +cargo clippy +cargo test +``` + + + +`chore(quotesdb): add build.rs to convert api/openapi.yaml to JSON at compile time` + + +quotesdb/api diff --git a/quotesdb/.nbd/tickets/893eba.md b/quotesdb/.nbd/tickets/893eba.md index 3ab1985..416225d 100644 --- a/quotesdb/.nbd/tickets/893eba.md +++ b/quotesdb/.nbd/tickets/893eba.md @@ -3,7 +3,7 @@ title = "Test suite: tag operations — create with tags, list by tag filter, up priority = 5 status = "todo" ticket_type = "task" -dependencies = ["ce1e4f", "9b581f", "175382"] +dependencies = ["9b581f", "175382"] +++ diff --git a/quotesdb/.nbd/tickets/8c87db.md b/quotesdb/.nbd/tickets/8c87db.md index e77bfb6..e14a872 100644 --- a/quotesdb/.nbd/tickets/8c87db.md +++ b/quotesdb/.nbd/tickets/8c87db.md @@ -3,7 +3,7 @@ title = "Test suite: DELETE /api/quotes/:id — valid auth 204 no body, wrong au priority = 5 status = "todo" ticket_type = "task" -dependencies = ["ce1e4f", "9b581f", "b20b5a"] +dependencies = ["9b581f", "b20b5a"] +++ diff --git a/quotesdb/.nbd/tickets/93515e.md b/quotesdb/.nbd/tickets/93515e.md index a0b637a..3539e0a 100644 --- a/quotesdb/.nbd/tickets/93515e.md +++ b/quotesdb/.nbd/tickets/93515e.md @@ -3,21 +3,33 @@ title = "Set up ui/Cargo.toml with Yew/Wasm dependencies (yew, yew-router, gloo, priority = 8 status = "todo" ticket_type = "task" -dependencies = ["c3503b", "166996"] +dependencies = ["166996"] +++ - The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and hosted on Cloudflare Pages. It communicates with the backend API via `fetch` calls. Source lives in `src/bin/ui/`. Run with `trunk serve` for local development. + +TRIAGE resolved (ticket 166996): use yew = "0.22", yew-router = "0.19", wasm-bindgen = "0.2" (compatible with wasm-bindgen-cli 0.2.108 in the Nix dev shell). -Add UI-side Yew/Wasm dependencies to `Cargo.toml` under `[target.'cfg(target_arch = "wasm32")'.dependencies]`. Resolve TRIAGE ticket 166996 (Yew version selection) first, then add: `yew`, `yew-router`, `gloo` (timers, fetch), `wasm-bindgen`, `web-sys`, `serde`, `serde_json`, and `wasm-bindgen-futures`. +Add UI-side Yew/Wasm dependencies to `Cargo.toml` under `[target.'cfg(target_arch = "wasm32")'.dependencies]`. Use the following pinned versions: + +- `yew = "0.22"` (latest stable: 0.22.1) +- `yew-router = "0.19"` (latest stable: 0.19.0, requires yew ^0.22.0) +- `gloo` — timers and fetch utilities for Yew +- `wasm-bindgen = "0.2"` (compatible with nix-shell wasm-bindgen-cli 0.2.108) +- `web-sys` — browser API bindings +- `serde` with `derive` feature +- `serde_json` +- `wasm-bindgen-futures` — for async fetch in Wasm + +Also add `serde` and `serde_json` to `[dependencies]` (non-target-scoped) so shared lib types can use derive macros on both targets. -- Resolve TRIAGE ticket 166996 (Yew + yew-router version compatibility) before pinning versions. -- All UI dependencies must be scoped to the wasm32 target — they must not appear in host builds. -- `wasm-bindgen` version must be compatible with the `wasm-bindgen-cli` version in the Nix dev shell. +- All UI-only dependencies must be scoped to the wasm32 target — they must not appear in host builds. +- `wasm-bindgen` version must match the `wasm-bindgen-cli` version in the Nix dev shell (currently 0.2.108). +- `serde` and `serde_json` are needed on both targets for shared types — add to `[dependencies]` not the wasm target section. @@ -36,4 +48,4 @@ trunk build `chore(quotesdb): set up ui Cargo dependencies for Yew/Wasm` - + \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/93f1b6.md b/quotesdb/.nbd/tickets/93f1b6.md index 5401a7c..77f45d9 100644 --- a/quotesdb/.nbd/tickets/93f1b6.md +++ b/quotesdb/.nbd/tickets/93f1b6.md @@ -3,7 +3,7 @@ title = "Test suite: GET /api/quotes — pagination (page=1, page=N, out-of-rang priority = 5 status = "todo" ticket_type = "task" -dependencies = ["ce1e4f", "9b581f", "886bfd"] +dependencies = ["9b581f", "886bfd"] +++ diff --git a/quotesdb/.nbd/tickets/9b581f.md b/quotesdb/.nbd/tickets/9b581f.md index 1bc699c..e6832b1 100644 --- a/quotesdb/.nbd/tickets/9b581f.md +++ b/quotesdb/.nbd/tickets/9b581f.md @@ -3,29 +3,102 @@ title = "Implement test server harness — spawn quotesdb-api with temp SQLite D priority = 8 status = "todo" ticket_type = "task" -dependencies = ["ce1e4f", "5f5ba0", "2ab7a8", "fba598"] +dependencies = ["5f5ba0", "2ab7a8", "fba598"] +++ -Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment. The test harness spawns the API server on a random port and returns the base URL. +Integration tests live in `tests/` and exercise the API binary against a temporary SQLite database. They run with `cargo test` and must not require a running Cloudflare environment. + +Architecture decided in triage: +- 2ab7a8: Server is spawned as a tokio task using the native Axum path (cfg-split, no workers-rs on host) +- fba598: Isolation strategy is **per-test temp SQLite file** via `tempfile` crate (transaction rollback cannot intercept server-side pool commits; in-memory SQLite is incompatible with multi-connection SQLx pools) +- 0d84fa: HTTP client for tests is `reqwest` with `tokio::test` -Implement a test server harness in `tests/helpers.rs` (or similar) that: -1. Creates a temporary SQLite database file (or in-memory DB) -2. Runs migrations to initialise the schema -3. Spawns the `quotesdb-api` binary on a random available port -4. Returns the base URL (e.g. `http://127.0.0.1:PORT`) for use in test functions -5. Cleans up (drops DB, stops server) when the test ends +Implement `tests/helpers.rs` providing a `spawn_test_server()` async function that: +1. Creates a temporary SQLite file via `tempfile::TempDir` +2. Opens a `SqlitePool` connected to that file +3. Runs migrations via `sqlx::migrate!()` +4. Builds the Axum router via `build_router(repo)` (same router used by the API binary) +5. Binds to a random port with `TcpListener::bind("127.0.0.1:0")` +6. Spawns the server with `tokio::spawn(axum::serve(...))` +7. Returns a `TestContext` that holds the `TempDir` (RAII cleanup), base URL, and task handle + +```rust +// tests/helpers.rs +use std::sync::Arc; +use tempfile::TempDir; +use tokio::net::TcpListener; +use sqlx::SqlitePool; + +pub struct TestContext { + _db_dir: TempDir, // deleted on drop + pub base_url: String, + _server: tokio::task::JoinHandle<()>, +} + +pub async fn spawn_test_server() -> TestContext { + let db_dir = TempDir::new().expect("temp dir"); + let db_path = db_dir.path().join("test.sqlite"); + let db_url = format!("sqlite:{}?mode=rwc", db_path.display()); + + let pool = SqlitePool::connect(&db_url).await.expect("pool"); + sqlx::migrate!("./migrations").run(&pool).await.expect("migrations"); + + let repo = Arc::new(NativeRepository::new(pool)); + let app = build_router(repo); + + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let port = listener.local_addr().unwrap().port(); + + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + TestContext { + _db_dir: db_dir, + base_url: format!("http://127.0.0.1:{port}"), + _server: server, + } +} +``` + +Usage in a test: +```rust +#[tokio::test] +async fn test_create_quote() { + let ctx = spawn_test_server().await; + let client = reqwest::Client::new(); + let res = client + .put(format!("{}/api/quotes", ctx.base_url)) + .json(&serde_json::json!({"text": "hello", "author": "world"})) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201); +} +``` + + -- Resolve TRIAGE ticket 2ab7a8 (workers-rs test binary compatibility) and 33ed29 (local dev config) before implementing. -- Resolve TRIAGE ticket fba598 (test isolation strategy: per-test DB vs transaction rollback) before deciding on isolation approach. -- The harness must be reusable across all test modules — import it as a shared helper. -- Each test must get a clean, isolated database state (no cross-test pollution). +- `build_router` and `NativeRepository` must be pub-accessible from the `quotesdb` crate (may require re-exports in `src/lib.rs`). +- `sqlx::migrate!()` macro path is relative to the crate root — migrations must be in `migrations/` at the crate root. +- Each test gets a unique `TempDir`, so parallel test execution (`cargo test`) is safe. +- Do not set `--test-threads=1`; parallel execution must work. +- The `_server` handle is intentionally leaked (tokio runtime drops it when the test ends). + +In `[dev-dependencies]` (ticket 5f5ba0): +- `tempfile = "3"` +- `reqwest = { version = "0.12", features = ["json"] }` +- `tokio = { version = "1", features = ["full"] }` +- `serde_json = "1"` + + Use `superpowers:test-driven-development` — the harness is itself tested by running `cargo test`. Use `superpowers:verification-before-completion` before closing. @@ -43,5 +116,5 @@ cargo test -`test(quotesdb): implement test server harness with temp SQLite DB` +`test(quotesdb): implement test server harness with per-test temp SQLite DB` diff --git a/quotesdb/.nbd/tickets/9c9546.md b/quotesdb/.nbd/tickets/9c9546.md new file mode 100644 index 0000000..e0f546d --- /dev/null +++ b/quotesdb/.nbd/tickets/9c9546.md @@ -0,0 +1,66 @@ ++++ +title = "Create .env.example documenting DATABASE_URL and all local dev environment variables" +priority = 5 +status = "todo" +ticket_type = "task" +dependencies = ["33ed29"] ++++ + + +TRIAGE 33ed29 resolved the local dev database strategy: rusqlite with a local SQLite file. +The only environment variable required for local development is `DATABASE_URL` (optional — defaults +to `./quotesdb.sqlite`). No Turso, no wrangler, no Cloudflare account needed locally. + +A `.env.example` file in the project root serves as self-documenting reference for contributors. +The `.env` file itself is gitignored (never committed). `.env.example` is committed and documents +all variables with their defaults and a brief description. + + + +Create `quotesdb/.env.example` with the following content: + +```sh +# quotesdb local development environment variables +# Copy to .env and customise. The .env file is gitignored and must never be committed. +# +# All variables below have sensible defaults for local development and are OPTIONAL. + +# Path to the local SQLite database file used by `cargo run` (native API server). +# The file is created automatically on first run; migrations run on startup. +# In production this variable is unused — the Workers runtime uses the D1 binding. +DATABASE_URL=./quotesdb.sqlite +``` + +Also ensure `.gitignore` in the `quotesdb/` root has an entry for `.env`: + +```gitignore +.env +``` + + + +- TRIAGE 33ed29: rusqlite + local SQLite file. `DATABASE_URL` is the only required env var. +- No Cloudflare account, no wrangler, no Turso credentials needed for local dev. + + + +- `.env.example` must be committed to the repo. `.env` must be gitignored. +- Only document variables that are actually used by the codebase (see ticket 6e829e / 00aff0 for where DATABASE_URL is read). +- Do not add placeholder values for production secrets — `.env.example` is for local dev only. +- If production-only secrets (e.g., Cloudflare API tokens for infra) are identified later, add them in a separate PR with appropriate comments. + + + +Verify `.env.example` is tracked and `.env` is gitignored: + +```sh +git status # .env.example should appear as a new untracked file +echo "test" > .env +git status # .env must NOT appear (should be ignored) +rm .env +``` + + + +`chore(quotesdb): add .env.example documenting DATABASE_URL for local dev` + \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/9ef703.md b/quotesdb/.nbd/tickets/9ef703.md new file mode 100644 index 0000000..66e3d9b --- /dev/null +++ b/quotesdb/.nbd/tickets/9ef703.md @@ -0,0 +1,64 @@ ++++ +title = "Add `_redirects` SPA fallback — create file and include in Trunk build via copy-file" +priority = 8 +status = "todo" +ticket_type = "task" +dependencies = [] ++++ + + +Resolved from TRIAGE ticket e2bd9b. Yew uses client-side routing (BrowserRouter), so a direct +URL such as `/browse` or `/quotes/abc123` will 404 on Cloudflare Pages unless a fallback is +configured. The chosen approach is a `_redirects` file with `/* /index.html 200`, which instructs +Cloudflare Pages to serve `index.html` for any path that does not match a static asset — without +changing the URL in the browser (HTTP 200 proxy, not a redirect). + +This file must be present in the `dist/` output directory that `wrangler pages deploy` uploads. +Trunk handles this via its `copy-file` asset type: adding a `` line to `index.html` causes Trunk to copy the file verbatim into `dist/` +on every build. + +The API Worker claims `/api/*` at the Cloudflare routing level before Pages processes the request, +so the `/* /index.html 200` catch-all does not interfere with the API. + + + +1. Create `_redirects` at the `quotesdb/` project root (next to `index.html`) containing exactly: + + ``` + /* /index.html 200 + ``` + +2. Add the following line to `index.html` inside ``, alongside the other `data-trunk` links: + + ```html + + ``` + +3. Run `trunk build` and verify that `dist/_redirects` exists with the correct single-line content. + +4. Commit with: + ``` + chore(quotesdb): add _redirects SPA fallback for Cloudflare Pages routing + ``` + + + +- The `_redirects` file must live at the project root (same level as `index.html` and `Trunk.toml`), + not inside `src/` or a `static/` subdirectory. +- The line must use a 200 (proxy) code, not 301 or 302 — 200 preserves the URL in the browser, + which is required for client-side routing to work correctly. +- Do NOT add `/* /index.html 200` to the `_headers` file — headers do not fix routing. +- This ticket is scoped to file creation and Trunk build verification only. The CI/CD deploy + workflow is handled separately in ticket 5137d7. + + + +```sh +trunk build +ls dist/_redirects # must exist +cat dist/_redirects # must print: /* /index.html 200 +``` + + +quotesdb/ui diff --git a/quotesdb/.nbd/tickets/a23489.md b/quotesdb/.nbd/tickets/a23489.md index d157f26..afe8b5e 100644 --- a/quotesdb/.nbd/tickets/a23489.md +++ b/quotesdb/.nbd/tickets/a23489.md @@ -3,30 +3,62 @@ title = "Define Cloudflare Workers script resource — WASM artifact, D1 binding priority = 7 status = "todo" ticket_type = "task" -dependencies = ["25c413", "2d1371", "d0da0b", "07cafb", "efee79"] +dependencies = ["2d1371", "d0da0b", "07cafb", "efee79"] +++ -Infrastructure is managed with OpenTofu using the Cloudflare provider. Configuration lives in `infra/`. Resources include a Cloudflare Worker (API), Cloudflare D1 database (bound to the worker), and a Cloudflare Pages project (UI frontend). +Infrastructure is managed with OpenTofu using the Cloudflare provider. -The Cloudflare Worker hosts the `quotesdb-api` binary compiled for the Workers runtime. It is bound to the D1 database and deployed via OpenTofu. +Trages resolved: +- 07cafb: D1 binding — use `cloudflare_d1_database.db.id` directly; OpenTofu dependency graph handles ordering. No two-phase apply or data source needed. +- efee79: Correct resource name — `cloudflare_workers_script` (plural, confirmed from provider v4 source). -Define the Cloudflare Workers script resource in `infra/worker.tf`: -1. `cloudflare_workers_script` resource (resolve TRIAGE ticket efee79 for correct resource name in current provider version) -2. Set the WASM artifact path (the compiled `api` binary) -3. Bind the D1 database (name must match what workers-rs expects — resolve TRIAGE ticket 07cafb) -4. Set required environment variables +Define the Cloudflare Workers script resource in `infra/worker.tf`. Every block must have a comment. - -- Resolve TRIAGE ticket efee79 (correct Workers resource name) before writing the resource. -- Resolve TRIAGE ticket 07cafb (D1 chicken-and-egg) before wiring the D1 binding. -- The D1 binding name in the Worker must match the binding name in the workers-rs code. - + +```hcl +# infra/worker.tf + +# Cloudflare Workers script for the quotesdb API. +# Compiled from the `api` binary targeting wasm32-unknown-unknown. +# The Wasm artifact must be built before running `tofu apply`: +# cargo build --release --target wasm32-unknown-unknown --bin api +resource "cloudflare_workers_script" "api" { + account_id = var.cloudflare_account_id + + # Script name used in Cloudflare dashboard and for routing. + name = "quotesdb-api" + + # Compiled Wasm binary content, base64-encoded. + # Path is relative to the infra/ directory. + content = filebase64("../target/wasm32-unknown-unknown/release/api.wasm") + + # D1 database binding — referenced in workers-rs code as `env.DB`. + # `database_id` is resolved at apply time from the D1 resource output. + # OpenTofu automatically creates the D1 database before this script + # because of the attribute reference below (no explicit depends_on needed). + d1_database_binding { + name = "DB" + database_id = cloudflare_d1_database.db.id + } + + # Workers runtime compatibility date. + compatibility_date = "2024-09-23" +} +``` + + + +- The `content` attribute expects base64-encoded script bytes. For a Wasm Worker, this is the raw compiled Wasm file, not a JS bundle. +- The binding `name = "DB"` must match exactly what the workers-rs API code uses (`env.DB`). Verify this in `src/bin/api/main.rs`. +- `(known after apply)` for `database_id` in `tofu plan` is expected and correct — OpenTofu resolves it at apply time. +- The Wasm binary must be compiled before `tofu apply`. This is handled by the Gitea Actions CI/CD workflow (ticket to be created; also see ticket 5137d7 for the UI workflow pattern). + Run from the `infra/` directory: diff --git a/quotesdb/.nbd/tickets/a5049d.md b/quotesdb/.nbd/tickets/a5049d.md index 44c0d08..a192e63 100644 --- a/quotesdb/.nbd/tickets/a5049d.md +++ b/quotesdb/.nbd/tickets/a5049d.md @@ -3,7 +3,7 @@ title = "Implement database connection module and SQLx migrations (quotes + quot priority = 8 status = "todo" ticket_type = "task" -dependencies = ["f3dc74", "1f5bb5", "e8a330", "580e66", "33ed29"] +dependencies = ["1f5bb5", "580e66", "33ed29"] +++ @@ -16,17 +16,37 @@ The database schema consists of two tables: - `quote_tags` — join table for quote-to-tag relationships with cascade delete + +**This ticket's SQLx-based goal has been superseded by ticket 00aff0.** + +TRIAGE e8a330 concluded that SQLx is incompatible with workers-rs/D1. The new approach uses: +- workers-rs `D1Database` bindings for the WASM/production target +- `rusqlite` + `tokio-rusqlite` for the native/test target +- A `QuoteRepository` async trait as the shared interface +- `cfg(target_arch = "wasm32")` for compile-time target selection + +See ticket 00aff0 for the full implementation plan. + +This ticket remains open as tracking context but its implementation is covered by 00aff0. + + -Implement `src/bin/api/db.rs` (or equivalent module) providing: -1. A database connection pool constructor (Turso/SQLite locally, D1 in production) -2. SQLx migrations that create the `quotes` and `quote_tags` tables if they don't exist -3. Re-export the pool type for use by handlers +~~Implement `src/bin/api/db.rs` (or equivalent module) providing:~~ +~~1. A database connection pool constructor (Turso/SQLite locally, D1 in production)~~ +~~2. SQLx migrations that create the `quotes` and `quote_tags` tables if they don't exist~~ +~~3. Re-export the pool type for use by handlers~~ + +**Updated goal (see ticket 00aff0):** Implement `src/bin/api/db/` module with: +1. `QuoteRepository` trait in `db/mod.rs` +2. `D1Repository` in `db/d1.rs` (`#[cfg(target_arch = "wasm32")]`) +3. `NativeRepository` in `db/native.rs` (`#[cfg(not(target_arch = "wasm32"))]`) +4. SQL migration strings in `db/migrations.rs` -- Migration strategy depends on TRIAGE ticket 580e66 (DB migration strategy for Workers) — resolve that first. +- TRIAGE 580e66 resolved (same decision as 5c0c64): D1 production schema is applied via `wrangler d1 execute` (separate CI step). The Workers fetch handler does NOT run migrations. Native `main()` calls `repo.run_migrations()` via rusqlite on startup. - Schema must exactly match the design: NanoID primary key, `auth_code` stored plaintext, optional `source` and `date` fields, cascade delete on `quote_tags`. -- SQLx compatibility with workers-rs is tracked in TRIAGE ticket e8a330 — check that first. +- SQLx is NOT used. Use workers-rs D1 bindings (wasm32) and rusqlite (native). See 00aff0. diff --git a/quotesdb/.nbd/tickets/a6bce1.md b/quotesdb/.nbd/tickets/a6bce1.md index a112c31..a19f61c 100644 --- a/quotesdb/.nbd/tickets/a6bce1.md +++ b/quotesdb/.nbd/tickets/a6bce1.md @@ -3,7 +3,7 @@ title = "Write unit tests in api/src/tests.rs covering all handlers, auth logic, priority = 6 status = "todo" ticket_type = "task" -dependencies = ["f3dc74", "2ce22e", "5dbb7d", "886bfd", "05f8ae", "5d9f5a", "b20b5a", "28e7d9"] +dependencies = ["2ce22e", "5dbb7d", "886bfd", "05f8ae", "5d9f5a", "b20b5a", "28e7d9"] +++ diff --git a/quotesdb/.nbd/tickets/a91260.md b/quotesdb/.nbd/tickets/a91260.md index 6a53ee6..8c3d8cf 100644 --- a/quotesdb/.nbd/tickets/a91260.md +++ b/quotesdb/.nbd/tickets/a91260.md @@ -1,9 +1,9 @@ +++ title = "[TRIAGE] workers-rs compatibility with native Rust test binaries (may need separate native feature flag)" priority = 9 -status = "todo" +status = "done" ticket_type = "task" -dependencies = ["f3dc74"] +dependencies = [] +++ @@ -20,11 +20,33 @@ workers-rs compatibility with native Rust test binaries: the workers-rs crate ta 3. **Separate test binary** — integration tests spawn a separately compiled native test server binary. - -1. Research the options above and choose the best approach for this project. -2. Update ticket 6e829e (api main.rs) and ticket 9b581f (test harness) with the chosen approach. -3. Mark this ticket done with a note on the chosen approach in the body or a comment. - + +**Option 1 variant: `cfg(target_arch = "wasm32")` — no feature flag needed.** + +Note: `target_env = "worker"` is incorrect. The right discriminant is `target_arch = "wasm32"`. + +The `cfg(target_arch)` split is cleaner than a feature flag because it is tied to the actual +build target, not an opt-in flag that could be forgotten: + +- `[target.'cfg(target_arch = "wasm32")'.dependencies]` → workers-rs (pulled in only for WASM) +- `[target.'cfg(not(target_arch = "wasm32"))'.dependencies]` → tokio, axum, rusqlite (native only) + +In `main.rs`: +```rust +#[cfg(target_arch = "wasm32")] +#[worker::event(fetch)] +pub async fn main(...) { /* workers-rs entry point */ } + +#[cfg(not(target_arch = "wasm32"))] +#[tokio::main] +async fn main() { /* native Axum server on :8080 */ } +``` + +`cargo test` (native host) automatically compiles the native path. No special flags. +No feature flag pollution. Resolved as part of TRIAGE e8a330 (DB strategy decision). + +See implementation ticket 00aff0 for full details. + `chore(quotesdb): resolve triage — workersrs-compatibility-with-native-rust-test-binaries-may-n` diff --git a/quotesdb/.nbd/tickets/a9534d.md b/quotesdb/.nbd/tickets/a9534d.md index e023817..4fee687 100644 --- a/quotesdb/.nbd/tickets/a9534d.md +++ b/quotesdb/.nbd/tickets/a9534d.md @@ -1,9 +1,9 @@ +++ title = "[TRIAGE] Local dev CORS and Trunk API proxy config (trunk serve proxying to api on different port)" priority = 8 -status = "todo" +status = "done" ticket_type = "task" -dependencies = ["c3503b"] +dependencies = [] +++ @@ -20,12 +20,23 @@ Local dev CORS and Trunk proxy config: during `trunk serve`, the UI runs on one 3. **Same-origin in production** — in production, both are served from the same Cloudflare account; in dev, use the Trunk proxy. - -1. Research the options above and choose the best approach for this project. -2. Update ticket dc3d2b (Trunk.toml) and ticket 1e6a09 (API client module) with the chosen approach. -3. Mark this ticket done with a note on the chosen approach in the body or a comment. + +**Chosen approach: Option 1 — Trunk proxy.** + +Rationale: +- Mirrors the production architecture: Cloudflare routes `/api/*` to the Worker at the same domain as the Pages site. No CORS configuration is needed in production either. +- Frontend uses **relative URLs** (`/api/quotes`, not `http://localhost:3000/api/quotes`). The same paths work in both dev (Trunk proxies them) and production (Cloudflare routes them). +- Zero CORS configuration: no `tower-http` CORS middleware, no `Access-Control-Allow-Origin` headers. Simpler API, smaller attack surface. +- Port: API runs on `localhost:3000` via `cargo run` (plain Axum/Tokio for local dev). + +Implementation: +- `Trunk.toml` — add `[[proxy]] rewrite = "/api" backend = "http://localhost:3000"`. See ticket 00d6d7. +- `src/bin/ui/api.rs` — use relative URLs only. See ticket 1e6a09. + +Updated tickets: dc3d2b (Trunk.toml setup), 1e6a09 (API client module). +Created ticket: 00d6d7 (dedicated implementation task for the proxy config). `chore(quotesdb): resolve triage — local-dev-cors-and-trunk-api-proxy-config-trunk-serve-proxyi` - + \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/aa0eab.md b/quotesdb/.nbd/tickets/aa0eab.md index 62856b9..d2c681e 100644 --- a/quotesdb/.nbd/tickets/aa0eab.md +++ b/quotesdb/.nbd/tickets/aa0eab.md @@ -3,7 +3,7 @@ title = "Test suite: GET /api/quotes/random — 200 with quote, 404 when databas priority = 5 status = "todo" ticket_type = "task" -dependencies = ["ce1e4f", "9b581f", "2ce22e"] +dependencies = ["9b581f", "2ce22e"] +++ diff --git a/quotesdb/.nbd/tickets/ae6a82.md b/quotesdb/.nbd/tickets/ae6a82.md index 19d5938..de0ac97 100644 --- a/quotesdb/.nbd/tickets/ae6a82.md +++ b/quotesdb/.nbd/tickets/ae6a82.md @@ -3,7 +3,7 @@ title = "Define Cloudflare Worker route/domain — worker.dev subdomain or custo priority = 6 status = "todo" ticket_type = "task" -dependencies = ["25c413", "a23489"] +dependencies = ["a23489"] +++ diff --git a/quotesdb/.nbd/tickets/ae886f.md b/quotesdb/.nbd/tickets/ae886f.md index 60e4795..8363e9c 100644 --- a/quotesdb/.nbd/tickets/ae886f.md +++ b/quotesdb/.nbd/tickets/ae886f.md @@ -3,30 +3,57 @@ title = "Define Cloudflare Pages project resource — build config, output dir, priority = 7 status = "todo" ticket_type = "task" -dependencies = ["25c413", "2d1371", "fc9bfd", "e2bd9b"] +dependencies = ["2d1371", "fc9bfd"] +++ Infrastructure is managed with OpenTofu using the Cloudflare provider. Configuration lives in `infra/`. Resources include a Cloudflare Worker (API), Cloudflare D1 database (bound to the worker), and a Cloudflare Pages project (UI frontend). -Cloudflare Pages hosts the Yew/Wasm frontend. Resolve TRIAGE ticket fc9bfd (Pages build strategy: CI build vs pre-built artifact upload) before implementing this resource. +Build strategy resolved in triage fc9bfd: **pre-built artifact + Gitea Actions + `wrangler pages deploy`**. Pages CI build is not used. The `cloudflare_pages_project` resource is configured for direct upload (no git connection). The actual artifact deployment is handled by `.gitea/workflows/deploy-ui.yml` (ticket 3781c9). Define the Cloudflare Pages project resource in `infra/pages.tf`: -1. `cloudflare_pages_project` resource for the `quotesdb-ui` project -2. Configure the build settings (build command: `trunk build`, output directory: `dist/`) -3. Connect to the git repository or configure for direct artifact upload (per TRIAGE fc9bfd) +1. `cloudflare_pages_project` resource named `quotesdb-ui` +2. Set `production_branch = "quotesdb"` (the integration branch) +3. Configure `deployment_configs` with `production.compatibility_date` and `production.d1_databases` if needed +4. Do NOT configure a git source block — this project uses direct upload -Every block must have a comment. +Every block must have a comment explaining its purpose. -- Resolve TRIAGE ticket fc9bfd (Pages build strategy) before choosing git-connected vs artifact upload. -- Resolve TRIAGE ticket e2bd9b (SPA routing — 404 fallback) before finalising Pages config. -- The output directory must match Trunk's `dist/` output. +- Do NOT add a `source` block to the Pages project (no git-connected build — direct upload only). +- SPA routing (triage e2bd9b) is resolved: a `_redirects` file (`/* /index.html 200`) is included + in the Trunk build output via `` (ticket 9ef703). + No changes are needed in the OpenTofu Pages resource — Cloudflare Pages processes `_redirects` + automatically from the uploaded `dist/` directory. +- The output directory (`dist/`) is a Trunk convention; it is documented here for reference but not configured in OpenTofu (wrangler handles it at deploy time). +- The Pages project name `quotesdb-ui` must match the name used in `wrangler pages deploy --project-name quotesdb-ui`. + +```hcl +# infra/pages.tf + +# Cloudflare Pages project for the quotesdb Yew/Wasm frontend. +# Uses direct upload — artifacts are deployed via wrangler in Gitea Actions (ticket 3781c9). +resource "cloudflare_pages_project" "ui" { + account_id = var.cloudflare_account_id + name = "quotesdb-ui" + production_branch = "quotesdb" + + # Deployment configuration for the production environment. + deployment_configs { + production { + compatibility_date = "2024-01-01" + # SPA routing: handled by dist/_redirects (/* /index.html 200) — see ticket 9ef703. + } + } +} +``` + + Run from the `infra/` directory: diff --git a/quotesdb/.nbd/tickets/af56a7.md b/quotesdb/.nbd/tickets/af56a7.md index 265d789..29100a2 100644 --- a/quotesdb/.nbd/tickets/af56a7.md +++ b/quotesdb/.nbd/tickets/af56a7.md @@ -1,33 +1,69 @@ +++ -title = "Document local dev environment — Turso/SQLite instead of D1, any wrangler.toml config required" +title = "Write docs/LOCAL_DEV.md — local dev quickstart (cargo run + trunk serve, rusqlite, DATABASE_URL)" priority = 5 status = "todo" ticket_type = "task" -dependencies = ["f3dc74", "33ed29"] +dependencies = ["33ed29"] +++ -The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file via Turso (development). Source lives in `src/bin/api/`. +The `quotesdb` API is built with Axum + Tokio, targeting Cloudflare Workers via `workers-rs`. It serves JSON at `/api/*` endpoints and persists data to Cloudflare D1 (production) or a local SQLite file (development). -Shared types and utilities are in `src/lib.rs` — code placed there must compile for both the host target and `wasm32-unknown-unknown`. +TRIAGE 33ed29 resolved the local dev database strategy: **plain rusqlite with a local SQLite file**. +No Turso, no wrangler, no Cloudflare account required for local development. -Local development uses Turso (file-backed SQLite) instead of Cloudflare D1. The API reads the database connection string from an environment variable. There may also be `wrangler.toml` configuration needed for `wrangler dev`. +Selection is **compile-time** via `cfg(target_arch = "wasm32")`: +- `wasm32` → workers-rs D1 bindings (production) +- native → `rusqlite` + `tokio-rusqlite` with a local `.sqlite` file (dev/test) + +The native `main()` (see ticket 6e829e) reads `DATABASE_URL` from the environment, defaulting to +`./quotesdb.sqlite`, and calls `repo.run_migrations()` on startup to create tables if they don't exist. -Write documentation (in `docs/PLANNING.md` or a dedicated `docs/LOCAL_DEV.md`) explaining how to set up and run the API locally: -1. How to install/run Turso for a local SQLite file -2. What environment variables to set (database URL, etc.) -3. How to run `cargo run` to start the API server -4. Any `wrangler.toml` configuration needed for `wrangler dev` (if applicable) -5. How the D1 vs Turso selection is made at runtime +Write `docs/LOCAL_DEV.md` explaining how to set up and run the quotesdb project locally: + +1. **Prerequisites** — Rust (via Nix flake), no Cloudflare account needed +2. **Running the API**: + - `cargo run` from the `quotesdb/` directory + - Listens on `localhost:3000` + - Creates `./quotesdb.sqlite` automatically on first run + - Override DB path: `DATABASE_URL=/path/to/db.sqlite cargo run` +3. **Running the UI**: + - `trunk serve` from the `quotesdb/` directory + - Listens on `localhost:8080` + - Proxies `/api/*` to `localhost:3000` (see Trunk.toml `[[proxy]]` block) +4. **Environment variables**: + - `DATABASE_URL` — path to SQLite file (optional, default: `./quotesdb.sqlite`) + - No other variables required for local dev +5. **Local dev workflow** (two terminals): + ```sh + # Terminal 1 — API + cargo run + # Terminal 2 — UI + trunk serve + # Open http://localhost:8080 + ``` +6. **No wrangler required** — `cargo run` uses the native Axum server with rusqlite directly. + Wrangler is only needed for Workers deployment (handled by CI/infra). +7. **Database notes**: + - Schema is applied automatically via `run_migrations()` on first `cargo run` + - Delete `./quotesdb.sqlite` to start fresh + - `sqlite3 ./quotesdb.sqlite` for manual inspection + +- TRIAGE 33ed29: rusqlite + local SQLite file (not Turso, not wrangler dev) +- TRIAGE a9534d: Trunk proxy for `/api/*` (not CORS middleware) +- TRIAGE e8a330: no SQLx, `cfg(target_arch)` split + + -- Do not commit any `.env` files — document the variables, not the values. -- Cross-reference the TRIAGE ticket 33ed29 decision on Turso vs D1 local selection strategy. +- Do not document `.env` files directly — list the env vars and their defaults, but note that `.env` is gitignored. +- Cross-reference tickets 6e829e (api main.rs), dc3d2b (Trunk.toml), and 00aff0 (DB abstraction). +- Keep it concise — it's a quickstart, not exhaustive reference docs. -`docs(quotesdb): document local dev environment setup for api` - +`docs(quotesdb): write LOCAL_DEV.md — local dev quickstart for api and ui` + \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/b20b5a.md b/quotesdb/.nbd/tickets/b20b5a.md index 11a88df..381aaaa 100644 --- a/quotesdb/.nbd/tickets/b20b5a.md +++ b/quotesdb/.nbd/tickets/b20b5a.md @@ -3,7 +3,7 @@ title = "Implement DELETE /api/quotes/:id — verify X-Auth-Code, cascade delete priority = 5 status = "todo" ticket_type = "task" -dependencies = ["f3dc74", "a5049d", "d792e2"] +dependencies = ["a5049d", "d792e2"] +++ diff --git a/quotesdb/.nbd/tickets/b3ef98.md b/quotesdb/.nbd/tickets/b3ef98.md index f5cacc2..0b8e9db 100644 --- a/quotesdb/.nbd/tickets/b3ef98.md +++ b/quotesdb/.nbd/tickets/b3ef98.md @@ -3,7 +3,7 @@ title = "Implement Author page (/author/:name) — paginated list of quotes by a priority = 5 status = "todo" ticket_type = "task" -dependencies = ["c3503b", "04f865", "1e6a09", "0d987f", "2c5a57", "d3d502", "fc2f51"] +dependencies = ["04f865", "1e6a09", "0d987f", "2c5a57", "d3d502", "fc2f51", "0fbdd5"] +++ diff --git a/quotesdb/.nbd/tickets/bb1514.md b/quotesdb/.nbd/tickets/bb1514.md new file mode 100644 index 0000000..33dd3e2 --- /dev/null +++ b/quotesdb/.nbd/tickets/bb1514.md @@ -0,0 +1,163 @@ ++++ +title = "Create infra/schema.sql — idempotent D1 schema for quotes and quote_tags" +priority = 7 +status = "todo" +ticket_type = "task" +dependencies = ["d0da0b", "5c0c64"] ++++ + + +TRIAGE 5c0c64 resolved: the chosen D1 migration strategy is a **separate wrangler step**. + +Production schema is applied once after `tofu apply` using: + +```sh +wrangler d1 execute quotesdb --file infra/schema.sql --remote +``` + +For local dev, the native `main()` calls `repo.run_migrations()` (rusqlite `execute_batch`). +For tests, test setup calls `NativeRepository::run_migrations()` directly. + +The D1 `run_migrations()` method in `D1Repository` (wasm32 path, ticket 00aff0) may still call +`CREATE TABLE IF NOT EXISTS` defensively on startup — but the canonical provisioning path for +production is the wrangler CLI command above, not a startup handler. + +`infra/schema.sql` is the single source of truth for the SQL that wrangler applies. The Rust +constants in `db/migrations.rs` (ticket 00aff0) contain the same SQL in split form suitable for +the D1 `prepare().run()` API and rusqlite `execute_batch`. + + + +Create `infra/schema.sql` — a self-contained, idempotent SQL file that provisions the full +`quotesdb` schema on a blank D1 database in a single wrangler command. + + + + +## 1. Create `infra/schema.sql` + +```sql +-- quotesdb D1 schema +-- ============================================================================= +-- Apply to production D1: +-- wrangler d1 execute quotesdb --file infra/schema.sql --remote +-- +-- Apply locally (wrangler dev): +-- wrangler d1 execute quotesdb --local --file infra/schema.sql +-- +-- For native dev/test builds, NativeRepository::run_migrations() applies +-- equivalent SQL automatically via rusqlite on startup. +-- ============================================================================= + +-- Stores individual quotes. auth_code is the 4-word passphrase for edit/delete. +CREATE TABLE IF NOT EXISTS quotes ( + id TEXT PRIMARY KEY, -- NanoID (~21 chars) + text TEXT NOT NULL, + author TEXT NOT NULL, + source TEXT, -- optional: book, speech, etc. + date TEXT, -- optional: ISO date YYYY-MM-DD + auth_code TEXT NOT NULL, -- 4-word passphrase, stored plaintext + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Join table linking quotes to zero or more tags. Cascades on quote deletion. +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) +); +``` + +## 2. Incremental migration convention (document in infra/README.md) + +For future schema changes, create numbered migration files: + +``` +infra/migrations/ + 001_initial.sql -- (retroactive, same content as schema.sql) + 002_add_index.sql -- future: e.g. CREATE INDEX IF NOT EXISTS ... +``` + +Apply individually: +```sh +wrangler d1 execute quotesdb --file infra/migrations/002_add_index.sql --remote +``` + +No automated migration tracking is needed at this project's scale. + +## 3. Full deployment workflow to document in `infra/README.md` + +```sh +# Step 1 — provision infrastructure (creates Worker, D1 database, Pages project) +cd infra/ +tofu apply + +# Step 2 — apply initial schema to D1 (run once after first apply) +cd .. +wrangler d1 execute quotesdb --file infra/schema.sql --remote + +# Re-running step 2 is safe (CREATE TABLE IF NOT EXISTS). +``` + +## 4. Local dev workflow + +```sh +# Start local API (applies schema automatically via NativeRepository::run_migrations()) +cargo run +# or with a specific DB file: +DATABASE_URL=./dev.sqlite cargo run +``` + +No manual wrangler step needed for local dev. + + + + +**null_resource local-exec (rejected):** Provisioners are an OpenTofu anti-pattern. They don't +re-run unless the resource is tainted, aren't tracked in state, are OS-dependent (requires +wrangler installed on the CI runner at apply time), and hard to test. Breaking `tofu apply` +idempotency is not worth the single-command convenience. + +**API startup migration for D1 (rejected):** Cloudflare Workers spin up per-request via V8 +isolates. Calling DDL (`CREATE TABLE IF NOT EXISTS`) on every request is wasteful and fragile. +The native `main()` calls `run_migrations()` at startup because it runs as a real server, but +the Workers handler does NOT. The D1 provisioning path must be a separate step. + + + + +- `infra/schema.sql` must use `CREATE TABLE IF NOT EXISTS` for idempotency — safe to re-run. +- Schema must exactly match the design doc: NanoID PK, `auth_code` plaintext, optional `source` + and `date`, CASCADE delete on `quote_tags`. +- Do NOT run `wrangler d1 execute` inside OpenTofu (no `null_resource`). +- `db/migrations.rs` (ticket 00aff0) contains equivalent SQL as Rust constants — keep in sync + with `infra/schema.sql` manually when schema changes. + + + +This ticket has no Rust compilation artifact. Validate that the SQL is correct: + +```sh +# Smoke-test the schema against a local SQLite file +sqlite3 /tmp/test_quotesdb.sqlite < infra/schema.sql +sqlite3 /tmp/test_quotesdb.sqlite ".tables" +# Should output: quote_tags quotes + +# Clean up +rm /tmp/test_quotesdb.sqlite +``` + + + +- Resolves dependency: TRIAGE 5c0c64 (D1 migrations strategy — wrangler step chosen) +- Resolves dependency: TRIAGE 580e66 (DB migration strategy for Workers — same decision) +- Blocked by: d0da0b (D1 resource — need database name confirmed as "quotesdb") +- Informs: 75489a (migration workflow docs — documents the wrangler command from this ticket) +- Informs: 00aff0 (DB abstraction — migrations.rs constants mirror this file's SQL) +- Sub-project: quotesdb/infra + + + +`feat(quotesdb): add infra/schema.sql with idempotent D1 schema for quotes and quote_tags` + diff --git a/quotesdb/.nbd/tickets/c3503b.md b/quotesdb/.nbd/tickets/c3503b.md index 4c7179f..8c4e8f3 100644 --- a/quotesdb/.nbd/tickets/c3503b.md +++ b/quotesdb/.nbd/tickets/c3503b.md @@ -3,7 +3,7 @@ title = "quotesdb/ui" priority = 7 status = "todo" ticket_type = "project" -dependencies = [] +dependencies = ["166996", "5e3e37", "a9534d", "93515e", "dc3d2b", "04f865", "1e6a09", "0d987f", "2c5a57", "d3d502", "fc2f51", "f850c6", "1a274d", "1ba523", "5f1112", "5cdbd9", "b3ef98", "372790", "0fbdd5", "00d6d7", "9ef703", "5379eb"] +++ diff --git a/quotesdb/.nbd/tickets/ce1e4f.md b/quotesdb/.nbd/tickets/ce1e4f.md index 531ac71..0c90b28 100644 --- a/quotesdb/.nbd/tickets/ce1e4f.md +++ b/quotesdb/.nbd/tickets/ce1e4f.md @@ -3,7 +3,7 @@ title = "quotesdb/qa" priority = 7 status = "todo" ticket_type = "project" -dependencies = [] +dependencies = ["2ab7a8", "fba598", "0d84fa", "5f5ba0", "9b581f", "e8f5cf", "789d0f", "4a4c26", "aa0eab", "93f1b6", "f9f448", "fae330", "8c87db", "893eba", "75e3f0"] +++ diff --git a/quotesdb/.nbd/tickets/d0da0b.md b/quotesdb/.nbd/tickets/d0da0b.md index ab13c6c..2cc342a 100644 --- a/quotesdb/.nbd/tickets/d0da0b.md +++ b/quotesdb/.nbd/tickets/d0da0b.md @@ -3,28 +3,45 @@ title = "Define Cloudflare D1 database resource and document binding name for th priority = 7 status = "todo" ticket_type = "task" -dependencies = ["25c413", "2d1371", "5c0c64"] +dependencies = ["2d1371", "5c0c64"] +++ -Infrastructure is managed with OpenTofu using the Cloudflare provider. Configuration lives in `infra/`. Resources include a Cloudflare Worker (API), Cloudflare D1 database (bound to the worker), and a Cloudflare Pages project (UI frontend). +Infrastructure is managed with OpenTofu using the Cloudflare provider. Configuration lives in `infra/`. The D1 database must be provisioned before the Worker can bind to it — OpenTofu handles this automatically via the attribute reference in the Worker resource (see triage 07cafb). -Cloudflare D1 is the production SQLite-compatible database. It must be provisioned before the Worker can bind to it. +Triage 5c0c64 resolved: schema is applied via `wrangler d1 execute quotesdb --file infra/schema.sql --remote` as a separate step after `tofu apply`. Do NOT use `null_resource` local-exec. -Define the Cloudflare D1 database resource in `infra/d1.tf`: -1. `cloudflare_d1_database` resource for the `quotesdb` database -2. Output the D1 database ID so it can be referenced in the Worker binding -3. Document the binding name (e.g. `DB`) that the Worker expects — this must match the workers-rs binding name in the API code - -Every block must have a comment. +Define the Cloudflare D1 database resource in `infra/d1.tf`. - -- Resolve TRIAGE ticket 5c0c64 (D1 migrations in OpenTofu) for how to apply the schema after provisioning. -- Document the D1 database ID output — needed for the Worker binding (ticket 07cafb). - + +```hcl +# infra/d1.tf + +# Cloudflare D1 database for the quotesdb application. +# SQLite-compatible, bound to the API Worker under the binding name "DB". +resource "cloudflare_d1_database" "db" { + account_id = var.cloudflare_account_id + name = "quotesdb" +} + +# Export the D1 database ID so it can be referenced in worker.tf and +# used as an argument to `wrangler d1 execute` for schema migrations. +output "d1_database_id" { + description = "D1 database ID — referenced by the Worker binding and schema migration commands." + value = cloudflare_d1_database.db.id +} +``` + + + +- `cloudflare_d1_database` outputs `id` (String) — the identifier used in Worker bindings. +- The binding name `"DB"` must match what the workers-rs code uses to access the database (set in the API source, not here). +- After `tofu apply`, apply the schema: `wrangler d1 execute quotesdb --file infra/schema.sql --remote` (see ticket bb1514 for schema.sql). +- The D1 ID showing as `(known after apply)` in `tofu plan` is expected; the Worker binding resolves it at apply time automatically. + Run from the `infra/` directory: diff --git a/quotesdb/.nbd/tickets/d3d502.md b/quotesdb/.nbd/tickets/d3d502.md index badcbfa..d46a5ad 100644 --- a/quotesdb/.nbd/tickets/d3d502.md +++ b/quotesdb/.nbd/tickets/d3d502.md @@ -3,7 +3,7 @@ title = "Implement tag filter component — tag input/select for browse and auth priority = 5 status = "todo" ticket_type = "task" -dependencies = ["c3503b", "93515e"] +dependencies = ["93515e", "0fbdd5"] +++ diff --git a/quotesdb/.nbd/tickets/d5839a.md b/quotesdb/.nbd/tickets/d5839a.md index ec0f621..9aa3ffe 100644 --- a/quotesdb/.nbd/tickets/d5839a.md +++ b/quotesdb/.nbd/tickets/d5839a.md @@ -3,7 +3,7 @@ title = "Write infra/README.md — setup, apply, destroy instructions and requir priority = 3 status = "todo" ticket_type = "task" -dependencies = ["25c413", "2d1371", "d0da0b", "a23489", "ae886f", "ae6a82"] +dependencies = ["2d1371", "d0da0b", "a23489", "ae886f", "ae6a82"] +++ diff --git a/quotesdb/.nbd/tickets/d792e2.md b/quotesdb/.nbd/tickets/d792e2.md index 9de82be..7e2dc0d 100644 --- a/quotesdb/.nbd/tickets/d792e2.md +++ b/quotesdb/.nbd/tickets/d792e2.md @@ -3,7 +3,7 @@ title = 'Implement error handling — consistent {"error": "..."} envelope for 4 priority = 5 status = "todo" ticket_type = "task" -dependencies = ["f3dc74", "1f5bb5", "6e829e"] +dependencies = ["1f5bb5", "6e829e"] +++ diff --git a/quotesdb/.nbd/tickets/dc3d2b.md b/quotesdb/.nbd/tickets/dc3d2b.md index 5255064..36a325f 100644 --- a/quotesdb/.nbd/tickets/dc3d2b.md +++ b/quotesdb/.nbd/tickets/dc3d2b.md @@ -3,7 +3,7 @@ title = "Set up ui/Trunk.toml and ui/index.html — build configuration and Wasm priority = 8 status = "todo" ticket_type = "task" -dependencies = ["c3503b", "a9534d"] +dependencies = ["a9534d", "9ef703"] +++ @@ -11,17 +11,54 @@ The `quotesdb` UI is a Yew (Rust → Wasm) single-page app compiled by Trunk and -Create `Trunk.toml` and `index.html` in the `quotesdb/` root (not `src/bin/ui/`): -1. `Trunk.toml` must specify `--bin ui` so Trunk compiles the `ui` binary, not the default `api` binary -2. `index.html` is the Trunk HTML entry point — it should `` to compiled CSS and include the Wasm loader script tag +Update `Trunk.toml` and `index.html` in the `quotesdb/` root: +1. `Trunk.toml` must specify the target index.html so Trunk uses the right entry point. +2. `Trunk.toml` must include a `[[proxy]]` block to forward `/api/*` requests to the local API server (see proxy-config section below). +3. `index.html` must: + - Include `` (plain CSS — see triage 5e3e37) + - Include `` so Trunk compiles the `ui` binary + - Include `
` as the Yew mount point -Verify `trunk serve` starts successfully. +Verify `trunk build` succeeds.
+ +CSS approach resolved in triage 5e3e37: **plain CSS**. + +- CSS file lives at `src/bin/ui/style.css` +- Linked in index.html as: `` +- Naming convention: BEM-style — `quote-card`, `quote-card__text`, `page-browse`, etc. +- No CDN Tailwind (incompatible with Wasm class-name scanning), no Rust CSS-in-Wasm crate. +- The actual CSS content is implemented in ticket bb1514. + + + +CORS/proxy approach resolved in triage a9534d: **Trunk proxy**. + +During `trunk serve`, Trunk's built-in proxy forwards all `/api/*` requests to the local API server. +The API server runs on `localhost:3000` during local development (plain Axum/Tokio — not wrangler dev). +No CORS headers are needed anywhere; the proxy makes API calls appear same-origin. + +Add this block to `Trunk.toml`: + +```toml +[[proxy]] +rewrite = "/api" +backend = "http://localhost:3000" +``` + +This means: +- `trunk serve` listens on `localhost:8080` (default) +- Browser requests to `http://localhost:8080/api/quotes` are transparently proxied to `http://localhost:3000/api/quotes` +- In production (Cloudflare Pages + Workers), `/api/*` routes to the Worker via Cloudflare's routing — same path prefix, no code change needed + + -- Resolve TRIAGE ticket 5e3e37 (CSS approach) before adding stylesheet links. -- The `Trunk.toml` `[build.cargo]` section must set `args = ["--bin", "ui"]`. - `index.html` must include a `
` mount point for the Yew app. +- Trunk.toml `target` must point to `index.html`. +- The `[[proxy]]` block must appear in `Trunk.toml` (not `Trunk.toml.dev` or elsewhere). +- Do NOT remove or overwrite the `` line + added by ticket 9ef703 — that line must remain in `index.html`. @@ -34,4 +71,4 @@ trunk build `chore(quotesdb): set up Trunk.toml and index.html for UI build` - + \ No newline at end of file diff --git a/quotesdb/.nbd/tickets/e2bd9b.md b/quotesdb/.nbd/tickets/e2bd9b.md index 0cd8781..061de17 100644 --- a/quotesdb/.nbd/tickets/e2bd9b.md +++ b/quotesdb/.nbd/tickets/e2bd9b.md @@ -1,9 +1,9 @@ +++ title = "[TRIAGE] Cloudflare Pages SPA routing — 404 fallback config for client-side Yew router" priority = 8 -status = "todo" +status = "done" ticket_type = "task" -dependencies = ["25c413"] +dependencies = [] +++ @@ -21,9 +21,27 @@ Cloudflare Pages SPA routing: Yew uses client-side routing. A direct URL to `/br -1. Research the options above and choose the best approach for this project. -2. Add a `_redirects` file (or equivalent) to the Trunk build output. Update ticket ae886f (Pages resource) and ticket dc3d2b (Trunk.toml) if the file needs to be included in the build. -3. Mark this ticket done with a note on the chosen approach in the body or a comment. +**Chosen approach: Option 1 — `_redirects` file.** + +- File content: `/* /index.html 200` +- The `200` code is a Cloudflare Pages "proxy" (not a redirect): it serves `index.html` content + while preserving the original URL in the browser. This is required for Yew's BrowserRouter to + function correctly on direct navigation and page reloads. +- The file lives at the `quotesdb/` project root and is copied into `dist/` by Trunk via + `` in `index.html`. +- Cloudflare Pages processes `_redirects` automatically from the uploaded artifact — no + OpenTofu changes are needed in `ae886f`. +- The `/api/*` Worker routes are claimed at the Cloudflare routing layer before Pages processes + requests, so the `/* /index.html 200` catch-all does not interfere with the API. + +Options 2 (`_headers`) and 3 (duplicate `404.html`) were ruled out: +- `_headers` does not fix routing — it only sets response headers. +- A `404.html` copy of `index.html` returns HTTP 404 status, which is incorrect for valid SPA + routes and can harm SEO and caching behaviour. + +Implementation ticket: **9ef703** — creates the `_redirects` file and adds the Trunk copy-file +directive to `index.html`. `dc3d2b` (Trunk.toml) depends on 9ef703 to ensure the copy-file +line is not accidentally overwritten. `ae886f` (Pages resource) updated with resolved notes. diff --git a/quotesdb/.nbd/tickets/e8a330.md b/quotesdb/.nbd/tickets/e8a330.md index f9ab42f..d59a4df 100644 --- a/quotesdb/.nbd/tickets/e8a330.md +++ b/quotesdb/.nbd/tickets/e8a330.md @@ -1,9 +1,9 @@ +++ title = "[TRIAGE] SQLx + workers-rs + Cloudflare D1 compatibility (known issues?)" priority = 9 -status = "todo" +status = "done" ticket_type = "task" -dependencies = ["f3dc74"] +dependencies = [] +++ @@ -20,10 +20,32 @@ SQLx + workers-rs + Cloudflare D1 compatibility: does SQLx work with the Cloudfl 3. **SQLite over HTTP (Turso)** — use Turso in both dev and production (Turso cloud instead of D1). Avoids D1 entirely. + +**Option 2: workers-rs D1 bindings, extended with a `cfg(target_arch)`-based native path.** + +SQLx is fundamentally incompatible with Cloudflare Workers/D1. D1 is accessed through the +workers-rs JavaScript binding layer (`worker::d1::D1Database`), not a TCP connection. +SQLx requires a TCP-based connection and its compile-time query macros cannot run in the +Workers/WASM build environment. + +**Chosen architecture:** +- `#[cfg(target_arch = "wasm32")]` → workers-rs `D1Database` bindings (production) +- `#[cfg(not(target_arch = "wasm32"))]` → `rusqlite` + `tokio-rusqlite` (native dev/tests) + +A `QuoteRepository` async trait provides a unified interface. Concrete type aliases +(`AppRepo`) avoid trait-object Send/Sync constraints in Axum handlers. + +`cargo test` on the native host automatically selects the rusqlite path — no wrangler dev +or feature flags needed for integration tests. + +The design doc's "Query layer: SQLx" is superseded. See implementation ticket 00aff0. + + -1. Research the options above and choose the best approach for this project. -2. Update ticket a5049d (database connection module) and ticket 1f5bb5 (Cargo.toml) with the chosen database access strategy. -3. Mark this ticket done with a note on the chosen approach in the body or a comment. +- Implementation ticket created: **00aff0** — full plan for Repository trait + D1/rusqlite impls. +- Ticket a5049d (database connection module) updated — SQLx goal superseded. +- Ticket 1f5bb5 (Cargo.toml) updated — cfg-split dependency constraints corrected. +- Tickets a91260 and 2ab7a8 also resolved by this decision (see those tickets). diff --git a/quotesdb/.nbd/tickets/e8f5cf.md b/quotesdb/.nbd/tickets/e8f5cf.md index 5db7815..afefd41 100644 --- a/quotesdb/.nbd/tickets/e8f5cf.md +++ b/quotesdb/.nbd/tickets/e8f5cf.md @@ -3,7 +3,7 @@ title = "Test suite: router ordering — verify /api/quotes/random is not matche priority = 6 status = "todo" ticket_type = "task" -dependencies = ["ce1e4f", "9b581f", "6e829e"] +dependencies = ["9b581f", "6e829e"] +++ diff --git a/quotesdb/.nbd/tickets/efee79.md b/quotesdb/.nbd/tickets/efee79.md index 90797b0..0fe57cc 100644 --- a/quotesdb/.nbd/tickets/efee79.md +++ b/quotesdb/.nbd/tickets/efee79.md @@ -1,9 +1,9 @@ +++ title = "[TRIAGE] Correct cloudflare_workers_script resource name in current Cloudflare provider version" priority = 7 -status = "todo" +status = "done" ticket_type = "task" -dependencies = ["25c413"] +dependencies = [] +++ @@ -20,12 +20,22 @@ Correct Cloudflare Workers script resource name: the Cloudflare OpenTofu provide 3. **Check provider changelog** — run `tofu providers schema` after `tofu init` to see available resources. + +**`cloudflare_workers_script`** (Option 1 — plural "workers"). + +Confirmed directly from the Cloudflare provider v4 GitHub source (`docs/resources/workers_script.md`). The resource is documented as `cloudflare_workers_script` with a `d1_database_binding` block. The singular form `cloudflare_worker_script` does not exist in v4. + +Also confirmed alongside triage 07cafb: the `d1_database_binding` block has required fields `name` (binding variable name) and `database_id` (the D1 ID, referenced as `cloudflare_d1_database.db.id`). + +Ticket a23489 updated with this resource name. + + 1. Research the options above and choose the best approach for this project. -2. Run `tofu providers schema | jq '.provider_schemas[].resource_schemas | keys | map(select(contains("worker")))'` to find the correct resource name. Update ticket a23489. +2. Run `tofu providers schema | jq` to find the correct resource name. Update ticket a23489. 3. Mark this ticket done with a note on the chosen approach in the body or a comment. -`chore(quotesdb): resolve triage — correct-cloudflareworkersscript-resource-name-in-current-clo` +`chore(quotesdb): resolve triage — cloudflare_workers_script-confirmed-plural-provider-v4` diff --git a/quotesdb/.nbd/tickets/f3dc74.md b/quotesdb/.nbd/tickets/f3dc74.md index 0a78b44..c5a3a82 100644 --- a/quotesdb/.nbd/tickets/f3dc74.md +++ b/quotesdb/.nbd/tickets/f3dc74.md @@ -3,7 +3,7 @@ title = "quotesdb/api" priority = 7 status = "todo" ticket_type = "project" -dependencies = [] +dependencies = ["00aff0", "af56a7", "9c9546", "8892d5"] +++ diff --git a/quotesdb/.nbd/tickets/f850c6.md b/quotesdb/.nbd/tickets/f850c6.md index a4b0458..d4130d9 100644 --- a/quotesdb/.nbd/tickets/f850c6.md +++ b/quotesdb/.nbd/tickets/f850c6.md @@ -3,7 +3,7 @@ title = "Implement auth code modal/prompt component — dialog requesting X-Auth priority = 5 status = "todo" ticket_type = "task" -dependencies = ["c3503b", "93515e", "0bc655"] +dependencies = ["93515e", "5379eb", "0fbdd5"] +++ @@ -15,15 +15,21 @@ The auth code modal prompts the user to enter their `X-Auth-Code` (4-word passph Implement an `AuthModal` Yew component (`src/bin/ui/components/auth_modal.rs`) that: 1. Shows a dialog overlay prompting for the auth code -2. Accepts `on_submit: Callback` and `on_cancel: Callback<()>` props -3. Renders an `` for the auth code and Submit/Cancel buttons -4. Calls `on_submit` with the entered code when submitted +2. Accepts `on_submit: Callback`, `on_cancel: Callback<()>`, and + `initial_value: Option` props +3. Renders an `` for the auth code, pre-populated from `initial_value` + if provided (supplied by the parent from session storage — see ticket 5379eb) +4. Renders Submit and Cancel buttons +5. Calls `on_submit` with the entered code when submitted -- Resolve TRIAGE ticket 0bc655 (auth code storage strategy) before deciding whether to persist the code in `localStorage` or keep it in component-only state. -- The modal must be accessible (label the input, support keyboard dismiss). -- Do not persist the auth code across sessions unless explicitly decided in ticket 0bc655. +- Storage strategy resolved in TRIAGE 0bc655: **session storage per quote ID** (ticket 5379eb). + The `AuthModal` itself does NOT read or write storage — it receives `initial_value` from + the parent component, which handles storage via `storage::get_auth_code`. +- The modal must be accessible: label the `` with ` diff --git a/quotesdb/.nbd/tickets/f9f448.md b/quotesdb/.nbd/tickets/f9f448.md index 4b4f4eb..96c2058 100644 --- a/quotesdb/.nbd/tickets/f9f448.md +++ b/quotesdb/.nbd/tickets/f9f448.md @@ -3,7 +3,7 @@ title = "Test suite: GET /api/quotes/:id — 200 with quote, 404 not found, sche priority = 5 status = "todo" ticket_type = "task" -dependencies = ["ce1e4f", "9b581f", "5dbb7d"] +dependencies = ["9b581f", "5dbb7d"] +++ diff --git a/quotesdb/.nbd/tickets/fae330.md b/quotesdb/.nbd/tickets/fae330.md index 0de8225..d89b1ea 100644 --- a/quotesdb/.nbd/tickets/fae330.md +++ b/quotesdb/.nbd/tickets/fae330.md @@ -3,7 +3,7 @@ title = "Test suite: POST /api/quotes/:id — valid auth 200, wrong auth 403, no priority = 5 status = "todo" ticket_type = "task" -dependencies = ["ce1e4f", "9b581f", "5d9f5a"] +dependencies = ["9b581f", "5d9f5a"] +++ diff --git a/quotesdb/.nbd/tickets/fba598.md b/quotesdb/.nbd/tickets/fba598.md index 9cad171..ee69d1d 100644 --- a/quotesdb/.nbd/tickets/fba598.md +++ b/quotesdb/.nbd/tickets/fba598.md @@ -1,9 +1,9 @@ +++ title = "[TRIAGE] Integration test isolation strategy — per-test temp DB vs shared DB with transaction rollback?" priority = 8 -status = "todo" +status = "done" ticket_type = "task" -dependencies = ["ce1e4f"] +dependencies = [] +++ @@ -20,6 +20,49 @@ Integration test isolation strategy: should each test get its own temporary data 3. **Per-test in-memory SQLite** — SQLite `:memory:` database per test. Fast (no file I/O) and fully isolated. May require `--test-threads=1` if the server shares state. + +**Per-test temp SQLite file** (Option 1 variant), using the `tempfile` crate for RAII cleanup. + +Rationale: + +- **Transaction rollback (Option 2) is not viable** for HTTP integration tests. The test harness spawns a real Axum server as a tokio task. That server manages its own SQLx connection pool and commits transactions independently. The test client cannot intercept or roll back those server-side transactions. + +- **In-memory SQLite (Option 3) conflicts with SQLx connection pools.** Each connection in a SQLx pool that opens `sqlite::memory:` gets its own isolated empty database. The test server and the test client would be talking to different databases. Named shared-cache URIs (`file:test_N?mode=memory&cache=shared`) work around this but are less-known, have edge cases in SQLx, and offer no meaningful speed advantage over a temp file on a tmpfs. + +- **Per-test temp file (chosen)** works correctly with SQLx pools because all pool connections share the same file path. Migration runs once per test (≈ milliseconds for 2 tables). `TempDir` from the `tempfile` crate provides RAII cleanup — the file is deleted when the `TestContext` is dropped. Tests run in parallel safely (each has a unique path). + +Implementation in test harness (`tests/helpers.rs`): +```rust +pub struct TestContext { + _db_dir: TempDir, // keeps temp file alive; deleted on drop + pub base_url: String, + _shutdown: tokio::task::JoinHandle<()>, +} + +pub async fn spawn_test_server() -> TestContext { + let db_dir = TempDir::new().unwrap(); + let db_path = db_dir.path().join("test.sqlite"); + let pool = SqlitePool::connect(&format!("sqlite:{}", db_path.display())) + .await + .unwrap(); + sqlx::migrate!("./migrations").run(&pool).await.unwrap(); + + let app = build_router(Arc::new(NativeRepository::new(pool))); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = tokio::spawn(axum::serve(listener, app).into_future()); + + TestContext { + _db_dir: db_dir, + base_url: format!("http://127.0.0.1:{port}"), + _shutdown: handle, + } +} +``` + +Dev-dependency added: `tempfile = "3"` (tracked in ticket 5f5ba0). + + 1. Research the options above and choose the best approach for this project. 2. Update ticket 9b581f (test harness) with the chosen isolation strategy. @@ -27,5 +70,5 @@ Integration test isolation strategy: should each test get its own temporary data -`chore(quotesdb): resolve triage — integration-test-isolation-strategy-pertest-temp-db-vs-share` +`chore(quotesdb): resolve triage — integration-test-isolation-per-test-tempfile-sqlite` diff --git a/quotesdb/.nbd/tickets/fc2f51.md b/quotesdb/.nbd/tickets/fc2f51.md index d6799ec..dd521b7 100644 --- a/quotesdb/.nbd/tickets/fc2f51.md +++ b/quotesdb/.nbd/tickets/fc2f51.md @@ -3,7 +3,7 @@ title = "Implement error display component — consistent error state UI across priority = 4 status = "todo" ticket_type = "task" -dependencies = ["c3503b", "93515e"] +dependencies = ["93515e", "0fbdd5"] +++ diff --git a/quotesdb/.nbd/tickets/fc9bfd.md b/quotesdb/.nbd/tickets/fc9bfd.md index f3a6328..1a686d7 100644 --- a/quotesdb/.nbd/tickets/fc9bfd.md +++ b/quotesdb/.nbd/tickets/fc9bfd.md @@ -1,9 +1,9 @@ +++ title = "[TRIAGE] Cloudflare Pages build strategy — Pages CI build vs pre-built trunk artifact upload" priority = 8 -status = "todo" +status = "done" ticket_type = "task" -dependencies = ["25c413"] +dependencies = [] +++ @@ -20,6 +20,21 @@ Cloudflare Pages build strategy: should the Trunk build run in Cloudflare Pages 3. **Wrangler Pages deploy** — use `wrangler pages deploy dist/` in CI after `trunk build`. + +**Pre-built artifact + Gitea Actions + `wrangler pages deploy`** (Options 2 + 3 combined). + +Rationale: +- **Pages CI build is not viable**: Cloudflare Pages CI supports Node.js, Python, Ruby, Go — not Rust or Nix. Installing `rustup` + `wasm32` target + `trunk` in a Pages build script is slow (~3–5 min per build), non-reproducible, and fragile. Nix flakes are not available in the Pages build sandbox at all. +- **OpenTofu does not upload artifacts**: The `cloudflare_pages_project` resource creates and configures the project, but Terraform/OpenTofu is not designed to upload build artifacts — that is a CD concern, not infrastructure state. +- **Gitea Actions + wrangler is the standard pattern**: Gitea Actions uses GitHub Actions-compatible YAML syntax. The workflow builds the Wasm artifact with `trunk build --release` then deploys with `wrangler pages deploy dist/`. + +Split of responsibilities: +- **OpenTofu** (`infra/pages.tf`): create `cloudflare_pages_project` with direct-upload config (no git connection), configure SPA routing, bind custom domain. Run once to provision. +- **Gitea Actions** (`.gitea/workflows/deploy-ui.yml`): on push to `quotesdb` branch, build and deploy. Secrets: `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`. + +Implementation ticket: 5137d7 + + 1. Research the options above and choose the best approach for this project. 2. Update ticket ae886f (Pages project resource) with the chosen strategy. Document the CI/CD flow in `infra/README.md`. @@ -27,5 +42,5 @@ Cloudflare Pages build strategy: should the Trunk build run in Cloudflare Pages -`chore(quotesdb): resolve triage — cloudflare-pages-build-strategy-pages-ci-build-vs-prebuilt-t` +`chore(quotesdb): resolve triage — pages-build-strategy-gitea-actions-wrangler-deploy` diff --git a/quotesdb/CLAUDE.md b/quotesdb/CLAUDE.md index 0668cfe..daac4d1 100644 --- a/quotesdb/CLAUDE.md +++ b/quotesdb/CLAUDE.md @@ -68,8 +68,9 @@ quotesdb (root project ticket, ec118c) Rules: - Create a ticket **before** starting any non-trivial work. -- Each work ticket must list its sub-project ticket as a dependency (`--deps `). -- Each sub-project ticket must list the root project ticket (`ec118c`) as a dependency. +- Work tickets are **dependencies of** their sub-project ticket — add them with `nbd update --deps "...existing...,"`. Work tickets do **not** list the sub-project in their own `--deps`. +- Sub-project tickets are **dependencies of** the root project ticket (`ec118c`) — they do **not** list `ec118c` in their own `--deps`. +- The dependency flows upward: `work → sub-project → root`. Each level can only close after all tickets below it are done. - **Only close a ticket after its work has been validated** (all `cargo fmt/check/clippy/test` pass, or equivalent for infra). diff --git a/quotesdb/docs/ARCHITECTURE.md b/quotesdb/docs/ARCHITECTURE.md index 2e7c409..5ddf05d 100644 --- a/quotesdb/docs/ARCHITECTURE.md +++ b/quotesdb/docs/ARCHITECTURE.md @@ -39,3 +39,15 @@ Integration tests bypass the UI and talk directly to the API over HTTP, using a - Any API that is not available in a Wasm environment Use `#[cfg(not(target_arch = "wasm32"))]` and `#[cfg(target_arch = "wasm32")]` guards where needed. + +## Key Dependency Versions + +Resolved versions for the UI Wasm target (scoped to `[target.'cfg(target_arch = "wasm32")'.dependencies]`): + +| Crate | Version | Notes | +|-------|---------|-------| +| `yew` | `"0.22"` | Latest stable (0.22.1). | +| `yew-router` | `"0.19"` | Latest stable (0.19.0). Requires `yew ^0.22.0` — compatible. | +| `wasm-bindgen` | `"0.2"` | Compatible with `wasm-bindgen-cli 0.2.108` in the Nix dev shell. | + +Rationale: yew-router 0.19 explicitly requires `yew ^0.22.0`, making this the only correct combination with the latest stable Yew. The `^0.2` wasm-bindgen constraint in both crates is satisfied by the Nix-pinned `0.2.108`.