diff --git a/docs/superpowers/specs/2026-06-05-spectrum-seed-wiring-design.md b/docs/superpowers/specs/2026-06-05-spectrum-seed-wiring-design.md new file mode 100644 index 0000000..7df93d2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-spectrum-seed-wiring-design.md @@ -0,0 +1,146 @@ +# Wire the Spectrum Seed into Runtime — Design + +**Date:** 2026-06-05 +**Status:** Approved (brainstorming) — ready for implementation planning. +**Issue:** #14. + +## Context + +`db::seed::seed_spectrum_cataloguing(conn)` already exists, is **idempotent** (each +vocabulary/field-definition is created only if its key is absent), and is tested at the +db layer (`crates/db/tests/seed.rs` covers content + re-seed idempotency). Nothing +invokes it yet — #14 is purely about **wiring**. + +The server binary already has the pattern to extend. `crates/server/src/main.rs` defines +a clap `Command` enum with one variant (`CreateUser`); `main` dispatches `None → run`, +`Some(sub) → one-shot`. `server::create_user(database_url, …)` (`crates/server/src/lib.rs`) +is the one-shot template: connect with a tiny pool (`Db::connect(url, 2)`), open a +transaction, do the work with `AuditActor::System`, commit, print/log, exit. + +The app is **single-tenant** (env-driven config, no control plane / provisioning service). +So #14's suggested "per-org provisioning" home does not exist yet; the realistic wiring +now is a manual one-shot, mirroring `create-user`. + +### Decision (from brainstorming) + +A **`server seed` CLI subcommand** — explicit, idempotent, safe to re-run, no coupling to +the serve path. (Rejected: a `--seed` startup flag — couples seeding to serving; +auto-seed-on-first-boot — silently mutates data on boot, needs first-boot detection; the +provisioning path — no control plane exists.) The operator runs `server seed` once when +setting up an instance, alongside `server create-user`. + +## Components + +### 1. `Command::Seed` variant (`crates/server/src/main.rs`) + +Add to the `Command` enum: +```rust +/// Seed the baseline Spectrum cataloguing vocabularies + field definitions (idempotent). +Seed, +``` +And a dispatch arm in `main`: +```rust +Some(Command::Seed) => seed(&cli.config.database_url).await, +``` +`Seed` takes no args of its own — it uses the flattened `Config`'s `database_url` (which +already reads `DATABASE_URL` from env / `--database-url`), exactly like `CreateUser` reads +`cli.config.database_url`. + +### 2. `server::seed` one-shot (`crates/server/src/lib.rs`) + +A new public function modeled on `create_user`: +```rust +/// One-shot: apply migrations (idempotent) then seed the baseline Spectrum cataloguing +/// vocabularies + field definitions. Safe to re-run. +pub async fn seed(database_url: &str) -> anyhow::Result<()> { + let db = Db::connect(database_url, 2) + .await + .context("connecting to the database")?; + + // Apply migrations first so `server seed` works on a fresh DB without first + // starting the server. Migrations are idempotent. (This is a deliberate, robust + // step beyond create_user, which assumes a migrated DB.) + db.migrate().await.context("running database migrations")?; + + let mut tx = db.pool().begin().await?; + db::seed::seed_spectrum_cataloguing(&mut tx) + .await + .context("seeding Spectrum cataloguing baseline")?; + tx.commit().await?; + + println!("seeded Spectrum cataloguing baseline (idempotent)"); + Ok(()) +} +``` +Notes: +- The seed function uses `AuditActor::System` internally for the vocabulary creates, so no + actor plumbing is needed at the server layer. +- It returns `()`; the printed line is a generic confirmation (re-running a fully-seeded DB + prints the same line — correct, since the operation is idempotent). +- `Db::migrate()` is the same method `run` calls on startup (`lib.rs:22`). + +### 3. Convenience: `just seed` recipe + README note + +- `justfile`: add + ``` + # Seed the baseline Spectrum cataloguing vocabularies + field definitions (idempotent) + seed: + cargo run -p server -- seed + ``` + (`set dotenv-load` already supplies `DATABASE_URL`.) +- `README.md` "Running locally": add one line to the setup steps, e.g. after creating the + admin user — "Seed the baseline cataloguing fields: `just seed` (or + `cargo run -p server -- seed`)." + +## Data flow + +`server seed` → `server::seed(database_url)` → `Db::connect` → `db.migrate()` → +`tx = begin()` → `db::seed::seed_spectrum_cataloguing(&mut tx)` (idempotent ensure-by-key) +→ `tx.commit()` → confirmation line → exit 0. + +## Error handling + +- Connection / migration failures → `anyhow` error with context, non-zero exit (matches + `create_user`). +- A partial seed cannot persist: all inserts run inside the single transaction, so any + error rolls the whole seed back (the seed fn already takes `&mut *tx`). +- Re-running on an already-seeded DB is a no-op (ensure-by-key) and exits 0. + +## Testing + +- **Existing (db layer):** `crates/db/tests/seed.rs` already asserts the seed creates the + expected vocabularies + field definitions and that re-seeding is idempotent. No change. +- **New (server layer):** add a test mirroring `crates/server/tests/create_user.rs`'s + harness (read it to match how it bridges a `#[sqlx::test]` `PgPool` to the + `database_url`-taking one-shot). The test exercises the wiring end-to-end: invoke the + seed one-shot **twice** against a fresh test DB and assert it succeeds both times and + that a known seeded row (e.g. vocabulary `material`, field definition `title`) is + present. This proves the `seed` glue + migrate path, complementing the db-layer content + tests. + - If `create_user.rs` tests the db layer directly rather than `server::create_user` + (because the one-shot takes a URL, not a pool), mirror that: call + `db::seed::seed_spectrum_cataloguing` twice via the pool and assert idempotent success. + The thin `server::seed` glue (connect + migrate + commit) is then covered by manual + verification (below). +- **Manual verification:** `docker compose up -d`, then `just seed` twice — both exit 0; + the second is a no-op; the seeded vocabularies/fields appear in the `/vocabularies` and + `/fields` admin screens. + +## Acceptance criteria + +1. `server seed` (and `just seed`) applies the idempotent Spectrum cataloguing seed and + exits 0; re-running is a safe no-op. +2. It works on a fresh (but reachable) database — migrations are applied first. +3. The wiring mirrors the existing `create-user` one-shot (pool of 2, tx, `AuditActor::System` + via the seed fn, `anyhow` context on failure). +4. `cargo nextest run --workspace` green; `cargo +nightly fmt` + `clippy -D warnings` clean; + no codename. +5. README "Running locally" documents the seed step; `just seed` recipe present. + +## Out of scope + +- `--seed` startup flag; auto seed-on-first-boot. +- Per-org provisioning / control-plane seeding (no control plane exists; revisit if it lands). +- Seeding vocabulary **terms** (the seed deliberately creates vocabularies empty; terms are + populated by the organisation or a later import). +- Making `create-user` migrate (out of scope; only `seed` gains the migrate step here).