chore(quotesdb): resolve all triage tickets and create implementation tickets
- All 21 TRIAGE decision tickets resolved with chosen approaches documented - This session: e2bd9b (SPA routing → _redirects), 2ec8b1 (OpenAPI → build.rs), 0d84fa (HTTP client → reqwest), 0bc655 (auth code → session storage) - New implementation tickets created: 9ef703, 8892d5, 5379eb - Downstream tickets updated with resolved approaches and correct dependencies - ARCHITECTURE.md updated with pinned WASM dependency versions (yew 0.22, yew-router 0.19, wasm-bindgen 0.2) - XML tags added to all tickets for improved LLM guidance Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
895b63a77c
commit
fc89180b82
@ -0,0 +1,343 @@
|
|||||||
|
+++
|
||||||
|
title = "Implement DB abstraction: QuoteRepository trait + cfg-split D1/rusqlite impls"
|
||||||
|
priority = 8
|
||||||
|
status = "todo"
|
||||||
|
ticket_type = "task"
|
||||||
|
dependencies = []
|
||||||
|
+++
|
||||||
|
|
||||||
|
<context>
|
||||||
|
Resolution of TRIAGE ticket e8a330: **SQLx is NOT compatible with Cloudflare Workers/D1.**
|
||||||
|
D1 is accessed through the workers-rs JavaScript binding layer, not a TCP connection.
|
||||||
|
SQLx relies on TCP connections (Postgres, MySQL, SQLite file) and cannot work in the Workers runtime.
|
||||||
|
|
||||||
|
**Chosen approach: `cfg(target_arch)`-based split**
|
||||||
|
|
||||||
|
- `#[cfg(target_arch = "wasm32")]` → workers-rs D1 bindings (`worker::d1::D1Database`)
|
||||||
|
- `#[cfg(not(target_arch = "wasm32"))]` → `rusqlite` + `tokio-rusqlite` (native dev/test)
|
||||||
|
|
||||||
|
`cargo test` on the native host automatically selects the rusqlite path. No feature flags,
|
||||||
|
no wrangler dev required for integration tests. The design doc's "Query layer: SQLx" is
|
||||||
|
superseded by this approach.
|
||||||
|
|
||||||
|
This decision also resolves TRIAGE tickets a91260 and 2ab7a8 (workers-rs native test binaries):
|
||||||
|
the `cfg(target_arch)` split handles the test environment automatically.
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<goal>
|
||||||
|
Implement the database abstraction layer for `quotesdb-api`:
|
||||||
|
|
||||||
|
1. **`src/bin/api/db/mod.rs`** — `QuoteRepository` async trait + shared result types
|
||||||
|
2. **`src/bin/api/db/d1.rs`** — `D1Repository` using workers-rs D1 bindings (`wasm32` only)
|
||||||
|
3. **`src/bin/api/db/native.rs`** — `NativeRepository` using `rusqlite`/`tokio-rusqlite` (native only)
|
||||||
|
4. **`src/bin/api/db/migrations.rs`** — SQL migration strings (`CREATE TABLE IF NOT EXISTS`)
|
||||||
|
5. **`Cargo.toml`** — wire cfg-split dependencies for workers-rs and rusqlite
|
||||||
|
</goal>
|
||||||
|
|
||||||
|
<implementation-plan>
|
||||||
|
|
||||||
|
## 1. Cargo.toml dependency additions
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# Dependencies always present (both targets)
|
||||||
|
async-trait = "0.1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
# API only — WASM/Workers target
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
worker = { version = "0.7", features = ["d1", "axum"] }
|
||||||
|
|
||||||
|
# API only — native target (local dev and cargo test)
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
axum = "0.8"
|
||||||
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
|
tokio-rusqlite = "0.5"
|
||||||
|
```
|
||||||
|
|
||||||
|
`rusqlite` with `features = ["bundled"]` compiles SQLite in — no system SQLite dependency.
|
||||||
|
|
||||||
|
## 2. Module file layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/bin/api/
|
||||||
|
├── main.rs # cfg-split: workers-rs event handler OR native tokio::main
|
||||||
|
├── router.rs # build_router<R: QuoteRepository>(...) — shared for both targets
|
||||||
|
├── handlers/ # Axum route handlers — generic over repo type
|
||||||
|
└── db/
|
||||||
|
├── mod.rs # QuoteRepository trait + shared types (DbError, ListResult, etc.)
|
||||||
|
├── d1.rs # D1Repository — cfg(target_arch = "wasm32")
|
||||||
|
├── native.rs # NativeRepository — cfg(not(target_arch = "wasm32"))
|
||||||
|
└── migrations.rs # SQL migration strings
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. QuoteRepository trait (`src/bin/api/db/mod.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
mod d1;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
mod native;
|
||||||
|
pub mod migrations;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub use d1::D1Repository;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub use native::NativeRepository;
|
||||||
|
|
||||||
|
pub struct ListResult {
|
||||||
|
pub quotes: Vec<crate::Quote>,
|
||||||
|
pub page: u32,
|
||||||
|
pub total_pages: u32,
|
||||||
|
pub total_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum DeleteResult { Deleted, NotFound, Forbidden }
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum DbError {
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Internal(String),
|
||||||
|
#[error("not found")]
|
||||||
|
NotFound,
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Async repository interface for all quote operations.
|
||||||
|
///
|
||||||
|
/// `?Send` is required because `D1Database` wraps JS values and is not `Send`.
|
||||||
|
/// Both implementations satisfy this bound.
|
||||||
|
#[async_trait::async_trait(?Send)]
|
||||||
|
pub trait QuoteRepository {
|
||||||
|
/// Run CREATE TABLE IF NOT EXISTS migrations. Call once on startup.
|
||||||
|
async fn run_migrations(&self) -> Result<(), DbError>;
|
||||||
|
|
||||||
|
async fn list_quotes(
|
||||||
|
&self, page: u32, author: Option<&str>, tag: Option<&str>,
|
||||||
|
) -> Result<ListResult, DbError>;
|
||||||
|
|
||||||
|
async fn get_quote(&self, id: &str) -> Result<Option<crate::Quote>, DbError>;
|
||||||
|
async fn get_random_quote(&self) -> Result<Option<crate::Quote>, DbError>;
|
||||||
|
|
||||||
|
/// Creates a quote. Generates an auth_code if not provided.
|
||||||
|
/// Returns (quote, auth_code).
|
||||||
|
async fn create_quote(&self, input: crate::CreateQuoteInput) -> Result<(crate::Quote, String), DbError>;
|
||||||
|
|
||||||
|
async fn update_quote(
|
||||||
|
&self, id: &str, auth_code: &str, input: crate::UpdateQuoteInput,
|
||||||
|
) -> Result<Option<crate::Quote>, DbError>;
|
||||||
|
|
||||||
|
async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note on `?Send`**: Axum's `State<Arc<dyn QuoteRepository>>` requires `Send + Sync` on native
|
||||||
|
(tokio multi-threaded). Since the trait is `?Send`, use a concrete type alias instead of
|
||||||
|
a trait object:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// In router.rs — concrete type alias, no dyn needed
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub type AppRepo = db::D1Repository;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub type AppRepo = db::NativeRepository;
|
||||||
|
|
||||||
|
pub fn build_router(repo: Arc<AppRepo>) -> Router { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
This avoids the trait-object/Send complexity entirely. Handlers receive `State<Arc<AppRepo>>`.
|
||||||
|
|
||||||
|
## 4. D1Repository (`src/bin/api/db/d1.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Only compiled for wasm32 — imports from worker crate are safe here
|
||||||
|
#![cfg(target_arch = "wasm32")]
|
||||||
|
use worker::d1::D1Database;
|
||||||
|
use super::{DbError, DeleteResult, ListResult, QuoteRepository};
|
||||||
|
|
||||||
|
pub struct D1Repository { db: D1Database }
|
||||||
|
|
||||||
|
impl D1Repository {
|
||||||
|
pub fn new(db: D1Database) -> Self { Self { db } }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait(?Send)]
|
||||||
|
impl QuoteRepository for D1Repository {
|
||||||
|
async fn run_migrations(&self) -> Result<(), DbError> {
|
||||||
|
self.db.prepare(super::migrations::CREATE_QUOTES)
|
||||||
|
.run().await.map_err(|e| DbError::Internal(e.to_string()))?;
|
||||||
|
self.db.prepare(super::migrations::CREATE_QUOTE_TAGS)
|
||||||
|
.run().await.map_err(|e| DbError::Internal(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_quote(&self, id: &str) -> Result<Option<crate::Quote>, DbError> {
|
||||||
|
let row = self.db
|
||||||
|
.prepare("SELECT q.id, q.text, q.author, q.source, q.date, q.created_at, q.updated_at \
|
||||||
|
FROM quotes q WHERE q.id = ?1")
|
||||||
|
.bind(&[id.into()])
|
||||||
|
.map_err(|e| DbError::Internal(e.to_string()))?
|
||||||
|
.first::<serde_json::Value>(None).await
|
||||||
|
.map_err(|e| DbError::Internal(e.to_string()))?;
|
||||||
|
// Deserialize and attach tags via separate query
|
||||||
|
// ...
|
||||||
|
Ok(row.map(|v| serde_json::from_value(v).unwrap()))
|
||||||
|
}
|
||||||
|
// ... remaining methods
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Tags require a separate query per quote (or a GROUP_CONCAT aggregation):
|
||||||
|
```sql
|
||||||
|
SELECT tag FROM quote_tags WHERE quote_id = ?1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. NativeRepository (`src/bin/api/db/native.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Only compiled for non-wasm32
|
||||||
|
#![cfg(not(target_arch = "wasm32"))]
|
||||||
|
use tokio_rusqlite::Connection;
|
||||||
|
use super::{DbError, DeleteResult, ListResult, QuoteRepository};
|
||||||
|
|
||||||
|
pub struct NativeRepository { conn: Connection }
|
||||||
|
|
||||||
|
impl NativeRepository {
|
||||||
|
pub async fn new(db_path: &str) -> Result<Self, DbError> {
|
||||||
|
let conn = Connection::open(db_path).await
|
||||||
|
.map_err(|e| DbError::Internal(e.to_string()))?;
|
||||||
|
Ok(Self { conn })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait(?Send)]
|
||||||
|
impl QuoteRepository for NativeRepository {
|
||||||
|
async fn run_migrations(&self) -> Result<(), DbError> {
|
||||||
|
self.conn.call(|conn| {
|
||||||
|
conn.execute_batch(&format!(
|
||||||
|
"PRAGMA foreign_keys = ON; {}; {};",
|
||||||
|
super::migrations::CREATE_QUOTES,
|
||||||
|
super::migrations::CREATE_QUOTE_TAGS,
|
||||||
|
))?;
|
||||||
|
Ok(())
|
||||||
|
}).await.map_err(|e| DbError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_quote(&self, id: &str) -> Result<Option<crate::Quote>, DbError> {
|
||||||
|
let id = id.to_string();
|
||||||
|
self.conn.call(move |conn| {
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, text, author, source, date, created_at, updated_at \
|
||||||
|
FROM quotes WHERE id = ?1")?;
|
||||||
|
// map_row to Quote struct, then fetch tags separately
|
||||||
|
// ...
|
||||||
|
Ok(None) // placeholder
|
||||||
|
}).await.map_err(|e| DbError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
// ... remaining methods
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Migrations (`src/bin/api/db/migrations.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Creates the quotes table if it does not already exist.
|
||||||
|
pub const CREATE_QUOTES: &str = "
|
||||||
|
CREATE TABLE IF NOT EXISTS quotes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
author TEXT NOT NULL,
|
||||||
|
source TEXT,
|
||||||
|
date TEXT,
|
||||||
|
auth_code TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)";
|
||||||
|
|
||||||
|
/// Creates the quote_tags join table with cascade delete.
|
||||||
|
pub const CREATE_QUOTE_TAGS: &str = "
|
||||||
|
CREATE TABLE IF NOT EXISTS quote_tags (
|
||||||
|
quote_id TEXT NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (quote_id, tag)
|
||||||
|
)";
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. main.rs cfg-split entry points
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src/bin/api/main.rs
|
||||||
|
|
||||||
|
// ── WASM / Workers entry point ──────────────────────────────────────────────
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use worker::*;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
#[event(fetch)]
|
||||||
|
pub async fn main(req: Request, env: Env, ctx: Context) -> Result<Response> {
|
||||||
|
let db = env.d1("DB")?;
|
||||||
|
let repo = std::sync::Arc::new(db::D1Repository::new(db));
|
||||||
|
router::build_router(repo).call(req, env, ctx).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Native entry point (local dev + cargo test server) ──────────────────────
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let db_path = std::env::var("DATABASE_URL")
|
||||||
|
.unwrap_or_else(|_| "./quotesdb.sqlite".to_string());
|
||||||
|
let repo = std::sync::Arc::new(
|
||||||
|
db::NativeRepository::new(&db_path).await.expect("failed to open DB")
|
||||||
|
);
|
||||||
|
repo.run_migrations().await.expect("failed to run migrations");
|
||||||
|
let app = router::build_router(repo);
|
||||||
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</implementation-plan>
|
||||||
|
|
||||||
|
<constraints>
|
||||||
|
- `async_trait::async_trait(?Send)` required — `D1Database` wraps JS values and is NOT `Send`.
|
||||||
|
- Use concrete type alias (`AppRepo`) in handlers/router instead of `dyn QuoteRepository` to avoid
|
||||||
|
the Send + Sync trait object constraint on native Axum.
|
||||||
|
- `rusqlite` must use `features = ["bundled"]` — no system SQLite dependency.
|
||||||
|
- Tags are stored in a separate table; always fetch them with a second query per quote.
|
||||||
|
- The `auth_code` column must be included in DB SELECT for update/delete auth checks but
|
||||||
|
NEVER returned in public GET responses.
|
||||||
|
- Foreign keys must be explicitly enabled in rusqlite: `PRAGMA foreign_keys = ON`.
|
||||||
|
- `tokio-rusqlite` v0.5 uses `spawn_blocking` internally — safe to use from async handlers.
|
||||||
|
</constraints>
|
||||||
|
|
||||||
|
<related-tickets>
|
||||||
|
- Resolves TRIAGE: e8a330 (SQLx + workers-rs + D1 compatibility)
|
||||||
|
- Also resolves TRIAGE: a91260 (workers-rs native test binaries) and 2ab7a8 (test harness approach)
|
||||||
|
- Supersedes: a5049d (DB connection module — SQLx approach invalidated)
|
||||||
|
- Informs: 1f5bb5 (Cargo.toml — cfg-split deps), 6e829e (api main.rs — cfg-split entry point), 9b581f (test harness)
|
||||||
|
</related-tickets>
|
||||||
|
|
||||||
|
<skills>
|
||||||
|
Use `superpowers:test-driven-development` — write `NativeRepository` tests before implementing query methods.
|
||||||
|
Use `superpowers:verification-before-completion` before closing.
|
||||||
|
</skills>
|
||||||
|
|
||||||
|
<validation>
|
||||||
|
Run in order from the `quotesdb/` directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt
|
||||||
|
cargo check # verifies native build (rusqlite path)
|
||||||
|
cargo clippy
|
||||||
|
cargo test # tests use NativeRepository automatically
|
||||||
|
|
||||||
|
# Also verify WASM target compiles (workers-rs D1 path):
|
||||||
|
cargo check --target wasm32-unknown-unknown
|
||||||
|
```
|
||||||
|
</validation>
|
||||||
|
|
||||||
|
<commit>
|
||||||
|
`feat(quotesdb): implement QuoteRepository trait and cfg-split D1/rusqlite DB abstraction`
|
||||||
|
</commit>
|
||||||
@ -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"]
|
||||||
|
+++
|
||||||
|
|
||||||
|
<context>
|
||||||
|
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.
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<decision>
|
||||||
|
**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.
|
||||||
|
</decision>
|
||||||
|
|
||||||
|
<goal>
|
||||||
|
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).
|
||||||
|
</goal>
|
||||||
|
|
||||||
|
<local-dev-workflow>
|
||||||
|
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.
|
||||||
|
</local-dev-workflow>
|
||||||
|
|
||||||
|
<production-routing>
|
||||||
|
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.
|
||||||
|
</production-routing>
|
||||||
|
|
||||||
|
<constraints>
|
||||||
|
- 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.
|
||||||
|
</constraints>
|
||||||
|
|
||||||
|
<validation>
|
||||||
|
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
|
||||||
|
```
|
||||||
|
</validation>
|
||||||
|
|
||||||
|
<commit>
|
||||||
|
`chore(quotesdb): add Trunk proxy config to forward /api/* to local API server`
|
||||||
|
</commit>
|
||||||
@ -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"]
|
||||||
|
+++
|
||||||
|
|
||||||
|
<context>
|
||||||
|
CSS approach resolved in triage 5e3e37: **plain CSS** — a single `src/bin/ui/style.css` file linked from `index.html` via `<link data-trunk rel="css" href="src/bin/ui/style.css"/>`.
|
||||||
|
|
||||||
|
All UI component tickets use BEM-style class names defined in this file. No CSS-in-Rust crates, no CDN Tailwind.
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<goal>
|
||||||
|
Write `src/bin/ui/style.css` covering all pages and components in the Yew UI.
|
||||||
|
</goal>
|
||||||
|
|
||||||
|
<naming-convention>
|
||||||
|
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` |
|
||||||
|
|
||||||
|
</naming-convention>
|
||||||
|
|
||||||
|
<design-notes>
|
||||||
|
- 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).
|
||||||
|
</design-notes>
|
||||||
|
|
||||||
|
<yew-usage>
|
||||||
|
In Yew components, use class names as string literals:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
html! {
|
||||||
|
<div class="quote-card">
|
||||||
|
<blockquote class="quote-card__text">{ "e.text }</blockquote>
|
||||||
|
<cite class="quote-card__author">{ "e.author }</cite>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For conditional classes use the `classes!` macro:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
html! {
|
||||||
|
<button class={classes!("btn", "btn--primary", disabled.then_some("btn--disabled"))}>
|
||||||
|
{ "Submit" }
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</yew-usage>
|
||||||
|
|
||||||
|
<validation>
|
||||||
|
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.
|
||||||
|
</validation>
|
||||||
|
|
||||||
|
<commit>
|
||||||
|
`style(quotesdb): add UI stylesheet with BEM component classes`
|
||||||
|
</commit>
|
||||||
@ -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"]
|
||||||
|
+++
|
||||||
|
|
||||||
|
<context>
|
||||||
|
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.
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<goal>
|
||||||
|
Create `.gitea/workflows/deploy-ui.yml` at the repository root (not inside `quotesdb/`).
|
||||||
|
</goal>
|
||||||
|
|
||||||
|
<implementation>
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
</implementation>
|
||||||
|
|
||||||
|
<secrets>
|
||||||
|
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.
|
||||||
|
</secrets>
|
||||||
|
|
||||||
|
<notes>
|
||||||
|
- 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).
|
||||||
|
</notes>
|
||||||
|
|
||||||
|
<constraints>
|
||||||
|
- 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.
|
||||||
|
</constraints>
|
||||||
|
|
||||||
|
<validation>
|
||||||
|
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`
|
||||||
|
</validation>
|
||||||
|
|
||||||
|
<commit>
|
||||||
|
`ci(quotesdb): add Gitea Actions workflow to build and deploy UI to Cloudflare Pages`
|
||||||
|
</commit>
|
||||||
@ -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 = []
|
||||||
|
+++
|
||||||
|
|
||||||
|
<context>
|
||||||
|
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.
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<goal>
|
||||||
|
**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<String> {
|
||||||
|
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<String>`
|
||||||
|
prop. Pre-populate the `<input>` 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<String>,
|
||||||
|
pub on_cancel: Callback<()>,
|
||||||
|
pub initial_value: Option<String>, // 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.
|
||||||
|
</goal>
|
||||||
|
|
||||||
|
<constraints>
|
||||||
|
- 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.
|
||||||
|
</constraints>
|
||||||
|
|
||||||
|
<validation>
|
||||||
|
```sh
|
||||||
|
trunk build
|
||||||
|
```
|
||||||
|
</validation>
|
||||||
|
|
||||||
|
<commit>
|
||||||
|
`feat(quotesdb): implement auth code session storage utility and AuthModal pre-fill`
|
||||||
|
</commit>
|
||||||
|
|
||||||
|
<domain>quotesdb/ui</domain>
|
||||||
@ -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"]
|
||||||
|
+++
|
||||||
|
|
||||||
|
<context>
|
||||||
|
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).
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<goal>
|
||||||
|
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.
|
||||||
|
</goal>
|
||||||
|
|
||||||
|
<implementation>
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
</implementation>
|
||||||
|
|
||||||
|
<secrets>
|
||||||
|
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.
|
||||||
|
</secrets>
|
||||||
|
|
||||||
|
<notes>
|
||||||
|
- `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.
|
||||||
|
</notes>
|
||||||
|
|
||||||
|
<constraints>
|
||||||
|
- 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.
|
||||||
|
</constraints>
|
||||||
|
|
||||||
|
<validation>
|
||||||
|
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
|
||||||
|
</validation>
|
||||||
|
|
||||||
|
<commit>
|
||||||
|
`ci(quotesdb): add Gitea Actions workflow to build and deploy API Worker via OpenTofu`
|
||||||
|
</commit>
|
||||||
@ -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 = []
|
||||||
|
+++
|
||||||
|
|
||||||
|
<context>
|
||||||
|
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.
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<goal>
|
||||||
|
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)
|
||||||
|
</goal>
|
||||||
|
|
||||||
|
<implementation>
|
||||||
|
|
||||||
|
## 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()
|
||||||
|
```
|
||||||
|
|
||||||
|
</implementation>
|
||||||
|
|
||||||
|
<constraints>
|
||||||
|
- `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)
|
||||||
|
</constraints>
|
||||||
|
|
||||||
|
<skills>
|
||||||
|
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.
|
||||||
|
</skills>
|
||||||
|
|
||||||
|
<validation>
|
||||||
|
Run in order from the `quotesdb/` directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt
|
||||||
|
cargo check
|
||||||
|
cargo clippy
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
</validation>
|
||||||
|
|
||||||
|
<commit>
|
||||||
|
`feat(quotesdb): add generate_id() using UUID v4 — WASM-compatible ID generation`
|
||||||
|
</commit>
|
||||||
@ -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 = []
|
||||||
|
+++
|
||||||
|
|
||||||
|
<context>
|
||||||
|
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).
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<goal>
|
||||||
|
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
|
||||||
|
```
|
||||||
|
</goal>
|
||||||
|
|
||||||
|
<constraints>
|
||||||
|
- `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`.
|
||||||
|
</constraints>
|
||||||
|
|
||||||
|
<downstream>
|
||||||
|
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.
|
||||||
|
</downstream>
|
||||||
|
|
||||||
|
<validation>
|
||||||
|
```sh
|
||||||
|
cargo fmt
|
||||||
|
cargo check
|
||||||
|
cargo clippy
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
</validation>
|
||||||
|
|
||||||
|
<commit>
|
||||||
|
`chore(quotesdb): add build.rs to convert api/openapi.yaml to JSON at compile time`
|
||||||
|
</commit>
|
||||||
|
|
||||||
|
<domain>quotesdb/api</domain>
|
||||||
@ -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"]
|
||||||
|
+++
|
||||||
|
|
||||||
|
<context>
|
||||||
|
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.
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<goal>
|
||||||
|
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
|
||||||
|
```
|
||||||
|
</goal>
|
||||||
|
|
||||||
|
<decisions-reflected>
|
||||||
|
- 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.
|
||||||
|
</decisions-reflected>
|
||||||
|
|
||||||
|
<constraints>
|
||||||
|
- `.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.
|
||||||
|
</constraints>
|
||||||
|
|
||||||
|
<validation>
|
||||||
|
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
|
||||||
|
```
|
||||||
|
</validation>
|
||||||
|
|
||||||
|
<commit>
|
||||||
|
`chore(quotesdb): add .env.example documenting DATABASE_URL for local dev`
|
||||||
|
</commit>
|
||||||
@ -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 = []
|
||||||
|
+++
|
||||||
|
|
||||||
|
<context>
|
||||||
|
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 `<link data-trunk rel="copy-file"
|
||||||
|
href="_redirects"/>` 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.
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<goal>
|
||||||
|
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 `<head>`, alongside the other `data-trunk` links:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link data-trunk rel="copy-file" href="_redirects"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
</goal>
|
||||||
|
|
||||||
|
<constraints>
|
||||||
|
- 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.
|
||||||
|
</constraints>
|
||||||
|
|
||||||
|
<validation>
|
||||||
|
```sh
|
||||||
|
trunk build
|
||||||
|
ls dist/_redirects # must exist
|
||||||
|
cat dist/_redirects # must print: /* /index.html 200
|
||||||
|
```
|
||||||
|
</validation>
|
||||||
|
|
||||||
|
<domain>quotesdb/ui</domain>
|
||||||
@ -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
|
priority = 5
|
||||||
status = "todo"
|
status = "todo"
|
||||||
ticket_type = "task"
|
ticket_type = "task"
|
||||||
dependencies = ["f3dc74", "33ed29"]
|
dependencies = ["33ed29"]
|
||||||
+++
|
+++
|
||||||
|
|
||||||
<context>
|
<context>
|
||||||
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.
|
||||||
</context>
|
</context>
|
||||||
|
|
||||||
<goal>
|
<goal>
|
||||||
Write documentation (in `docs/PLANNING.md` or a dedicated `docs/LOCAL_DEV.md`) explaining how to set up and run the API locally:
|
Write `docs/LOCAL_DEV.md` explaining how to set up and run the quotesdb project locally:
|
||||||
1. How to install/run Turso for a local SQLite file
|
|
||||||
2. What environment variables to set (database URL, etc.)
|
1. **Prerequisites** — Rust (via Nix flake), no Cloudflare account needed
|
||||||
3. How to run `cargo run` to start the API server
|
2. **Running the API**:
|
||||||
4. Any `wrangler.toml` configuration needed for `wrangler dev` (if applicable)
|
- `cargo run` from the `quotesdb/` directory
|
||||||
5. How the D1 vs Turso selection is made at runtime
|
- 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
|
||||||
</goal>
|
</goal>
|
||||||
|
|
||||||
|
<decisions-reflected>
|
||||||
|
- 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
|
||||||
|
</decisions-reflected>
|
||||||
|
|
||||||
<constraints>
|
<constraints>
|
||||||
- Do not commit any `.env` files — document the variables, not the values.
|
- Do not document `.env` files directly — list the env vars and their defaults, but note that `.env` is gitignored.
|
||||||
- Cross-reference the TRIAGE ticket 33ed29 decision on Turso vs D1 local selection strategy.
|
- 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.
|
||||||
</constraints>
|
</constraints>
|
||||||
|
|
||||||
<commit>
|
<commit>
|
||||||
`docs(quotesdb): document local dev environment setup for api`
|
`docs(quotesdb): write LOCAL_DEV.md — local dev quickstart for api and ui`
|
||||||
</commit>
|
</commit>
|
||||||
@ -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"]
|
||||||
|
+++
|
||||||
|
|
||||||
|
<context>
|
||||||
|
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`.
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<goal>
|
||||||
|
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.
|
||||||
|
</goal>
|
||||||
|
|
||||||
|
<implementation>
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
</implementation>
|
||||||
|
|
||||||
|
<why-not-the-other-options>
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
</why-not-the-other-options>
|
||||||
|
|
||||||
|
<constraints>
|
||||||
|
- `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.
|
||||||
|
</constraints>
|
||||||
|
|
||||||
|
<validation>
|
||||||
|
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
|
||||||
|
```
|
||||||
|
</validation>
|
||||||
|
|
||||||
|
<related-tickets>
|
||||||
|
- 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
|
||||||
|
</related-tickets>
|
||||||
|
|
||||||
|
<commit>
|
||||||
|
`feat(quotesdb): add infra/schema.sql with idempotent D1 schema for quotes and quote_tags`
|
||||||
|
</commit>
|
||||||
Loading…
Reference in New Issue