Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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::Systeminternally 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 methodruncalls 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 -- seedset dotenv-loadalready suppliesDATABASE_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(orcargo 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 →
anyhowerror with context, non-zero exit (matchescreate_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.rsalready 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]PgPoolto thedatabase_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. vocabularymaterial, field definitiontitle) is present. This proves theseedglue + migrate path, complementing the db-layer content tests.- If
create_user.rstests the db layer directly rather thanserver::create_user(because the one-shot takes a URL, not a pool), mirror that: calldb::seed::seed_spectrum_cataloguingtwice via the pool and assert idempotent success. The thinserver::seedglue (connect + migrate + commit) is then covered by manual verification (below).
- If
- Manual verification:
docker compose up -d, thenjust seedtwice — both exit 0; the second is a no-op; the seeded vocabularies/fields appear in the/vocabulariesand/fieldsadmin screens.
Acceptance criteria
server seed(andjust seed) applies the idempotent Spectrum cataloguing seed and exits 0; re-running is a safe no-op.- It works on a fresh (but reachable) database — migrations are applied first.
- The wiring mirrors the existing
create-userone-shot (pool of 2, tx,AuditActor::Systemvia the seed fn,anyhowcontext on failure). cargo nextest run --workspacegreen;cargo +nightly fmt+clippy -D warningsclean; no codename.- README "Running locally" documents the seed step;
just seedrecipe present.
Out of scope
--seedstartup 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-usermigrate (out of scope; onlyseedgains the migrate step here).