An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.

docs: add MM-72 implementation plans

authored by malpercio.dev and committed by

Tangled 29b1f022 f3395226

+1298
+140
docs/implementation-plans/2026-03-10-MM-72/phase_01.md
··· 1 + # MM-72 SQLite Migration Infrastructure — Implementation Plan 2 + 3 + **Goal:** Add `sqlx` to the workspace and verify the build is clean before any db module code is written. 4 + 5 + **Architecture:** Infrastructure-only phase. Two Cargo.toml edits and a build check. No functional code, no tests. 6 + 7 + **Tech Stack:** Rust stable, Cargo workspace, sqlx 0.8 (`runtime-tokio` + `sqlite` features) 8 + 9 + **Scope:** Phase 1 of 3 from the original design plan. 10 + 11 + **Codebase verified:** 2026-03-10 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + This phase is infrastructure. It establishes the dependency but introduces no behaviour to test. 18 + 19 + **Verifies: None** — Phase 1 is verified operationally (`cargo build` + `cargo clippy` passing). 20 + 21 + --- 22 + 23 + <!-- START_TASK_1 --> 24 + ### Task 1: Add sqlx to workspace Cargo.toml 25 + 26 + **Files:** 27 + - Modify: `Cargo.toml` (workspace root — after the `axum` entry in `[workspace.dependencies]`) 28 + 29 + **Step 1: Insert sqlx into [workspace.dependencies]** 30 + 31 + Open `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/Cargo.toml`. 32 + 33 + After the `axum = "0.7"` entry and its blank line, add a `# Database` section: 34 + 35 + ```toml 36 + # Web framework (relay) 37 + axum = "0.7" 38 + 39 + # Database 40 + sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } 41 + 42 + # Serialization 43 + ``` 44 + 45 + The file should look like this around the insertion point: 46 + 47 + ```toml 48 + # Web framework (relay) 49 + axum = "0.7" 50 + 51 + # Database 52 + sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } 53 + 54 + # Serialization 55 + serde = { version = "1", features = ["derive"] } 56 + ``` 57 + 58 + **Step 2: Verify the edit** 59 + 60 + Run: 61 + ```bash 62 + grep -n "sqlx" Cargo.toml 63 + ``` 64 + Expected output: one line showing `sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }`. 65 + 66 + **Step 3: Commit** 67 + 68 + ```bash 69 + git add Cargo.toml 70 + git commit -m "chore(deps): add sqlx 0.8 to workspace dependencies" 71 + ``` 72 + <!-- END_TASK_1 --> 73 + 74 + <!-- START_TASK_2 --> 75 + ### Task 2: Opt relay crate into sqlx 76 + 77 + **Files:** 78 + - Modify: `crates/relay/Cargo.toml` — add `sqlx` to `[dependencies]`, after the `tower-http` entry and before `[dev-dependencies]` 79 + 80 + **Step 1: Insert sqlx into relay [dependencies]** 81 + 82 + Open `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/Cargo.toml`. 83 + 84 + After the `tower-http = { workspace = true }` entry in `[dependencies]`, add: 85 + 86 + ```toml 87 + tower-http = { workspace = true } 88 + sqlx = { workspace = true } 89 + 90 + [dev-dependencies] 91 + ``` 92 + 93 + The `[dependencies]` section should look like this after the edit: 94 + 95 + ```toml 96 + [dependencies] 97 + axum = { workspace = true } 98 + common = { workspace = true, features = ["axum"] } 99 + clap = { workspace = true } 100 + anyhow = { workspace = true } 101 + tracing = { workspace = true } 102 + tracing-subscriber = { workspace = true } 103 + tokio = { workspace = true } 104 + tower-http = { workspace = true } 105 + sqlx = { workspace = true } 106 + 107 + [dev-dependencies] 108 + tower = { workspace = true } 109 + serde_json = { workspace = true } 110 + ``` 111 + 112 + **Step 2: Verify the edit** 113 + 114 + Run: 115 + ```bash 116 + grep -n "sqlx" crates/relay/Cargo.toml 117 + ``` 118 + Expected output: one line showing `sqlx = { workspace = true }`. 119 + 120 + **Step 3: Verify build and lint pass** 121 + 122 + Run: 123 + ```bash 124 + cargo build --workspace 125 + ``` 126 + Expected: compiles successfully. On first run, cargo will download and compile sqlx and its dependencies including `libsqlite3-sys`. If `LIBSQLITE3_SYS_USE_PKG_CONFIG=1` is set (devenv auto-sets it), sqlx links against the Nix-provided SQLite. If absent (CI/Docker), it compiles bundled SQLite — no action needed either way. 127 + 128 + Run: 129 + ```bash 130 + cargo clippy --workspace -- -D warnings 131 + ``` 132 + Expected: zero warnings, zero errors. 133 + 134 + **Step 4: Commit** 135 + 136 + ```bash 137 + git add crates/relay/Cargo.toml 138 + git commit -m "chore(deps): opt relay into sqlx workspace dependency" 139 + ``` 140 + <!-- END_TASK_2 -->
+463
docs/implementation-plans/2026-03-10-MM-72/phase_02.md
··· 1 + # MM-72 SQLite Migration Infrastructure — Implementation Plan 2 + 3 + **Goal:** Implement the `db/` module inside `crates/relay/src/` with pool factory, forward-only migration runner, Wave 1 schema (`V001__init.sql`), and unit tests using in-memory SQLite. 4 + 5 + **Architecture:** `db/mod.rs` is the imperative shell for all database I/O. `DbError` mirrors the `ConfigError` thiserror pattern. `run_migrations` creates `schema_migrations` before any `.sql` file is executed. `MIGRATIONS` embeds `.sql` files at compile time via `include_str!`. Tests stay entirely in-memory — no disk files for the migration runner tests. 6 + 7 + **Tech Stack:** Rust stable, sqlx 0.8 (`SqlitePool`, `SqlitePoolOptions`, `SqliteConnectOptions`, `SqliteJournalMode`), thiserror 2, tempfile 3 (dev only, for WAL mode test) 8 + 9 + **Scope:** Phase 2 of 3 from the original design plan. 10 + 11 + **Codebase verified:** 2026-03-10 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + ### MM-72.AC2: Migrations are idempotent 18 + - **MM-72.AC2.1 Success:** Running the relay a second time does not re-apply V001 — row count in `schema_migrations` remains 1 19 + - **MM-72.AC2.2 Success:** `schema_migrations` records `version = 1` with a non-null `applied_at` timestamp after first run 20 + 21 + ### MM-72.AC4: WAL mode enabled 22 + - **MM-72.AC4.1 Success:** `PRAGMA journal_mode` queried on the pool returns `wal` 23 + 24 + ### MM-72.AC5: Unit tests use in-memory SQLite 25 + - **MM-72.AC5.1 Success:** Migration runner unit tests use `":memory:"` — no `relay.db` or temp files created on disk during `cargo test` 26 + - **MM-72.AC5.2 Success:** `cargo test --workspace` passes in a clean environment with no pre-existing `relay.db` 27 + 28 + ### MM-72.AC6: Toolchain checks pass 29 + - **MM-72.AC6.1 Success:** `cargo clippy --workspace -- -D warnings` passes with no warnings 30 + - **MM-72.AC6.2 Success:** `cargo fmt --all --check` passes 31 + 32 + --- 33 + 34 + <!-- START_TASK_1 --> 35 + ### Task 1: Add thiserror and tempfile to relay Cargo.toml 36 + 37 + **Verifies:** None — infrastructure task 38 + 39 + **Files:** 40 + - Modify: `crates/relay/Cargo.toml` 41 + 42 + **Step 1: Add thiserror to [dependencies] and tempfile to [dev-dependencies]** 43 + 44 + Open `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/Cargo.toml`. 45 + 46 + After Phase 1, the file looks like: 47 + ```toml 48 + [dependencies] 49 + axum = { workspace = true } 50 + common = { workspace = true, features = ["axum"] } 51 + clap = { workspace = true } 52 + anyhow = { workspace = true } 53 + tracing = { workspace = true } 54 + tracing-subscriber = { workspace = true } 55 + tokio = { workspace = true } 56 + tower-http = { workspace = true } 57 + sqlx = { workspace = true } 58 + 59 + [dev-dependencies] 60 + tower = { workspace = true } 61 + serde_json = { workspace = true } 62 + ``` 63 + 64 + Add `thiserror = { workspace = true }` to `[dependencies]` and `tempfile = { workspace = true }` to `[dev-dependencies]`: 65 + 66 + ```toml 67 + [dependencies] 68 + axum = { workspace = true } 69 + common = { workspace = true, features = ["axum"] } 70 + clap = { workspace = true } 71 + anyhow = { workspace = true } 72 + thiserror = { workspace = true } 73 + tracing = { workspace = true } 74 + tracing-subscriber = { workspace = true } 75 + tokio = { workspace = true } 76 + tower-http = { workspace = true } 77 + sqlx = { workspace = true } 78 + 79 + [dev-dependencies] 80 + tower = { workspace = true } 81 + serde_json = { workspace = true } 82 + tempfile = { workspace = true } 83 + ``` 84 + 85 + **Step 2: Verify** 86 + 87 + Run: 88 + ```bash 89 + cargo build -p relay 90 + ``` 91 + Expected: compiles without errors. 92 + 93 + **Step 3: Commit** 94 + 95 + ```bash 96 + git add crates/relay/Cargo.toml 97 + git commit -m "chore(deps): add thiserror + tempfile to relay crate" 98 + ``` 99 + <!-- END_TASK_1 --> 100 + 101 + <!-- START_SUBCOMPONENT_A (tasks 2-4) --> 102 + 103 + <!-- START_TASK_2 --> 104 + ### Task 2: Create the db/migrations directory and V001__init.sql 105 + 106 + **Verifies:** None — infrastructure task (presence of this file is compile-time verified by `include_str!` in Task 3) 107 + 108 + **Files:** 109 + - Create: `crates/relay/src/db/migrations/V001__init.sql` 110 + 111 + **Step 1: Create the directory and SQL file** 112 + 113 + ```bash 114 + mkdir -p /Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/db/migrations 115 + ``` 116 + 117 + Create `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/db/migrations/V001__init.sql` with this exact content: 118 + 119 + ```sql 120 + CREATE TABLE server_metadata ( 121 + key TEXT NOT NULL, 122 + value TEXT NOT NULL, 123 + PRIMARY KEY (key) 124 + ) WITHOUT ROWID; 125 + ``` 126 + 127 + Note: `schema_migrations` is NOT defined here. The migration runner creates it with `CREATE TABLE IF NOT EXISTS` before executing any `.sql` files. `V001__init.sql` only needs to create `server_metadata`. 128 + 129 + **Step 2: Commit** 130 + 131 + ```bash 132 + git add crates/relay/src/db/migrations/V001__init.sql 133 + git commit -m "feat(db): add V001__init.sql Wave 1 schema (server_metadata)" 134 + ``` 135 + <!-- END_TASK_2 --> 136 + 137 + <!-- START_TASK_3 --> 138 + ### Task 3: Create db/mod.rs — DbError, open_pool, run_migrations, unit tests 139 + 140 + **Verifies:** MM-72.AC2.1, MM-72.AC2.2, MM-72.AC4.1, MM-72.AC5.1, MM-72.AC5.2 141 + 142 + **Design note:** The Definition of Done in the design plan (line 11) describes `open_pool(path: &Path)`, but the Architecture section (line 89) specifies `open_pool(url: &str)`. This implementation follows the Architecture section — `&str` is correct because `SqliteConnectOptions::from_str` requires a URL string, not a `Path`. The DoD has a minor inconsistency that can be ignored. 143 + 144 + **Files:** 145 + - Create: `crates/relay/src/db/mod.rs` 146 + 147 + **Step 1: Create the file with the following exact content** 148 + 149 + Create `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/db/mod.rs`: 150 + 151 + ```rust 152 + // pattern: Imperative Shell 153 + use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}; 154 + use sqlx::SqlitePool; 155 + use std::str::FromStr; 156 + 157 + /// Errors from database pool creation or migration execution. 158 + #[derive(Debug, thiserror::Error)] 159 + pub enum DbError { 160 + #[error("failed to open database pool: {0}")] 161 + Pool(#[from] sqlx::Error), 162 + /// Errors in migration infrastructure (bootstrap table, transaction control). 163 + /// Distinct from `Migration` so operators know there is no version 0 to look for. 164 + #[error("failed to initialize migration infrastructure: {0}")] 165 + Setup(sqlx::Error), 166 + #[error("migration v{version} failed: {source}")] 167 + Migration { version: u32, source: sqlx::Error }, 168 + } 169 + 170 + struct Migration { 171 + version: u32, 172 + sql: &'static str, 173 + } 174 + 175 + static MIGRATIONS: &[Migration] = &[Migration { 176 + version: 1, 177 + sql: include_str!("migrations/V001__init.sql"), 178 + }]; 179 + 180 + /// Open a WAL-mode SQLite connection pool with a maximum of 1 connection. 181 + /// 182 + /// Accepts any sqlx URL string (e.g. `"sqlite:relay.db"`, `"sqlite::memory:"`). 183 + /// `create_if_missing` is enabled so the file is created on first run. 184 + /// WAL journal mode is set via `SqliteConnectOptions` — not a raw PRAGMA — so 185 + /// sqlx tracks the mode across the connection lifecycle. 186 + pub async fn open_pool(url: &str) -> Result<SqlitePool, DbError> { 187 + let opts = SqliteConnectOptions::from_str(url)? 188 + .create_if_missing(true) 189 + .journal_mode(SqliteJournalMode::Wal); 190 + 191 + SqlitePoolOptions::new() 192 + .max_connections(1) 193 + .connect_with(opts) 194 + .await 195 + .map_err(DbError::Pool) 196 + } 197 + 198 + /// Apply any pending migrations from `MIGRATIONS` to the given pool. 199 + /// 200 + /// Creates `schema_migrations` if it does not exist, reads which versions 201 + /// are already recorded, then applies all pending migrations in a single 202 + /// transaction and records each applied version. 203 + pub async fn run_migrations(pool: &SqlitePool) -> Result<(), DbError> { 204 + // Bootstrap the tracking table before any migration SQL runs. 205 + sqlx::query( 206 + "CREATE TABLE IF NOT EXISTS schema_migrations ( 207 + version INTEGER PRIMARY KEY, 208 + applied_at TEXT NOT NULL 209 + ) WITHOUT ROWID", 210 + ) 211 + .execute(pool) 212 + .await 213 + .map_err(DbError::Setup)?; 214 + 215 + // Fetch already-applied versions. 216 + let applied: Vec<(i32,)> = sqlx::query_as("SELECT version FROM schema_migrations") 217 + .fetch_all(pool) 218 + .await 219 + .map_err(DbError::Setup)?; 220 + let applied_set: std::collections::HashSet<u32> = 221 + applied.into_iter().map(|(v,)| v as u32).collect(); 222 + 223 + // Collect pending migrations in order. 224 + let pending: Vec<&Migration> = MIGRATIONS 225 + .iter() 226 + .filter(|m| !applied_set.contains(&m.version)) 227 + .collect(); 228 + 229 + if pending.is_empty() { 230 + return Ok(()); 231 + } 232 + 233 + // Apply all pending migrations in one transaction. 234 + let mut tx = pool.begin().await.map_err(DbError::Setup)?; 235 + 236 + for migration in pending { 237 + // Use raw_sql (not query) so multi-statement SQL files execute fully. 238 + sqlx::raw_sql(migration.sql) 239 + .execute(&mut *tx) 240 + .await 241 + .map_err(|e| DbError::Migration { 242 + version: migration.version, 243 + source: e, 244 + })?; 245 + 246 + sqlx::query( 247 + "INSERT INTO schema_migrations (version, applied_at) VALUES (?, datetime('now'))", 248 + ) 249 + .bind(migration.version as i32) 250 + .execute(&mut *tx) 251 + .await 252 + .map_err(|e| DbError::Migration { 253 + version: migration.version, 254 + source: e, 255 + })?; 256 + } 257 + 258 + tx.commit().await.map_err(DbError::Setup)?; 259 + 260 + Ok(()) 261 + } 262 + 263 + #[cfg(test)] 264 + mod tests { 265 + use super::*; 266 + 267 + /// Open a fresh in-memory pool for each test. 268 + /// Uses "sqlite::memory:" — no files created on disk. 269 + async fn in_memory_pool() -> SqlitePool { 270 + open_pool("sqlite::memory:").await.expect("failed to open in-memory pool") 271 + } 272 + 273 + #[tokio::test] 274 + async fn select_one_succeeds() { 275 + let pool = in_memory_pool().await; 276 + let (n,): (i64,) = sqlx::query_as("SELECT 1") 277 + .fetch_one(&pool) 278 + .await 279 + .unwrap(); 280 + assert_eq!(n, 1); 281 + } 282 + 283 + #[tokio::test] 284 + async fn migrations_apply_on_first_run() { 285 + let pool = in_memory_pool().await; 286 + run_migrations(&pool).await.unwrap(); 287 + 288 + let (count,): (i64,) = 289 + sqlx::query_as("SELECT COUNT(*) FROM schema_migrations") 290 + .fetch_one(&pool) 291 + .await 292 + .unwrap(); 293 + assert_eq!(count, 1); 294 + } 295 + 296 + /// MM-72.AC2.1: Running migrations twice leaves only one row in schema_migrations. 297 + #[tokio::test] 298 + async fn migrations_are_idempotent() { 299 + let pool = in_memory_pool().await; 300 + run_migrations(&pool).await.unwrap(); 301 + run_migrations(&pool).await.unwrap(); // second call — must be a no-op 302 + 303 + let (count,): (i64,) = 304 + sqlx::query_as("SELECT COUNT(*) FROM schema_migrations") 305 + .fetch_one(&pool) 306 + .await 307 + .unwrap(); 308 + assert_eq!(count, 1, "second run must not insert a duplicate migration row"); 309 + } 310 + 311 + /// MM-72.AC2.2: schema_migrations records version=1 with a non-null applied_at. 312 + #[tokio::test] 313 + async fn schema_migrations_records_version_and_timestamp() { 314 + let pool = in_memory_pool().await; 315 + run_migrations(&pool).await.unwrap(); 316 + 317 + let (version, applied_at): (i64, String) = sqlx::query_as( 318 + "SELECT version, applied_at FROM schema_migrations WHERE version = 1", 319 + ) 320 + .fetch_one(&pool) 321 + .await 322 + .unwrap(); 323 + 324 + assert_eq!(version, 1); 325 + assert!(!applied_at.is_empty(), "applied_at must be non-empty"); 326 + } 327 + 328 + #[tokio::test] 329 + async fn server_metadata_table_exists_and_accepts_inserts() { 330 + let pool = in_memory_pool().await; 331 + run_migrations(&pool).await.unwrap(); 332 + 333 + sqlx::query( 334 + "INSERT INTO server_metadata (key, value) VALUES ('test_key', 'test_value')", 335 + ) 336 + .execute(&pool) 337 + .await 338 + .unwrap(); 339 + 340 + let (value,): (String,) = 341 + sqlx::query_as("SELECT value FROM server_metadata WHERE key = 'test_key'") 342 + .fetch_one(&pool) 343 + .await 344 + .unwrap(); 345 + assert_eq!(value, "test_value"); 346 + } 347 + 348 + /// MM-72.AC4.1: WAL mode requires a real file — use tempfile here, not :memory:. 349 + /// In-memory SQLite reports journal_mode = "memory", not "wal". 350 + #[tokio::test] 351 + async fn wal_mode_enabled_on_file_pool() { 352 + let dir = tempfile::tempdir().unwrap(); 353 + let db_path = dir.path().join("test_wal.db"); 354 + let url = format!("sqlite:{}", db_path.display()); 355 + 356 + let pool = open_pool(&url).await.unwrap(); 357 + 358 + let (mode,): (String,) = sqlx::query_as("PRAGMA journal_mode") 359 + .fetch_one(&pool) 360 + .await 361 + .unwrap(); 362 + 363 + assert_eq!(mode, "wal", "pool must use WAL journal mode"); 364 + } 365 + } 366 + ``` 367 + 368 + **Step 2: Verify compilation** 369 + 370 + Run: 371 + ```bash 372 + cargo build -p relay 373 + ``` 374 + Expected: compiles without errors. The `include_str!("migrations/V001__init.sql")` macro is verified at compile time — if the file path is wrong, the build fails here. 375 + 376 + **Step 3: Run unit tests** 377 + 378 + Run: 379 + ```bash 380 + cargo test -p relay db:: 381 + ``` 382 + Expected: all 6 tests in `db::tests` pass. No `relay.db` or files created in the project directory. 383 + 384 + **Step 4: Run full workspace tests** 385 + 386 + Run: 387 + ```bash 388 + cargo test --workspace 389 + ``` 390 + Expected: all tests pass, including the pre-existing 5 tests in `relay::app::tests`. 391 + 392 + **Step 5: Commit** 393 + 394 + ```bash 395 + git add crates/relay/src/db/mod.rs 396 + git commit -m "feat(db): add db module with pool factory, migration runner, and unit tests" 397 + ``` 398 + <!-- END_TASK_3 --> 399 + 400 + <!-- START_TASK_4 --> 401 + ### Task 4: Register db module in main.rs and verify toolchain checks 402 + 403 + **Verifies:** MM-72.AC6.1, MM-72.AC6.2 404 + 405 + **Files:** 406 + - Modify: `crates/relay/src/main.rs` (line 5 — after `mod app;`) 407 + 408 + **Step 1: Add mod db declaration** 409 + 410 + Open `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/main.rs`. 411 + 412 + After line 5 (`mod app;`), add: 413 + 414 + ```rust 415 + mod app; 416 + mod db; 417 + ``` 418 + 419 + The `db` module is declared here but not yet used in `run()` — that wiring happens in Phase 3. Declaring it here ensures the module compiles as part of the binary and the compiler checks for errors. 420 + 421 + **Step 2: Suppress dead_code warning** 422 + 423 + Because `db` is declared but not used in `run()` yet, cargo clippy will emit a warning. Add `#[allow(dead_code)]` to the module declaration to suppress it until Phase 3 wires it in: 424 + 425 + ```rust 426 + mod app; 427 + #[allow(dead_code)] 428 + mod db; 429 + ``` 430 + 431 + **Step 3: Run clippy** 432 + 433 + Run: 434 + ```bash 435 + cargo clippy --workspace -- -D warnings 436 + ``` 437 + Expected: zero warnings, zero errors. 438 + 439 + **Step 4: Run fmt check** 440 + 441 + Run: 442 + ```bash 443 + cargo fmt --all --check 444 + ``` 445 + Expected: no formatting differences. If differences are found, run `cargo fmt --all` to fix them, then re-run the check. 446 + 447 + **Step 5: Run all workspace tests** 448 + 449 + Run: 450 + ```bash 451 + cargo test --workspace 452 + ``` 453 + Expected: all tests pass (11 relay tests: 5 existing app tests + 6 new db tests). 454 + 455 + **Step 6: Commit** 456 + 457 + ```bash 458 + git add crates/relay/src/main.rs 459 + git commit -m "feat(relay): declare db module in main.rs" 460 + ``` 461 + <!-- END_TASK_4 --> 462 + 463 + <!-- END_SUBCOMPONENT_A -->
+401
docs/implementation-plans/2026-03-10-MM-72/phase_03.md
··· 1 + # MM-72 SQLite Migration Infrastructure — Implementation Plan 2 + 3 + **Goal:** Wire `open_pool` + `run_migrations` into `main.rs`, add `db: SqlitePool` to `AppState`, and update the test fixture — making the pool available to every Axum handler via Axum's `State` extractor. 4 + 5 + **Architecture:** `main.rs` (imperative shell) calls `db::open_pool` and `db::run_migrations` after config load and before AppState construction. `AppState` gains a `db: SqlitePool` field — no `Arc` wrapping needed since `SqlitePool` is already Arc-backed. The `test_state()` fixture becomes `async` and opens an in-memory pool with migrations applied. 6 + 7 + **Tech Stack:** Rust stable, sqlx 0.8 `SqlitePool`, axum 0.7 `State` extractor, anyhow `Context` trait 8 + 9 + **Scope:** Phase 3 of 3 from the original design plan. 10 + 11 + **Codebase verified:** 2026-03-10 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + ### MM-72.AC1: relay.db created on first start 18 + - **MM-72.AC1.1 Success:** `cargo run --bin relay` (with a valid `relay.toml`) creates `relay.db` in the configured `data_dir` 19 + - **MM-72.AC1.2 Success:** `schema_migrations` table exists in the produced database 20 + - **MM-72.AC1.3 Success:** `server_metadata` table exists in the produced database 21 + 22 + ### MM-72.AC2: Migrations are idempotent 23 + - **MM-72.AC2.1 Success:** Running the relay a second time does not re-apply V001 — row count in `schema_migrations` remains 1 24 + 25 + ### MM-72.AC3: Pool available in AppState 26 + - **MM-72.AC3.1 Success:** Handler tests that extract `State<AppState>` compile and pass with the `db: SqlitePool` field present 27 + - **MM-72.AC3.2 Success:** `sqlx::query("SELECT 1").execute(&state.db)` succeeds in tests using an in-memory pool 28 + 29 + ### MM-72.AC5: Unit tests use in-memory SQLite 30 + - **MM-72.AC5.2 Success:** `cargo test --workspace` passes in a clean environment with no pre-existing `relay.db` 31 + 32 + ### MM-72.AC6: Toolchain checks pass 33 + - **MM-72.AC6.1 Success:** `cargo clippy --workspace -- -D warnings` passes with no warnings 34 + - **MM-72.AC6.2 Success:** `cargo fmt --all --check` passes 35 + 36 + --- 37 + 38 + <!-- START_TASK_1 --> 39 + ### Task 1: Add db: SqlitePool to AppState and update test_state() 40 + 41 + **Verifies:** MM-72.AC3.1, MM-72.AC3.2 42 + 43 + **Files:** 44 + - Modify: `crates/relay/src/app.rs` 45 + 46 + **Step 1: Update AppState struct** 47 + 48 + Open `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/app.rs`. 49 + 50 + The current `AppState` (lines 7–13): 51 + ```rust 52 + /// Shared application state cloned into every request handler via Axum's `State` extractor. 53 + #[derive(Clone)] 54 + pub struct AppState { 55 + // Read by handlers once XRPC endpoints are implemented; suppressed until then. 56 + #[allow(dead_code)] 57 + pub config: Arc<Config>, 58 + } 59 + ``` 60 + 61 + Replace with: 62 + ```rust 63 + /// Shared application state cloned into every request handler via Axum's `State` extractor. 64 + #[derive(Clone)] 65 + pub struct AppState { 66 + // Read by handlers once XRPC endpoints are implemented; suppressed until then. 67 + #[allow(dead_code)] 68 + pub config: Arc<Config>, 69 + pub db: sqlx::SqlitePool, 70 + } 71 + ``` 72 + 73 + `sqlx::SqlitePool` is Arc-backed internally — no `Arc<Mutex<>>` wrapper is needed. The `#[derive(Clone)]` on `AppState` works because `SqlitePool` implements `Clone` (cloning is cheap — it just clones the Arc reference to the shared pool). 74 + 75 + **Step 2: Update the imports block** 76 + 77 + The current imports at the top of `app.rs`: 78 + ```rust 79 + use std::sync::Arc; 80 + 81 + use axum::{extract::Path, routing::get, Router}; 82 + use common::{ApiError, Config, ErrorCode}; 83 + use tower_http::{cors::CorsLayer, trace::TraceLayer}; 84 + ``` 85 + 86 + No import changes are needed — `sqlx::SqlitePool` is referenced with its full path in the struct to avoid ambiguity with future imports. 87 + 88 + **Step 3: Update test_state() to be async and open an in-memory pool** 89 + 90 + The `#[cfg(test)]` block currently starts at line 38. The `test_state()` function (lines 49–62): 91 + 92 + ```rust 93 + fn test_state() -> AppState { 94 + AppState { 95 + config: Arc::new(Config { 96 + bind_address: "127.0.0.1".to_string(), 97 + port: 8080, 98 + data_dir: PathBuf::from("/tmp"), 99 + database_url: "/tmp/test.db".to_string(), 100 + public_url: "https://test.example.com".to_string(), 101 + blobs: BlobsConfig::default(), 102 + oauth: OAuthConfig::default(), 103 + iroh: IrohConfig::default(), 104 + }), 105 + } 106 + } 107 + ``` 108 + 109 + Replace it with the async version: 110 + ```rust 111 + async fn test_state() -> AppState { 112 + let pool = crate::db::open_pool("sqlite::memory:") 113 + .await 114 + .expect("failed to open test pool"); 115 + crate::db::run_migrations(&pool) 116 + .await 117 + .expect("failed to run test migrations"); 118 + AppState { 119 + config: Arc::new(Config { 120 + bind_address: "127.0.0.1".to_string(), 121 + port: 8080, 122 + data_dir: PathBuf::from("/tmp"), 123 + database_url: "sqlite::memory:".to_string(), 124 + public_url: "https://test.example.com".to_string(), 125 + blobs: BlobsConfig::default(), 126 + oauth: OAuthConfig::default(), 127 + iroh: IrohConfig::default(), 128 + }), 129 + db: pool, 130 + } 131 + } 132 + ``` 133 + 134 + **Step 4: Update all test_state() call sites** 135 + 136 + Every existing test in `app.rs` calls `test_state()`. Since `test_state` is now async, each call must add `.await`: 137 + 138 + Find all occurrences of `test_state()` in the test block (there are 5 tests, each calling it once) and change each to `test_state().await`: 139 + 140 + ```rust 141 + // Before: 142 + let response = app(test_state()) 143 + 144 + // After: 145 + let response = app(test_state().await) 146 + ``` 147 + 148 + All 5 tests already use `#[tokio::test]` and are `async fn`, so `.await` is valid in each. 149 + 150 + **Step 5: Add a test that exercises state.db (AC3.2)** 151 + 152 + Add this test after the existing 5 tests in the `#[cfg(test)]` block: 153 + 154 + ```rust 155 + #[tokio::test] 156 + async fn appstate_db_pool_is_queryable() { 157 + let state = test_state().await; 158 + sqlx::query("SELECT 1") 159 + .execute(&state.db) 160 + .await 161 + .expect("db pool in AppState must be queryable"); 162 + } 163 + ``` 164 + 165 + **Step 6: Verify compilation** 166 + 167 + Run: 168 + ```bash 169 + cargo build -p relay 170 + ``` 171 + Expected: compiles without errors. 172 + 173 + **Step 7: Run relay tests** 174 + 175 + Run: 176 + ```bash 177 + cargo test -p relay 178 + ``` 179 + Expected: all 6 app tests pass (5 existing XRPC tests + 1 new db pool test), plus all 6 db module tests. 180 + 181 + **Step 8: Commit** 182 + 183 + ```bash 184 + git add crates/relay/src/app.rs 185 + git commit -m "feat(relay): add db: SqlitePool to AppState and update test fixture" 186 + ``` 187 + <!-- END_TASK_1 --> 188 + 189 + <!-- START_TASK_2 --> 190 + ### Task 2: Wire open_pool + run_migrations into main.rs 191 + 192 + **Verifies:** MM-72.AC1.1, MM-72.AC1.2, MM-72.AC1.3, MM-72.AC2.1 193 + 194 + **Files:** 195 + - Modify: `crates/relay/src/main.rs` 196 + 197 + **Step 1: Review current main.rs structure** 198 + 199 + The relevant section of `run()` in `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/main.rs` currently looks like this (lines 23–45): 200 + 201 + ```rust 202 + async fn run() -> anyhow::Result<()> { 203 + tracing_subscriber::fmt() 204 + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) 205 + .try_init() 206 + .map_err(|e| anyhow::anyhow!("failed to initialize tracing subscriber: {e}"))?; 207 + 208 + let cli = Cli::parse(); 209 + let config_path = cli.config.unwrap_or_else(|| PathBuf::from("relay.toml")); 210 + 211 + let config = common::load_config(&config_path) 212 + .with_context(|| format!("failed to load config from {}", config_path.display()))?; 213 + 214 + tracing::info!( 215 + bind_address = %config.bind_address, 216 + port = config.port, 217 + public_url = %config.public_url, 218 + "relay starting" 219 + ); 220 + 221 + let addr = format!("{}:{}", config.bind_address, config.port); 222 + let state = app::AppState { 223 + config: Arc::new(config), 224 + }; 225 + ... 226 + ``` 227 + 228 + **Step 2: Insert pool creation and migration wiring** 229 + 230 + Between the `tracing::info!` log and the `let addr = ...` line, insert the pool creation and migration steps. The `config.database_url` is a plain path by default (e.g. `/var/pds/relay.db`); format it as a valid sqlx URL before passing to `open_pool`. 231 + 232 + The updated `run()` function body, starting from `let addr`: 233 + 234 + ```rust 235 + let addr = format!("{}:{}", config.bind_address, config.port); 236 + 237 + // **Intentional deviation from design:** The design doc's startup sequence shows 238 + // `open_pool(&config.database_url)` directly. However, `config.database_url` defaults 239 + // to a plain filesystem path (e.g. `/var/pds/relay.db`) when not explicitly set, which 240 + // is not a valid sqlx URL. We format it here rather than changing Config or open_pool, 241 + // keeping both functions general-purpose. 242 + // 243 + // Plain absolute paths like "/var/pds/relay.db" become "sqlite:///var/pds/relay.db". 244 + // Already-formatted "sqlite://..." URLs pass through unchanged. 245 + let db_url = if config.database_url.starts_with("sqlite:") { 246 + config.database_url.clone() 247 + } else if config.database_url.starts_with('/') { 248 + format!("sqlite://{}", config.database_url) 249 + } else { 250 + format!("sqlite:{}", config.database_url) 251 + }; 252 + 253 + let pool = db::open_pool(&db_url) 254 + .await 255 + .with_context(|| format!("failed to open database at {}", config.database_url))?; 256 + 257 + db::run_migrations(&pool) 258 + .await 259 + .with_context(|| "failed to run database migrations")?; 260 + 261 + let state = app::AppState { 262 + config: Arc::new(config), 263 + db: pool, 264 + }; 265 + ``` 266 + 267 + **Why `.with_context()` works here:** `DbError` is derived via `thiserror`, which implements `std::error::Error`. `anyhow::Context` is implemented for `Result<T, E>` where `E: std::error::Error + Send + Sync + 'static` — so `.with_context(|| ...)` converts `DbError` into `anyhow::Error` automatically. No `.map_err` is needed. 268 + 269 + **Step 3: Verify the module declaration** 270 + 271 + Confirm that `mod db;` is present in main.rs (added in Phase 2, Task 4). The top of main.rs should read: 272 + 273 + ```rust 274 + use anyhow::Context; 275 + use clap::Parser; 276 + use std::{path::PathBuf, sync::Arc}; 277 + 278 + mod app; 279 + mod db; 280 + ``` 281 + 282 + If `mod db;` still has `#[allow(dead_code)]` from Phase 2 (Task 4 of Phase 2 added this as a temporary suppressor), remove the attribute now — `db` is actively used in `run()`. 283 + 284 + **Step 4: Verify build** 285 + 286 + Run: 287 + ```bash 288 + cargo build --workspace 289 + ``` 290 + Expected: compiles without errors. 291 + 292 + **Step 5: Run all tests** 293 + 294 + Run: 295 + ```bash 296 + cargo test --workspace 297 + ``` 298 + Expected: all tests pass. No `relay.db` file created in the project directory (tests use in-memory pools). 299 + 300 + **Step 6: Run clippy and fmt** 301 + 302 + Run: 303 + ```bash 304 + cargo clippy --workspace -- -D warnings 305 + cargo fmt --all --check 306 + ``` 307 + Expected: zero warnings, zero errors, no formatting differences. 308 + 309 + **Step 7: Commit** 310 + 311 + ```bash 312 + git add crates/relay/src/main.rs 313 + git commit -m "feat(relay): wire db pool and migrations into startup sequence" 314 + ``` 315 + <!-- END_TASK_2 --> 316 + 317 + <!-- START_TASK_3 --> 318 + ### Task 3: Manual verification — relay.db created on first start 319 + 320 + **Verifies:** MM-72.AC1.1, MM-72.AC1.2, MM-72.AC1.3, MM-72.AC2.1 (runtime) 321 + 322 + **Note:** These acceptance criteria require running the actual binary and cannot be automated with `cargo test` alone. This task verifies them manually. 323 + 324 + **Step 1: Ensure a valid relay.toml exists** 325 + 326 + The project includes `relay.dev.toml`. Copy it or create `relay.toml` in the workspace root with at minimum: 327 + 328 + ```toml 329 + data_dir = "/tmp/relay-test" 330 + public_url = "https://test.example.com" 331 + ``` 332 + 333 + Create the data directory: 334 + ```bash 335 + mkdir -p /tmp/relay-test 336 + ``` 337 + 338 + **Step 2: Run the relay binary** 339 + 340 + Run: 341 + ```bash 342 + cargo run --bin relay -- --config relay.toml 343 + ``` 344 + 345 + Expected startup output (tracing logs): 346 + ``` 347 + relay starting bind_address=0.0.0.0 port=8080 public_url=https://test.example.com 348 + listening address=0.0.0.0:8080 349 + ``` 350 + 351 + Press Ctrl+C to stop after the server binds. 352 + 353 + **Step 3: Verify relay.db was created** 354 + 355 + Run: 356 + ```bash 357 + ls -la /tmp/relay-test/relay.db 358 + ``` 359 + Expected: file exists. 360 + 361 + **Step 4: Verify tables exist** (AC1.2, AC1.3) 362 + 363 + Run: 364 + ```bash 365 + sqlite3 /tmp/relay-test/relay.db ".tables" 366 + ``` 367 + Expected output includes: `schema_migrations server_metadata` 368 + 369 + **Step 5: Verify schema_migrations has one row** (AC2.2) 370 + 371 + Run: 372 + ```bash 373 + sqlite3 /tmp/relay-test/relay.db "SELECT version, applied_at FROM schema_migrations;" 374 + ``` 375 + Expected output: 376 + ``` 377 + 1|<timestamp> 378 + ``` 379 + 380 + **Step 6: Run the binary a second time** (AC2.1) 381 + 382 + Run: 383 + ```bash 384 + cargo run --bin relay -- --config relay.toml 385 + ``` 386 + Stop with Ctrl+C after binding. 387 + 388 + **Step 7: Verify migration was NOT re-applied** 389 + 390 + Run: 391 + ```bash 392 + sqlite3 /tmp/relay-test/relay.db "SELECT COUNT(*) FROM schema_migrations;" 393 + ``` 394 + Expected: `1` (still one row, not two). 395 + 396 + **Step 8: Clean up** 397 + 398 + ```bash 399 + rm -rf /tmp/relay-test relay.toml 400 + ``` 401 + <!-- END_TASK_3 -->
+294
docs/implementation-plans/2026-03-10-MM-72/test-requirements.md
··· 1 + # Test Requirements: MM-72 SQLite Migration Infrastructure (Wave 1 Schema) 2 + 3 + Generated from test-analyst review of design plan `docs/design-plans/2026-03-10-MM-72.md` 4 + and implementation plans `docs/implementation-plans/2026-03-10-MM-72/`. 5 + 6 + **Automated coverage:** 11/16 acceptance criteria verified by unit/integration tests in `cargo test`. 7 + 8 + **Human verification:** 5 criteria require running the relay binary against a real filesystem and 9 + inspecting the produced database with `sqlite3`. These criteria are runtime-only by nature (file 10 + creation, WAL persistence on disk, idempotent startup across process restarts). 11 + 12 + --- 13 + 14 + ## Conventions 15 + 16 + - **AC identifiers** use the slugged form from the design plan: `MM-72.AC1.1`, `MM-72.AC2.1`, etc. 17 + - **Test file paths** are relative to the workspace root. 18 + - **In-memory vs. file-backed:** The design explicitly requires unit tests to use `":memory:"` (AC5.1). 19 + However, WAL mode cannot be verified on an in-memory database (SQLite reports `journal_mode = "memory"` 20 + for in-memory connections). The implementation plan resolves this by using `tempfile::tempdir()` for the 21 + WAL test only (phase_02.md Task 3, `wal_mode_enabled_on_file_pool`). This is consistent with AC5.1 22 + because the acceptance criterion scopes the in-memory requirement to "migration runner unit tests" -- 23 + the WAL test exercises `open_pool`, not `run_migrations`. 24 + - **AC1.x runtime verification:** The design plan's AC1 criteria ("relay.db created on first start") 25 + inherently require running the binary. The implementation plan documents this explicitly in 26 + phase_03.md Task 3 ("These acceptance criteria require running the actual binary and cannot be 27 + automated with `cargo test` alone"). They appear below under Human Verification. 28 + 29 + --- 30 + 31 + ## Automated Tests 32 + 33 + ### MM-72.AC2.1 -- Migrations are idempotent (row count) 34 + 35 + | Field | Value | 36 + |-------|-------| 37 + | **Criterion** | Running the relay a second time does not re-apply V001 -- row count in `schema_migrations` remains 1 | 38 + | **Test type** | Unit | 39 + | **Test file** | `crates/relay/src/db/mod.rs` (`#[cfg(test)] mod tests`) | 40 + | **Test name** | `migrations_are_idempotent` | 41 + | **Asserts** | Calls `run_migrations` twice on the same in-memory pool. Queries `SELECT COUNT(*) FROM schema_migrations` and asserts the count is exactly 1. | 42 + | **Implementation phase** | phase_02.md Task 3 | 43 + 44 + --- 45 + 46 + ### MM-72.AC2.2 -- schema_migrations records version and timestamp 47 + 48 + | Field | Value | 49 + |-------|-------| 50 + | **Criterion** | `schema_migrations` records `version = 1` with a non-null `applied_at` timestamp after first run | 51 + | **Test type** | Unit | 52 + | **Test file** | `crates/relay/src/db/mod.rs` (`#[cfg(test)] mod tests`) | 53 + | **Test name** | `schema_migrations_records_version_and_timestamp` | 54 + | **Asserts** | After `run_migrations`, queries `SELECT version, applied_at FROM schema_migrations WHERE version = 1`. Asserts `version == 1` and `applied_at` is a non-empty string. | 55 + | **Implementation phase** | phase_02.md Task 3 | 56 + 57 + --- 58 + 59 + ### MM-72.AC3.1 -- Handler tests compile with db field in AppState 60 + 61 + | Field | Value | 62 + |-------|-------| 63 + | **Criterion** | Handler tests that extract `State<AppState>` compile and pass with the `db: SqlitePool` field present | 64 + | **Test type** | Integration (compile-time + runtime) | 65 + | **Test file** | `crates/relay/src/app.rs` (`#[cfg(test)] mod tests`) | 66 + | **Test names** | All 5 existing XRPC handler tests: `xrpc_get_unknown_method_returns_501`, `xrpc_post_unknown_method_returns_501`, `xrpc_delete_returns_405`, `xrpc_response_has_json_content_type`, `xrpc_response_body_is_method_not_implemented` | 67 + | **Asserts** | These tests construct `AppState` via `test_state().await`, which includes the `db: SqlitePool` field. If the field were missing or the type wrong, the tests would fail to compile. All tests pass at runtime, confirming the router accepts the updated state. | 68 + | **Implementation phase** | phase_03.md Task 1 (Steps 3-4: `test_state()` becomes async, all call sites updated to `.await`) | 69 + 70 + --- 71 + 72 + ### MM-72.AC3.2 -- SELECT 1 succeeds on AppState pool 73 + 74 + | Field | Value | 75 + |-------|-------| 76 + | **Criterion** | `sqlx::query("SELECT 1").execute(&state.db)` succeeds in tests using an in-memory pool | 77 + | **Test type** | Integration | 78 + | **Test file** | `crates/relay/src/app.rs` (`#[cfg(test)] mod tests`) | 79 + | **Test name** | `appstate_db_pool_is_queryable` | 80 + | **Asserts** | Constructs `AppState` via `test_state().await`, then executes `sqlx::query("SELECT 1").execute(&state.db)`. Asserts the query completes without error. | 81 + | **Implementation phase** | phase_03.md Task 1 (Step 5) | 82 + 83 + --- 84 + 85 + ### MM-72.AC4.1 -- WAL mode enabled 86 + 87 + | Field | Value | 88 + |-------|-------| 89 + | **Criterion** | `PRAGMA journal_mode` queried on the pool returns `wal` | 90 + | **Test type** | Unit | 91 + | **Test file** | `crates/relay/src/db/mod.rs` (`#[cfg(test)] mod tests`) | 92 + | **Test name** | `wal_mode_enabled_on_file_pool` | 93 + | **Asserts** | Creates a file-backed pool via `open_pool` using a `tempfile::tempdir()` path. Queries `PRAGMA journal_mode` and asserts the result is the string `"wal"`. | 94 + | **Design rationale** | Uses a temp file instead of `":memory:"` because in-memory SQLite always reports `journal_mode = "memory"`, making WAL verification impossible. This is documented in the test's own doc comment and is consistent with AC5.1's scope (which applies to "migration runner unit tests", not pool factory tests). The temp directory is cleaned up automatically by `tempfile` on drop. | 95 + | **Implementation phase** | phase_02.md Task 3 | 96 + 97 + --- 98 + 99 + ### MM-72.AC5.1 -- Migration runner tests use in-memory SQLite 100 + 101 + | Field | Value | 102 + |-------|-------| 103 + | **Criterion** | Migration runner unit tests use `":memory:"` -- no `relay.db` or temp files created on disk during `cargo test` | 104 + | **Test type** | Unit (structural / convention) | 105 + | **Test file** | `crates/relay/src/db/mod.rs` (`#[cfg(test)] mod tests`) | 106 + | **Test names** | `select_one_succeeds`, `migrations_apply_on_first_run`, `migrations_are_idempotent`, `schema_migrations_records_version_and_timestamp`, `server_metadata_table_exists_and_accepts_inserts` | 107 + | **Asserts** | All five migration-related tests call `in_memory_pool()` which opens `"sqlite::memory:"`. No test in this group creates files on disk. The sole exception -- `wal_mode_enabled_on_file_pool` -- tests the pool factory, not the migration runner, and uses `tempfile` (cleaned up on drop). | 108 + | **Verification method** | Code review of test source. Additionally, running `cargo test --workspace` in a clean checkout and confirming no `relay.db` file appears anywhere in the workspace tree. | 109 + | **Implementation phase** | phase_02.md Task 3 | 110 + 111 + --- 112 + 113 + ### MM-72.AC5.2 -- cargo test passes in clean environment 114 + 115 + | Field | Value | 116 + |-------|-------| 117 + | **Criterion** | `cargo test --workspace` passes in a clean environment with no pre-existing `relay.db` | 118 + | **Test type** | Integration (CI gate) | 119 + | **Test file** | Entire workspace | 120 + | **Test name** | N/A -- full workspace test suite | 121 + | **Asserts** | `cargo test --workspace` exits 0. No `relay.db` file exists before or after the run. | 122 + | **Verification method** | CI pipeline runs `cargo test --workspace` on every push. Can be manually verified by cloning fresh and running the command. | 123 + | **Implementation phase** | phase_02.md Task 3 (Step 4), phase_03.md Task 1 (Step 7) | 124 + 125 + --- 126 + 127 + ### MM-72.AC6.1 -- cargo clippy passes 128 + 129 + | Field | Value | 130 + |-------|-------| 131 + | **Criterion** | `cargo clippy --workspace -- -D warnings` passes with no warnings | 132 + | **Test type** | Lint (CI gate) | 133 + | **Test file** | Entire workspace | 134 + | **Asserts** | `cargo clippy --workspace -- -D warnings` exits 0 with no diagnostic output. | 135 + | **Verification method** | CI pipeline. Manually: run the command and verify exit code. | 136 + | **Implementation phase** | phase_02.md Task 4 (Step 3), phase_03.md Task 2 (Step 6) | 137 + 138 + --- 139 + 140 + ### MM-72.AC6.2 -- cargo fmt passes 141 + 142 + | Field | Value | 143 + |-------|-------| 144 + | **Criterion** | `cargo fmt --all --check` passes | 145 + | **Test type** | Format check (CI gate) | 146 + | **Test file** | Entire workspace | 147 + | **Asserts** | `cargo fmt --all --check` exits 0 with no diff output. | 148 + | **Verification method** | CI pipeline. Manually: run the command and verify exit code. | 149 + | **Implementation phase** | phase_02.md Task 4 (Step 4), phase_03.md Task 2 (Step 6) | 150 + 151 + --- 152 + 153 + ## Supplementary Automated Tests (no direct AC mapping) 154 + 155 + These tests are defined in the implementation plan but do not map 1:1 to a named acceptance criterion. 156 + They provide coverage for implicit requirements (pool connectivity, schema correctness) that support 157 + multiple ACs. 158 + 159 + | Test name | Test file | Asserts | Supports ACs | 160 + |-----------|-----------|---------|--------------| 161 + | `select_one_succeeds` | `crates/relay/src/db/mod.rs` | `SELECT 1` returns 1 on an in-memory pool -- confirms pool is functional | AC3.2, AC5.1 | 162 + | `migrations_apply_on_first_run` | `crates/relay/src/db/mod.rs` | After first `run_migrations`, `schema_migrations` has exactly 1 row | AC2.1, AC2.2 | 163 + | `server_metadata_table_exists_and_accepts_inserts` | `crates/relay/src/db/mod.rs` | After migrations, `INSERT INTO server_metadata` succeeds and value is retrievable | AC1.3 (in-memory equivalent) | 164 + 165 + --- 166 + 167 + ## Human Verification 168 + 169 + ### MM-72.AC1.1 -- relay.db created on first start 170 + 171 + | Field | Value | 172 + |-------|-------| 173 + | **Criterion** | `cargo run --bin relay` (with a valid `relay.toml`) creates `relay.db` in the configured `data_dir` | 174 + | **Why not automated** | Requires running the actual binary with a real config file and filesystem. `cargo test` cannot exercise the `main.rs` startup path that reads `relay.toml`, constructs the database URL, and calls `open_pool` with a real file path. The binary also binds a TCP listener, which makes it unsuitable for headless test automation without process management. | 175 + | **Implementation reference** | phase_03.md Task 3 (Steps 1-3) | 176 + | **Manual steps** | | 177 + 178 + | Step | Action | Expected | 179 + |------|--------|----------| 180 + | 1 | Create a `relay.toml` with `data_dir = "/tmp/relay-test"` and `public_url = "https://test.example.com"`. Run `mkdir -p /tmp/relay-test`. | Directory exists. | 181 + | 2 | Run `cargo run --bin relay -- --config relay.toml` | Relay starts, logs "relay starting" and "listening". | 182 + | 3 | Press Ctrl+C to stop the relay. | Relay shuts down cleanly. | 183 + | 4 | Run `ls -la /tmp/relay-test/relay.db` | File exists. | 184 + | 5 | Clean up: `rm -rf /tmp/relay-test relay.toml` | -- | 185 + 186 + --- 187 + 188 + ### MM-72.AC1.2 -- schema_migrations table exists in produced database 189 + 190 + | Field | Value | 191 + |-------|-------| 192 + | **Criterion** | `schema_migrations` table exists in the produced database | 193 + | **Why not automated** | Same as AC1.1 -- requires the real binary to have produced the database file. The in-memory test (`migrations_apply_on_first_run`) proves the runner creates the table, but AC1.2 specifically requires verifying the on-disk artifact produced by the binary. | 194 + | **Implementation reference** | phase_03.md Task 3 (Step 4) | 195 + | **Manual steps** | | 196 + 197 + | Step | Action | Expected | 198 + |------|--------|----------| 199 + | 1 | After completing AC1.1 steps 1-3 (database file exists at `/tmp/relay-test/relay.db`): | -- | 200 + | 2 | Run `sqlite3 /tmp/relay-test/relay.db ".tables"` | Output includes `schema_migrations`. | 201 + 202 + --- 203 + 204 + ### MM-72.AC1.3 -- server_metadata table exists in produced database 205 + 206 + | Field | Value | 207 + |-------|-------| 208 + | **Criterion** | `server_metadata` table exists in the produced database | 209 + | **Why not automated** | Same rationale as AC1.2. The in-memory test (`server_metadata_table_exists_and_accepts_inserts`) proves the migration creates the table, but AC1.3 requires verifying the on-disk artifact. | 210 + | **Implementation reference** | phase_03.md Task 3 (Step 4) | 211 + | **Manual steps** | | 212 + 213 + | Step | Action | Expected | 214 + |------|--------|----------| 215 + | 1 | After completing AC1.1 steps 1-3 (database file exists at `/tmp/relay-test/relay.db`): | -- | 216 + | 2 | Run `sqlite3 /tmp/relay-test/relay.db ".tables"` | Output includes `server_metadata`. | 217 + 218 + --- 219 + 220 + ### MM-72.AC2.1 -- Idempotent across process restarts (runtime) 221 + 222 + | Field | Value | 223 + |-------|-------| 224 + | **Criterion** | Running the relay a second time does not re-apply V001 -- row count in `schema_migrations` remains 1 | 225 + | **Why not automated** | The unit test `migrations_are_idempotent` calls `run_migrations` twice on the same in-memory pool within a single process, which verifies the runner logic. However, AC2.1 as stated in phase_03.md Task 3 also requires verifying idempotency across separate binary invocations (process restart), which exercises the full startup path including URL formatting, file-backed pool reopening, and `schema_migrations` persistence on disk. | 226 + | **Note** | This criterion has **dual coverage**: the automated unit test verifies the core logic, and the manual steps below verify the end-to-end runtime behavior. Both are required for full confidence. | 227 + | **Implementation reference** | phase_03.md Task 3 (Steps 6-7) | 228 + | **Manual steps** | | 229 + 230 + | Step | Action | Expected | 231 + |------|--------|----------| 232 + | 1 | After completing AC1.1 steps 1-4 (relay has been started and stopped once, database file exists): | -- | 233 + | 2 | Run `sqlite3 /tmp/relay-test/relay.db "SELECT COUNT(*) FROM schema_migrations;"` | Output: `1` | 234 + | 3 | Run `cargo run --bin relay -- --config relay.toml` a second time. Press Ctrl+C after "listening" appears. | Relay starts and stops cleanly. | 235 + | 4 | Run `sqlite3 /tmp/relay-test/relay.db "SELECT COUNT(*) FROM schema_migrations;"` | Output: still `1` (not `2`). | 236 + 237 + --- 238 + 239 + ### MM-72.AC4.1 -- WAL mode on production database (runtime) 240 + 241 + | Field | Value | 242 + |-------|-------| 243 + | **Criterion** | `PRAGMA journal_mode` queried on the pool returns `wal` | 244 + | **Why partially automated** | The automated test `wal_mode_enabled_on_file_pool` verifies that `open_pool` sets WAL mode on a temp file. This is sufficient to prove the pool factory works correctly. However, for completeness, operators may want to verify that the production database file produced by the binary is also in WAL mode. This is optional -- the automated test is the primary verification. | 245 + | **Note** | This criterion has **primary automated coverage** via `wal_mode_enabled_on_file_pool`. The manual step below is supplementary. | 246 + | **Implementation reference** | phase_02.md Task 3 (`wal_mode_enabled_on_file_pool` test) | 247 + | **Manual steps (optional)** | | 248 + 249 + | Step | Action | Expected | 250 + |------|--------|----------| 251 + | 1 | After completing AC1.1 steps 1-3 (database file exists at `/tmp/relay-test/relay.db`): | -- | 252 + | 2 | Run `sqlite3 /tmp/relay-test/relay.db "PRAGMA journal_mode;"` | Output: `wal` | 253 + | 3 | Run `ls /tmp/relay-test/relay.db-wal` | WAL file exists (created by SQLite when WAL mode is active). | 254 + 255 + --- 256 + 257 + ## Traceability Matrix 258 + 259 + | Acceptance Criterion | Automated Test | Human Verification | Notes | 260 + |----------------------|----------------|-------------------|-------| 261 + | MM-72.AC1.1 (relay.db created) | -- | AC1.1 manual steps | Runtime-only; requires binary execution | 262 + | MM-72.AC1.2 (schema_migrations exists) | `server_metadata_table_exists_and_accepts_inserts` (indirect) | AC1.2 manual steps | In-memory test proves runner logic; manual verifies on-disk artifact | 263 + | MM-72.AC1.3 (server_metadata exists) | `server_metadata_table_exists_and_accepts_inserts` | AC1.3 manual steps | In-memory test proves runner logic; manual verifies on-disk artifact | 264 + | MM-72.AC2.1 (idempotent) | `migrations_are_idempotent` | AC2.1 manual steps | Unit test covers logic; manual covers cross-process restart | 265 + | MM-72.AC2.2 (version + timestamp) | `schema_migrations_records_version_and_timestamp` | -- | Fully automated | 266 + | MM-72.AC3.1 (AppState compiles) | 5 existing XRPC handler tests | -- | Compilation is the test; runtime confirms router accepts state | 267 + | MM-72.AC3.2 (SELECT 1 on state.db) | `appstate_db_pool_is_queryable` | -- | Fully automated | 268 + | MM-72.AC4.1 (WAL mode) | `wal_mode_enabled_on_file_pool` | AC4.1 manual steps (optional) | Automated test is primary; manual is supplementary | 269 + | MM-72.AC5.1 (in-memory tests) | Code review + 5 migration tests | -- | Structural: verify `in_memory_pool()` uses `"sqlite::memory:"` | 270 + | MM-72.AC5.2 (clean cargo test) | `cargo test --workspace` | -- | CI gate; no relay.db created | 271 + | MM-72.AC6.1 (clippy) | `cargo clippy --workspace -- -D warnings` | -- | CI gate | 272 + | MM-72.AC6.2 (fmt) | `cargo fmt --all --check` | -- | CI gate | 273 + 274 + --- 275 + 276 + ## Prerequisites 277 + 278 + - Development shell activated: `nix develop --impure --accept-flake-config` 279 + - Branch rebased onto `main` (requires `AppState` from MM-71) 280 + - All three implementation phases completed (phase_01, phase_02, phase_03) 281 + - `cargo test --workspace` exits 0 282 + - `cargo clippy --workspace -- -D warnings` exits 0 283 + - `cargo fmt --all --check` exits 0 284 + - `sqlite3` CLI available in shell (provided by devenv) 285 + 286 + ## Execution Order 287 + 288 + 1. Run `cargo test --workspace` -- covers all automated criteria (AC2.1, AC2.2, AC3.1, AC3.2, AC4.1, AC5.1, AC5.2) 289 + 2. Run `cargo clippy --workspace -- -D warnings` -- covers AC6.1 290 + 3. Run `cargo fmt --all --check` -- covers AC6.2 291 + 4. Execute human verification steps for AC1.1 through AC1.3 (single relay start) 292 + 5. Execute human verification steps for AC2.1 (second relay start) 293 + 6. Optionally execute human verification for AC4.1 (WAL mode on production file) 294 + 7. Clean up: `rm -rf /tmp/relay-test relay.toml`