···11+# MM-72 SQLite Migration Infrastructure — Implementation Plan
22+33+**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.
44+55+**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.
66+77+**Tech Stack:** Rust stable, sqlx 0.8 `SqlitePool`, axum 0.7 `State` extractor, anyhow `Context` trait
88+99+**Scope:** Phase 3 of 3 from the original design plan.
1010+1111+**Codebase verified:** 2026-03-10
1212+1313+---
1414+1515+## Acceptance Criteria Coverage
1616+1717+### MM-72.AC1: relay.db created on first start
1818+- **MM-72.AC1.1 Success:** `cargo run --bin relay` (with a valid `relay.toml`) creates `relay.db` in the configured `data_dir`
1919+- **MM-72.AC1.2 Success:** `schema_migrations` table exists in the produced database
2020+- **MM-72.AC1.3 Success:** `server_metadata` table exists in the produced database
2121+2222+### MM-72.AC2: Migrations are idempotent
2323+- **MM-72.AC2.1 Success:** Running the relay a second time does not re-apply V001 — row count in `schema_migrations` remains 1
2424+2525+### MM-72.AC3: Pool available in AppState
2626+- **MM-72.AC3.1 Success:** Handler tests that extract `State<AppState>` compile and pass with the `db: SqlitePool` field present
2727+- **MM-72.AC3.2 Success:** `sqlx::query("SELECT 1").execute(&state.db)` succeeds in tests using an in-memory pool
2828+2929+### MM-72.AC5: Unit tests use in-memory SQLite
3030+- **MM-72.AC5.2 Success:** `cargo test --workspace` passes in a clean environment with no pre-existing `relay.db`
3131+3232+### MM-72.AC6: Toolchain checks pass
3333+- **MM-72.AC6.1 Success:** `cargo clippy --workspace -- -D warnings` passes with no warnings
3434+- **MM-72.AC6.2 Success:** `cargo fmt --all --check` passes
3535+3636+---
3737+3838+<!-- START_TASK_1 -->
3939+### Task 1: Add db: SqlitePool to AppState and update test_state()
4040+4141+**Verifies:** MM-72.AC3.1, MM-72.AC3.2
4242+4343+**Files:**
4444+- Modify: `crates/relay/src/app.rs`
4545+4646+**Step 1: Update AppState struct**
4747+4848+Open `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/app.rs`.
4949+5050+The current `AppState` (lines 7–13):
5151+```rust
5252+/// Shared application state cloned into every request handler via Axum's `State` extractor.
5353+#[derive(Clone)]
5454+pub struct AppState {
5555+ // Read by handlers once XRPC endpoints are implemented; suppressed until then.
5656+ #[allow(dead_code)]
5757+ pub config: Arc<Config>,
5858+}
5959+```
6060+6161+Replace with:
6262+```rust
6363+/// Shared application state cloned into every request handler via Axum's `State` extractor.
6464+#[derive(Clone)]
6565+pub struct AppState {
6666+ // Read by handlers once XRPC endpoints are implemented; suppressed until then.
6767+ #[allow(dead_code)]
6868+ pub config: Arc<Config>,
6969+ pub db: sqlx::SqlitePool,
7070+}
7171+```
7272+7373+`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).
7474+7575+**Step 2: Update the imports block**
7676+7777+The current imports at the top of `app.rs`:
7878+```rust
7979+use std::sync::Arc;
8080+8181+use axum::{extract::Path, routing::get, Router};
8282+use common::{ApiError, Config, ErrorCode};
8383+use tower_http::{cors::CorsLayer, trace::TraceLayer};
8484+```
8585+8686+No import changes are needed — `sqlx::SqlitePool` is referenced with its full path in the struct to avoid ambiguity with future imports.
8787+8888+**Step 3: Update test_state() to be async and open an in-memory pool**
8989+9090+The `#[cfg(test)]` block currently starts at line 38. The `test_state()` function (lines 49–62):
9191+9292+```rust
9393+fn test_state() -> AppState {
9494+ AppState {
9595+ config: Arc::new(Config {
9696+ bind_address: "127.0.0.1".to_string(),
9797+ port: 8080,
9898+ data_dir: PathBuf::from("/tmp"),
9999+ database_url: "/tmp/test.db".to_string(),
100100+ public_url: "https://test.example.com".to_string(),
101101+ blobs: BlobsConfig::default(),
102102+ oauth: OAuthConfig::default(),
103103+ iroh: IrohConfig::default(),
104104+ }),
105105+ }
106106+}
107107+```
108108+109109+Replace it with the async version:
110110+```rust
111111+async fn test_state() -> AppState {
112112+ let pool = crate::db::open_pool("sqlite::memory:")
113113+ .await
114114+ .expect("failed to open test pool");
115115+ crate::db::run_migrations(&pool)
116116+ .await
117117+ .expect("failed to run test migrations");
118118+ AppState {
119119+ config: Arc::new(Config {
120120+ bind_address: "127.0.0.1".to_string(),
121121+ port: 8080,
122122+ data_dir: PathBuf::from("/tmp"),
123123+ database_url: "sqlite::memory:".to_string(),
124124+ public_url: "https://test.example.com".to_string(),
125125+ blobs: BlobsConfig::default(),
126126+ oauth: OAuthConfig::default(),
127127+ iroh: IrohConfig::default(),
128128+ }),
129129+ db: pool,
130130+ }
131131+}
132132+```
133133+134134+**Step 4: Update all test_state() call sites**
135135+136136+Every existing test in `app.rs` calls `test_state()`. Since `test_state` is now async, each call must add `.await`:
137137+138138+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`:
139139+140140+```rust
141141+// Before:
142142+let response = app(test_state())
143143+144144+// After:
145145+let response = app(test_state().await)
146146+```
147147+148148+All 5 tests already use `#[tokio::test]` and are `async fn`, so `.await` is valid in each.
149149+150150+**Step 5: Add a test that exercises state.db (AC3.2)**
151151+152152+Add this test after the existing 5 tests in the `#[cfg(test)]` block:
153153+154154+```rust
155155+#[tokio::test]
156156+async fn appstate_db_pool_is_queryable() {
157157+ let state = test_state().await;
158158+ sqlx::query("SELECT 1")
159159+ .execute(&state.db)
160160+ .await
161161+ .expect("db pool in AppState must be queryable");
162162+}
163163+```
164164+165165+**Step 6: Verify compilation**
166166+167167+Run:
168168+```bash
169169+cargo build -p relay
170170+```
171171+Expected: compiles without errors.
172172+173173+**Step 7: Run relay tests**
174174+175175+Run:
176176+```bash
177177+cargo test -p relay
178178+```
179179+Expected: all 6 app tests pass (5 existing XRPC tests + 1 new db pool test), plus all 6 db module tests.
180180+181181+**Step 8: Commit**
182182+183183+```bash
184184+git add crates/relay/src/app.rs
185185+git commit -m "feat(relay): add db: SqlitePool to AppState and update test fixture"
186186+```
187187+<!-- END_TASK_1 -->
188188+189189+<!-- START_TASK_2 -->
190190+### Task 2: Wire open_pool + run_migrations into main.rs
191191+192192+**Verifies:** MM-72.AC1.1, MM-72.AC1.2, MM-72.AC1.3, MM-72.AC2.1
193193+194194+**Files:**
195195+- Modify: `crates/relay/src/main.rs`
196196+197197+**Step 1: Review current main.rs structure**
198198+199199+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):
200200+201201+```rust
202202+async fn run() -> anyhow::Result<()> {
203203+ tracing_subscriber::fmt()
204204+ .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
205205+ .try_init()
206206+ .map_err(|e| anyhow::anyhow!("failed to initialize tracing subscriber: {e}"))?;
207207+208208+ let cli = Cli::parse();
209209+ let config_path = cli.config.unwrap_or_else(|| PathBuf::from("relay.toml"));
210210+211211+ let config = common::load_config(&config_path)
212212+ .with_context(|| format!("failed to load config from {}", config_path.display()))?;
213213+214214+ tracing::info!(
215215+ bind_address = %config.bind_address,
216216+ port = config.port,
217217+ public_url = %config.public_url,
218218+ "relay starting"
219219+ );
220220+221221+ let addr = format!("{}:{}", config.bind_address, config.port);
222222+ let state = app::AppState {
223223+ config: Arc::new(config),
224224+ };
225225+ ...
226226+```
227227+228228+**Step 2: Insert pool creation and migration wiring**
229229+230230+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`.
231231+232232+The updated `run()` function body, starting from `let addr`:
233233+234234+```rust
235235+ let addr = format!("{}:{}", config.bind_address, config.port);
236236+237237+ // **Intentional deviation from design:** The design doc's startup sequence shows
238238+ // `open_pool(&config.database_url)` directly. However, `config.database_url` defaults
239239+ // to a plain filesystem path (e.g. `/var/pds/relay.db`) when not explicitly set, which
240240+ // is not a valid sqlx URL. We format it here rather than changing Config or open_pool,
241241+ // keeping both functions general-purpose.
242242+ //
243243+ // Plain absolute paths like "/var/pds/relay.db" become "sqlite:///var/pds/relay.db".
244244+ // Already-formatted "sqlite://..." URLs pass through unchanged.
245245+ let db_url = if config.database_url.starts_with("sqlite:") {
246246+ config.database_url.clone()
247247+ } else if config.database_url.starts_with('/') {
248248+ format!("sqlite://{}", config.database_url)
249249+ } else {
250250+ format!("sqlite:{}", config.database_url)
251251+ };
252252+253253+ let pool = db::open_pool(&db_url)
254254+ .await
255255+ .with_context(|| format!("failed to open database at {}", config.database_url))?;
256256+257257+ db::run_migrations(&pool)
258258+ .await
259259+ .with_context(|| "failed to run database migrations")?;
260260+261261+ let state = app::AppState {
262262+ config: Arc::new(config),
263263+ db: pool,
264264+ };
265265+```
266266+267267+**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.
268268+269269+**Step 3: Verify the module declaration**
270270+271271+Confirm that `mod db;` is present in main.rs (added in Phase 2, Task 4). The top of main.rs should read:
272272+273273+```rust
274274+use anyhow::Context;
275275+use clap::Parser;
276276+use std::{path::PathBuf, sync::Arc};
277277+278278+mod app;
279279+mod db;
280280+```
281281+282282+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()`.
283283+284284+**Step 4: Verify build**
285285+286286+Run:
287287+```bash
288288+cargo build --workspace
289289+```
290290+Expected: compiles without errors.
291291+292292+**Step 5: Run all tests**
293293+294294+Run:
295295+```bash
296296+cargo test --workspace
297297+```
298298+Expected: all tests pass. No `relay.db` file created in the project directory (tests use in-memory pools).
299299+300300+**Step 6: Run clippy and fmt**
301301+302302+Run:
303303+```bash
304304+cargo clippy --workspace -- -D warnings
305305+cargo fmt --all --check
306306+```
307307+Expected: zero warnings, zero errors, no formatting differences.
308308+309309+**Step 7: Commit**
310310+311311+```bash
312312+git add crates/relay/src/main.rs
313313+git commit -m "feat(relay): wire db pool and migrations into startup sequence"
314314+```
315315+<!-- END_TASK_2 -->
316316+317317+<!-- START_TASK_3 -->
318318+### Task 3: Manual verification — relay.db created on first start
319319+320320+**Verifies:** MM-72.AC1.1, MM-72.AC1.2, MM-72.AC1.3, MM-72.AC2.1 (runtime)
321321+322322+**Note:** These acceptance criteria require running the actual binary and cannot be automated with `cargo test` alone. This task verifies them manually.
323323+324324+**Step 1: Ensure a valid relay.toml exists**
325325+326326+The project includes `relay.dev.toml`. Copy it or create `relay.toml` in the workspace root with at minimum:
327327+328328+```toml
329329+data_dir = "/tmp/relay-test"
330330+public_url = "https://test.example.com"
331331+```
332332+333333+Create the data directory:
334334+```bash
335335+mkdir -p /tmp/relay-test
336336+```
337337+338338+**Step 2: Run the relay binary**
339339+340340+Run:
341341+```bash
342342+cargo run --bin relay -- --config relay.toml
343343+```
344344+345345+Expected startup output (tracing logs):
346346+```
347347+relay starting bind_address=0.0.0.0 port=8080 public_url=https://test.example.com
348348+listening address=0.0.0.0:8080
349349+```
350350+351351+Press Ctrl+C to stop after the server binds.
352352+353353+**Step 3: Verify relay.db was created**
354354+355355+Run:
356356+```bash
357357+ls -la /tmp/relay-test/relay.db
358358+```
359359+Expected: file exists.
360360+361361+**Step 4: Verify tables exist** (AC1.2, AC1.3)
362362+363363+Run:
364364+```bash
365365+sqlite3 /tmp/relay-test/relay.db ".tables"
366366+```
367367+Expected output includes: `schema_migrations server_metadata`
368368+369369+**Step 5: Verify schema_migrations has one row** (AC2.2)
370370+371371+Run:
372372+```bash
373373+sqlite3 /tmp/relay-test/relay.db "SELECT version, applied_at FROM schema_migrations;"
374374+```
375375+Expected output:
376376+```
377377+1|<timestamp>
378378+```
379379+380380+**Step 6: Run the binary a second time** (AC2.1)
381381+382382+Run:
383383+```bash
384384+cargo run --bin relay -- --config relay.toml
385385+```
386386+Stop with Ctrl+C after binding.
387387+388388+**Step 7: Verify migration was NOT re-applied**
389389+390390+Run:
391391+```bash
392392+sqlite3 /tmp/relay-test/relay.db "SELECT COUNT(*) FROM schema_migrations;"
393393+```
394394+Expected: `1` (still one row, not two).
395395+396396+**Step 8: Clean up**
397397+398398+```bash
399399+rm -rf /tmp/relay-test relay.toml
400400+```
401401+<!-- END_TASK_3 -->
···11+# Test Requirements: MM-72 SQLite Migration Infrastructure (Wave 1 Schema)
22+33+Generated from test-analyst review of design plan `docs/design-plans/2026-03-10-MM-72.md`
44+and implementation plans `docs/implementation-plans/2026-03-10-MM-72/`.
55+66+**Automated coverage:** 11/16 acceptance criteria verified by unit/integration tests in `cargo test`.
77+88+**Human verification:** 5 criteria require running the relay binary against a real filesystem and
99+inspecting the produced database with `sqlite3`. These criteria are runtime-only by nature (file
1010+creation, WAL persistence on disk, idempotent startup across process restarts).
1111+1212+---
1313+1414+## Conventions
1515+1616+- **AC identifiers** use the slugged form from the design plan: `MM-72.AC1.1`, `MM-72.AC2.1`, etc.
1717+- **Test file paths** are relative to the workspace root.
1818+- **In-memory vs. file-backed:** The design explicitly requires unit tests to use `":memory:"` (AC5.1).
1919+ However, WAL mode cannot be verified on an in-memory database (SQLite reports `journal_mode = "memory"`
2020+ for in-memory connections). The implementation plan resolves this by using `tempfile::tempdir()` for the
2121+ WAL test only (phase_02.md Task 3, `wal_mode_enabled_on_file_pool`). This is consistent with AC5.1
2222+ because the acceptance criterion scopes the in-memory requirement to "migration runner unit tests" --
2323+ the WAL test exercises `open_pool`, not `run_migrations`.
2424+- **AC1.x runtime verification:** The design plan's AC1 criteria ("relay.db created on first start")
2525+ inherently require running the binary. The implementation plan documents this explicitly in
2626+ phase_03.md Task 3 ("These acceptance criteria require running the actual binary and cannot be
2727+ automated with `cargo test` alone"). They appear below under Human Verification.
2828+2929+---
3030+3131+## Automated Tests
3232+3333+### MM-72.AC2.1 -- Migrations are idempotent (row count)
3434+3535+| Field | Value |
3636+|-------|-------|
3737+| **Criterion** | Running the relay a second time does not re-apply V001 -- row count in `schema_migrations` remains 1 |
3838+| **Test type** | Unit |
3939+| **Test file** | `crates/relay/src/db/mod.rs` (`#[cfg(test)] mod tests`) |
4040+| **Test name** | `migrations_are_idempotent` |
4141+| **Asserts** | Calls `run_migrations` twice on the same in-memory pool. Queries `SELECT COUNT(*) FROM schema_migrations` and asserts the count is exactly 1. |
4242+| **Implementation phase** | phase_02.md Task 3 |
4343+4444+---
4545+4646+### MM-72.AC2.2 -- schema_migrations records version and timestamp
4747+4848+| Field | Value |
4949+|-------|-------|
5050+| **Criterion** | `schema_migrations` records `version = 1` with a non-null `applied_at` timestamp after first run |
5151+| **Test type** | Unit |
5252+| **Test file** | `crates/relay/src/db/mod.rs` (`#[cfg(test)] mod tests`) |
5353+| **Test name** | `schema_migrations_records_version_and_timestamp` |
5454+| **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. |
5555+| **Implementation phase** | phase_02.md Task 3 |
5656+5757+---
5858+5959+### MM-72.AC3.1 -- Handler tests compile with db field in AppState
6060+6161+| Field | Value |
6262+|-------|-------|
6363+| **Criterion** | Handler tests that extract `State<AppState>` compile and pass with the `db: SqlitePool` field present |
6464+| **Test type** | Integration (compile-time + runtime) |
6565+| **Test file** | `crates/relay/src/app.rs` (`#[cfg(test)] mod tests`) |
6666+| **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` |
6767+| **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. |
6868+| **Implementation phase** | phase_03.md Task 1 (Steps 3-4: `test_state()` becomes async, all call sites updated to `.await`) |
6969+7070+---
7171+7272+### MM-72.AC3.2 -- SELECT 1 succeeds on AppState pool
7373+7474+| Field | Value |
7575+|-------|-------|
7676+| **Criterion** | `sqlx::query("SELECT 1").execute(&state.db)` succeeds in tests using an in-memory pool |
7777+| **Test type** | Integration |
7878+| **Test file** | `crates/relay/src/app.rs` (`#[cfg(test)] mod tests`) |
7979+| **Test name** | `appstate_db_pool_is_queryable` |
8080+| **Asserts** | Constructs `AppState` via `test_state().await`, then executes `sqlx::query("SELECT 1").execute(&state.db)`. Asserts the query completes without error. |
8181+| **Implementation phase** | phase_03.md Task 1 (Step 5) |
8282+8383+---
8484+8585+### MM-72.AC4.1 -- WAL mode enabled
8686+8787+| Field | Value |
8888+|-------|-------|
8989+| **Criterion** | `PRAGMA journal_mode` queried on the pool returns `wal` |
9090+| **Test type** | Unit |
9191+| **Test file** | `crates/relay/src/db/mod.rs` (`#[cfg(test)] mod tests`) |
9292+| **Test name** | `wal_mode_enabled_on_file_pool` |
9393+| **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"`. |
9494+| **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. |
9595+| **Implementation phase** | phase_02.md Task 3 |
9696+9797+---
9898+9999+### MM-72.AC5.1 -- Migration runner tests use in-memory SQLite
100100+101101+| Field | Value |
102102+|-------|-------|
103103+| **Criterion** | Migration runner unit tests use `":memory:"` -- no `relay.db` or temp files created on disk during `cargo test` |
104104+| **Test type** | Unit (structural / convention) |
105105+| **Test file** | `crates/relay/src/db/mod.rs` (`#[cfg(test)] mod tests`) |
106106+| **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` |
107107+| **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). |
108108+| **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. |
109109+| **Implementation phase** | phase_02.md Task 3 |
110110+111111+---
112112+113113+### MM-72.AC5.2 -- cargo test passes in clean environment
114114+115115+| Field | Value |
116116+|-------|-------|
117117+| **Criterion** | `cargo test --workspace` passes in a clean environment with no pre-existing `relay.db` |
118118+| **Test type** | Integration (CI gate) |
119119+| **Test file** | Entire workspace |
120120+| **Test name** | N/A -- full workspace test suite |
121121+| **Asserts** | `cargo test --workspace` exits 0. No `relay.db` file exists before or after the run. |
122122+| **Verification method** | CI pipeline runs `cargo test --workspace` on every push. Can be manually verified by cloning fresh and running the command. |
123123+| **Implementation phase** | phase_02.md Task 3 (Step 4), phase_03.md Task 1 (Step 7) |
124124+125125+---
126126+127127+### MM-72.AC6.1 -- cargo clippy passes
128128+129129+| Field | Value |
130130+|-------|-------|
131131+| **Criterion** | `cargo clippy --workspace -- -D warnings` passes with no warnings |
132132+| **Test type** | Lint (CI gate) |
133133+| **Test file** | Entire workspace |
134134+| **Asserts** | `cargo clippy --workspace -- -D warnings` exits 0 with no diagnostic output. |
135135+| **Verification method** | CI pipeline. Manually: run the command and verify exit code. |
136136+| **Implementation phase** | phase_02.md Task 4 (Step 3), phase_03.md Task 2 (Step 6) |
137137+138138+---
139139+140140+### MM-72.AC6.2 -- cargo fmt passes
141141+142142+| Field | Value |
143143+|-------|-------|
144144+| **Criterion** | `cargo fmt --all --check` passes |
145145+| **Test type** | Format check (CI gate) |
146146+| **Test file** | Entire workspace |
147147+| **Asserts** | `cargo fmt --all --check` exits 0 with no diff output. |
148148+| **Verification method** | CI pipeline. Manually: run the command and verify exit code. |
149149+| **Implementation phase** | phase_02.md Task 4 (Step 4), phase_03.md Task 2 (Step 6) |
150150+151151+---
152152+153153+## Supplementary Automated Tests (no direct AC mapping)
154154+155155+These tests are defined in the implementation plan but do not map 1:1 to a named acceptance criterion.
156156+They provide coverage for implicit requirements (pool connectivity, schema correctness) that support
157157+multiple ACs.
158158+159159+| Test name | Test file | Asserts | Supports ACs |
160160+|-----------|-----------|---------|--------------|
161161+| `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 |
162162+| `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 |
163163+| `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) |
164164+165165+---
166166+167167+## Human Verification
168168+169169+### MM-72.AC1.1 -- relay.db created on first start
170170+171171+| Field | Value |
172172+|-------|-------|
173173+| **Criterion** | `cargo run --bin relay` (with a valid `relay.toml`) creates `relay.db` in the configured `data_dir` |
174174+| **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. |
175175+| **Implementation reference** | phase_03.md Task 3 (Steps 1-3) |
176176+| **Manual steps** | |
177177+178178+| Step | Action | Expected |
179179+|------|--------|----------|
180180+| 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. |
181181+| 2 | Run `cargo run --bin relay -- --config relay.toml` | Relay starts, logs "relay starting" and "listening". |
182182+| 3 | Press Ctrl+C to stop the relay. | Relay shuts down cleanly. |
183183+| 4 | Run `ls -la /tmp/relay-test/relay.db` | File exists. |
184184+| 5 | Clean up: `rm -rf /tmp/relay-test relay.toml` | -- |
185185+186186+---
187187+188188+### MM-72.AC1.2 -- schema_migrations table exists in produced database
189189+190190+| Field | Value |
191191+|-------|-------|
192192+| **Criterion** | `schema_migrations` table exists in the produced database |
193193+| **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. |
194194+| **Implementation reference** | phase_03.md Task 3 (Step 4) |
195195+| **Manual steps** | |
196196+197197+| Step | Action | Expected |
198198+|------|--------|----------|
199199+| 1 | After completing AC1.1 steps 1-3 (database file exists at `/tmp/relay-test/relay.db`): | -- |
200200+| 2 | Run `sqlite3 /tmp/relay-test/relay.db ".tables"` | Output includes `schema_migrations`. |
201201+202202+---
203203+204204+### MM-72.AC1.3 -- server_metadata table exists in produced database
205205+206206+| Field | Value |
207207+|-------|-------|
208208+| **Criterion** | `server_metadata` table exists in the produced database |
209209+| **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. |
210210+| **Implementation reference** | phase_03.md Task 3 (Step 4) |
211211+| **Manual steps** | |
212212+213213+| Step | Action | Expected |
214214+|------|--------|----------|
215215+| 1 | After completing AC1.1 steps 1-3 (database file exists at `/tmp/relay-test/relay.db`): | -- |
216216+| 2 | Run `sqlite3 /tmp/relay-test/relay.db ".tables"` | Output includes `server_metadata`. |
217217+218218+---
219219+220220+### MM-72.AC2.1 -- Idempotent across process restarts (runtime)
221221+222222+| Field | Value |
223223+|-------|-------|
224224+| **Criterion** | Running the relay a second time does not re-apply V001 -- row count in `schema_migrations` remains 1 |
225225+| **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. |
226226+| **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. |
227227+| **Implementation reference** | phase_03.md Task 3 (Steps 6-7) |
228228+| **Manual steps** | |
229229+230230+| Step | Action | Expected |
231231+|------|--------|----------|
232232+| 1 | After completing AC1.1 steps 1-4 (relay has been started and stopped once, database file exists): | -- |
233233+| 2 | Run `sqlite3 /tmp/relay-test/relay.db "SELECT COUNT(*) FROM schema_migrations;"` | Output: `1` |
234234+| 3 | Run `cargo run --bin relay -- --config relay.toml` a second time. Press Ctrl+C after "listening" appears. | Relay starts and stops cleanly. |
235235+| 4 | Run `sqlite3 /tmp/relay-test/relay.db "SELECT COUNT(*) FROM schema_migrations;"` | Output: still `1` (not `2`). |
236236+237237+---
238238+239239+### MM-72.AC4.1 -- WAL mode on production database (runtime)
240240+241241+| Field | Value |
242242+|-------|-------|
243243+| **Criterion** | `PRAGMA journal_mode` queried on the pool returns `wal` |
244244+| **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. |
245245+| **Note** | This criterion has **primary automated coverage** via `wal_mode_enabled_on_file_pool`. The manual step below is supplementary. |
246246+| **Implementation reference** | phase_02.md Task 3 (`wal_mode_enabled_on_file_pool` test) |
247247+| **Manual steps (optional)** | |
248248+249249+| Step | Action | Expected |
250250+|------|--------|----------|
251251+| 1 | After completing AC1.1 steps 1-3 (database file exists at `/tmp/relay-test/relay.db`): | -- |
252252+| 2 | Run `sqlite3 /tmp/relay-test/relay.db "PRAGMA journal_mode;"` | Output: `wal` |
253253+| 3 | Run `ls /tmp/relay-test/relay.db-wal` | WAL file exists (created by SQLite when WAL mode is active). |
254254+255255+---
256256+257257+## Traceability Matrix
258258+259259+| Acceptance Criterion | Automated Test | Human Verification | Notes |
260260+|----------------------|----------------|-------------------|-------|
261261+| MM-72.AC1.1 (relay.db created) | -- | AC1.1 manual steps | Runtime-only; requires binary execution |
262262+| 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 |
263263+| 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 |
264264+| MM-72.AC2.1 (idempotent) | `migrations_are_idempotent` | AC2.1 manual steps | Unit test covers logic; manual covers cross-process restart |
265265+| MM-72.AC2.2 (version + timestamp) | `schema_migrations_records_version_and_timestamp` | -- | Fully automated |
266266+| MM-72.AC3.1 (AppState compiles) | 5 existing XRPC handler tests | -- | Compilation is the test; runtime confirms router accepts state |
267267+| MM-72.AC3.2 (SELECT 1 on state.db) | `appstate_db_pool_is_queryable` | -- | Fully automated |
268268+| MM-72.AC4.1 (WAL mode) | `wal_mode_enabled_on_file_pool` | AC4.1 manual steps (optional) | Automated test is primary; manual is supplementary |
269269+| MM-72.AC5.1 (in-memory tests) | Code review + 5 migration tests | -- | Structural: verify `in_memory_pool()` uses `"sqlite::memory:"` |
270270+| MM-72.AC5.2 (clean cargo test) | `cargo test --workspace` | -- | CI gate; no relay.db created |
271271+| MM-72.AC6.1 (clippy) | `cargo clippy --workspace -- -D warnings` | -- | CI gate |
272272+| MM-72.AC6.2 (fmt) | `cargo fmt --all --check` | -- | CI gate |
273273+274274+---
275275+276276+## Prerequisites
277277+278278+- Development shell activated: `nix develop --impure --accept-flake-config`
279279+- Branch rebased onto `main` (requires `AppState` from MM-71)
280280+- All three implementation phases completed (phase_01, phase_02, phase_03)
281281+- `cargo test --workspace` exits 0
282282+- `cargo clippy --workspace -- -D warnings` exits 0
283283+- `cargo fmt --all --check` exits 0
284284+- `sqlite3` CLI available in shell (provided by devenv)
285285+286286+## Execution Order
287287+288288+1. Run `cargo test --workspace` -- covers all automated criteria (AC2.1, AC2.2, AC3.1, AC3.2, AC4.1, AC5.1, AC5.2)
289289+2. Run `cargo clippy --workspace -- -D warnings` -- covers AC6.1
290290+3. Run `cargo fmt --all --check` -- covers AC6.2
291291+4. Execute human verification steps for AC1.1 through AC1.3 (single relay start)
292292+5. Execute human verification steps for AC2.1 (second relay start)
293293+6. Optionally execute human verification for AC4.1 (WAL mode on production file)
294294+7. Clean up: `rm -rf /tmp/relay-test relay.toml`