Files
biggus-dickus/docs/superpowers/specs/2026-06-05-spectrum-seed-wiring-design.md
T
2026-06-05 22:55:27 +02:00

6.7 KiB

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:

/// Seed the baseline Spectrum cataloguing vocabularies + field definitions (idempotent).
Seed,

And a dispatch arm in main:

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:

/// 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 seedserver::seed(database_url)Db::connectdb.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).