Database Seeding
Populate your database with test or initial data
Overview
There are two seeding mechanisms in Stabilize, and they are not interchangeable. Pick one and stay with it:
| CLI seed files | Programmatic seeds | |
|---|---|---|
| Defined with | a seeds/*.ts file exporting seed(orm) | defineSeed(name, run) |
| Run with | stabilize-cli seed | orm.seed() / orm.seed([...]) |
| Callback receives | the Stabilize instance | a DBClient |
| Tracked in a table | yes — stabilize_seed_history | no — it runs every time |
The two conventions are incompatible
A CLI-generated seed file takes a Stabilize and calls orm.getRepository(Model). A defineSeed callback takes a DBClient — which has query, queryExec, transaction and close, and no getRepository. Handing a CLI-style seed to orm.seed(), or a defineSeed callback to the CLI, fails with getRepository is not a function (or the reverse).
CLI Seed Files
Generate a starter seed file for a model, then run every pending one:
The generated file exports a single seed(orm: Stabilize) function:
import { Stabilize, generateUUID } from "stabilize-orm";import { User } from "../models/User";
export async function seed(orm: Stabilize) { const repo = orm.getRepository(User);
await repo.bulkCreate([ { id: generateUUID(), email: "admin@example.com", name: "Admin", role: "admin" }, { id: generateUUID(), email: "alice@example.com", name: "Alice", role: "user" }, { id: generateUUID(), email: "bob@example.com", name: "Bob", role: "user" }, ]);
console.log("Seeded 3 User(s)");}Note there is no rollback export — the CLI has no command that calls one, so writing one simply has no effect. To undo a seed, truncate the table (see below).
The CLI keeps its own ledger, so running stabilize-cli seed twice applies nothing the second time. The key is the file name: a seed is recorded as applied under basename(file, ".ts"), and everything already in stabilize_seed_history is filtered out before the run. Renaming a seed file makes it run again; editing one does not.
CLI seeding does not support SQL Server
stabilize-cli seed creates stabilize_seed_history with one of three hand-written statements — MySQL, Postgres, or a combined default. SQL Server falls into the default and is sent CREATE TABLE IF NOT EXISTS, which T-SQL rejects. Use the programmatic seeds below on an SQL Server target.
Programmatic Seeds
defineSeed registers a named seed; orm.seed() runs the registered ones, or a list you pass it.
import { defineSeed } from "stabilize-orm";
defineSeed("seed users", async (db) => { // `db` is a DBClient, not the ORM. Query it directly... await db.query( "INSERT INTO users (id, email, name) VALUES (?, ?, ?)", [generateUUID(), "admin@example.com", "Admin"], );
// ...or wrap the work in a transaction. await db.transaction(async (tx) => { for (const row of rows) { await tx.query( "INSERT INTO users (id, email, name) VALUES (?, ?, ?)", [row.id, row.email, row.name], ); } });});
// Placeholders are always `?` — the client rewrites them per dialect.import "./seeds/index";import { orm } from "./db";
await orm.seed(); // every registered seed
// Or run a specific list, ignoring the registry:await orm.seed([ { name: "one-off", run: async (db) => { /* ... */ } },]);Registration order is execution order, and seeds run sequentially — each is awaited before the next starts, so a seed may rely on the one before it. Nothing is recorded in a table, so a seed registered and run twice does its work twice. Guard it yourself if that matters.
Resetting the Database
orm.reset(models) drops each model's table and its history table, then runs autoMigrate to recreate them empty — the natural counterpart to a seed run in a test harness. Missing tables are ignored, so it is safe on a fresh database.
import { orm } from "./db";
await orm.reset([User, Profile, Post]);await orm.seed(); // now re-seed from scratchThis is destructive and there is no confirmation. Note that it drops the tables it is given rather than emptying them, so any table not named is left alone.
Repository bulkCreate()
Both mechanisms above ultimately lean on the same write. bulkCreate() inserts a list in one transaction, and runs beforeCreate and beforeSave hooks for each row before the insert.
await userRepo.bulkCreate(rows); // T[]await userRepo.bulkCreate(rows, { batchSize: 500 }); // chunked insertBecause it is one transaction, a single bad row takes the whole batch with it — there is no partial insert to reconcile.
Repository seed()
A repository also has a seed() method, but be careful with it:
const userRepo = orm.getRepository(User);
const rows = await userRepo.seed([ { id: generateUUID(), email: "admin@example.com", name: "Admin" }, { id: generateUUID(), email: "user@example.com", name: "User" },], { ignoreDuplicates: true });ignoreDuplicates is table-wide, not row-wide
The name suggests per-record deduplication, in the manner of MySQL's INSERT IGNORE. It is not. The implementation is: read every row in the table, and if that read returned anything at all, return those rows and insert nothing.
// What seed() actually does, in full:const existing = await this.find().execute(client);if (existing.length > 0 && options.ignoreDuplicates) return existing;return this.bulkCreate(data, {}, client);Two consequences. First, once a table holds one row — from a previous seed, or a real user signing up — a later seed(..., { ignoreDuplicates: true }) is a no-op and inserts none of your data. Second, the array it returns is the rows already in the table, not the rows you passed, so assigning the result and using it will silently give you the wrong records. With no rows present, or with ignoreDuplicates left off, it inserts normally.
For predictable behaviour, prefer bulkCreate() with an explicit existence check, or firstOrCreate() per record.
Best Practices
- Keep seed files idempotent — the CLI ledger protects you against re-running an unchanged file, but not against a file whose data already exists by another route
- Give generated
idvalues explicitly when the model uses a UUID or STRING id; only an integeridis generated by the database - Use
bulkCreate()rather than a loop ofcreate()— one transaction instead of N - Separate development and production seeds; nothing in the mechanism distinguishes them
- Remember a seed survives
orm.reset()only if you run it again afterwards