Seeding
Demo data lives in the seed crate — a product crate under crates/,
scaffolded by nestrs new beside migrations.
Seeding is idempotent: every factory inserts with ON CONFLICT DO NOTHING,
so nestrs run db seed can run any number of times and only ever fills gaps.
nestrs run db reset chains a fresh migrate with a seed for a clean slate.
A fresh workspace starts with a connected but empty crates/seed/src/main.rs.
The factories/ + runner.rs split below is what it grows into once there is
more than one table to fill.
A factory
Section titled “A factory”One factory per table, each a seed(db) function that inserts its demo rows
and returns how many landed. Organizations use fixed UUIDs so other factories
— and tests — can reference them by constant. The ids are UUID v7 (version
nibble 7, variant 8): the Bind / #[crud] by-id routes reject any
non-v7 path id, so a seeded row has to be v7 to be reachable by id.
pub const ACME: Uuid = Uuid::from_u128(0x0000_0000_0000_7000_8000_0000_0000_ac3e);pub const GLOBEX: Uuid = Uuid::from_u128(0x0000_0000_0000_7000_8000_0000_0000_61b3);
const DEMO_ORGS: [(Uuid, &str); 2] = [(ACME, "Acme"), (GLOBEX, "Globex")];
pub async fn seed(db: &DatabaseConnection) -> Result<u64> { let mut inserted = 0; for (id, name) in DEMO_ORGS { let stmt = Query::insert() .into_table(Org::Table) .columns([Org::Id, Org::Name]) .values_panic([id.into(), name.to_owned().into()]) .on_conflict(OnConflict::column(Org::Id).do_nothing().to_owned()) .to_owned(); inserted += db.execute(&stmt).await?.rows_affected(); } Ok(inserted)}Rows that depend on others reach for those constants. Demo users also use
fixed ids — user::ACME_AUTHOR is the author of Acme posts — and conflict on
the unique email:
pub const ACME_AUTHOR: Uuid = Uuid::from_u128(0x0000_0000_0000_7000_8000_0000_0000_ac01);
// (id, org, name, email, role, password_hash) — an admin plus members per org.const DEMO: [(Uuid, Uuid, &str, &str, &str, Option<&str>); 7] = [ (ACME_AUTHOR, org::ACME, "Acme Author", "acme-user-1@example.test", "user", Some(DEMO_PASSWORD_HASH)), // ...];
// INSERT ... ON CONFLICT (email) DO NOTHING.The password hash is a pre-computed argon2id PHC constant, not a fresh hash per run — re-hashing would emit a new salt each time and break idempotency.
Posts hang off the org and author constants. post::WELCOME is a stable id
for the first Acme article:
pub const WELCOME: Uuid = Uuid::from_u128(0x0000_0000_0000_7000_8000_0000_0000_b001);
const DEMO: [(Uuid, Uuid, Uuid, &str, &str); 3] = [ ( WELCOME, org::ACME, user::ACME_AUTHOR, "Welcome to Publish", "Getting started with nestrs and the Publish demo app.", ), // ...];
// INSERT ... ON CONFLICT (id) DO NOTHING.The runner
Section titled “The runner”seed::run calls each factory in dependency order and sums the inserts —
parents before children, so foreign keys resolve:
pub async fn run(db: &DatabaseConnection) -> Result<u64> { let mut inserted = 0; inserted += factories::org::seed(db).await?; inserted += factories::user::seed(db).await?; inserted += factories::post::seed(db).await?; Ok(inserted)}Run it
Section titled “Run it”nestrs run db seed # load demo data (idempotent)nestrs run db reset # fresh migrate, then seednestrs run db seed runs the crate’s seed binary. It connects through
nest_rs_seaorm::connect_from_env() — the same connector
migrate uses, resolving
NESTRS_DATABASE__* through the .env cascade — then runs seed::run and
prints how many rows it inserted.
Because run is a plain function, tests call it directly. The crate’s e2e
migrates a throwaway database, seeds it, asserts rows landed, then seeds again
and asserts the second pass inserts zero — idempotence, verified.
Going further
Section titled “Going further”- Migrations — the schema the seed data fills.
- Database — entities and the service that reads them back.
Built by YV17labs