docs(specs): wire Spectrum seed into runtime via 'server seed' (#14)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
||||
Reference in New Issue
Block a user