Migrations & seeds
Migrations
Section titled “Migrations”npx flare migrate # local (shared with flare dev / flare start)npx flare migrate --remote # against the deployed databaseruns wrangler d1 migrations apply under the hood.
D1 has no native down-migration, so Flare implements its own:
npx flare migrate:rollback # undo the last migration, locallynpx flare migrate:rollback --steps 3npx flare migrate:rollback --remote --yes # remote rollbacks require --yesRollback resolves every down script before touching anything:
- A hand-written
migrations/down/<name>.sqlwins, if one exists. - Otherwise Flare derives the down from the up SQL:
CREATE TABLE/INDEX/ VIEW/TRIGGER→DROP … IF EXISTS, renames reversed,ADD COLUMN→DROP COLUMN(unless the column hasREFERENCES/UNIQUE/a primary key, which SQLite can’t drop). - Any other statement — a table rebuild,
UPDATE/INSERT, aDROP— makes the migration irreversible. Rollback refuses, naming the blocking statement and the down file you’d need to write by hand.
It prints the plan before running anything. Rolled-back migration files
stay on disk (Rails-style) — edit or delete them, then flare migrate
again.
npx flare gen migration backfill_contact_statusnpx flare gen migration add_phone_to_contacts --from-schemagen migration scaffolds a blank, numbered migration through
drizzle-kit --custom (so it’s recorded like a generated one), plus a
comment-only migrations/down/<file> template — an unwritten rollback
with no SQL statements makes rollback refuse outright, so a forgotten down
migration can never silently “succeed.” --from-schema diffs your current
Drizzle tables instead — the step after hand-editing a descriptor and
running flare sync-types.
Renaming a field without emptying it
Section titled “Renaming a field without emptying it”Rename a field and nothing connects the two names: the schema simply has a column fewer and a column more, and the migration drops one and adds the other — which empties it. Say they are the same column, and it stays one:
fullName: field.string({ rename: { from: "name" } })Then the usual two steps:
npx flare sync-typesnpx flare gen migration rename_name --from-schemacreate migrations/0004_rename_people_name_to_full_name.sql (people.name → full_name, data kept)The migration is a rename, and the data is still there:
ALTER TABLE `people` RENAME COLUMN `name` TO `full_name`;An index whose name carried the column is dropped and recreated in the same migration — an index holds no data, and leaving it under the old name would make the next diff churn it.
It applies once. Whether there is anything to do is decided by the migration history, not by the descriptor, so the declaration does nothing on every run after the first. Leave it in place or delete it on your next edit; both are safe.
Flare refuses a rename it cannot make sense of — one from a field that still exists, or from the field itself — and says which, rather than writing a migration that fails halfway.
Two ways to fill a table: a command for sample rows, and seed files for data you control.
npx flare seed:resource Contact --count 1000 # sample rows, no file needednpx flare seed:make contacts --resource Contactnpx flare seed # every seed file, in file-name ordernpx flare seed contacts # just seeds/contacts.seed.tsSample rows from a resource
Section titled “Sample rows from a resource”seed:resource reads the resource’s descriptor and makes rows that fit it:
an email field gets an address, a tel field an international number, an
enum one of its options, a belongsTo the id of a row that already exists.
Field names steer it too, so city reads “Kampala” and a Company.name is a
company rather than a person.
npx flare seed:resource Contact --count 1000npx flare seed:resource Contact 1m # a million rowsnpx flare seed:resource Contact 5k --truncate # replace what's therenpx flare seed:resource Contact 5k --remote --yes| Flag | |
|---|---|
--count <rows> |
1000, 25k, 1m, 1,000,000 (default 25) |
--truncate |
Delete the table’s rows first |
--seed <number> |
Same number, same rows |
--remote |
The deployed database, not the local one (needs --yes) |
Rows land in batches. Locally that runs about 14,000 rows a second — a
million rows in a bit over a minute; --remote writes one file and hands it to
D1’s import, rather than a query per batch.
Timestamps are spread over the past year and ids sort by creation time, so lists, charts and “newest first” look like an app that’s been running a while.
Seed files
Section titled “Seed files”seed:make writes seeds/<name>.seed.ts, with rows for a resource when the
name matches one (or you pass --resource). Required relations and files are
left as // TODO lines to fill in.
import { defineSeed } from "@flaredev/core";import { contacts } from "@/db/schema";
const COUNT = 50;
export default defineSeed(async ({ insertMany, fake, log }) => { const rows = await insertMany(contacts, COUNT, () => ({ name: fake.fullName(), email: fake.email(), status: fake.pick(["lead", "customer"]), })); log(`inserted ${rows} contacts`);});A seed is handed:
db |
Drizzle, over your schema, on the local database |
insertMany(table, count, build) |
Many rows at once, batched |
fake |
Sample values: fullName, email, phone, company, city, country, sentence, date, pick, some, int, bool |
seedUser({ email, password, … }) |
A user with a credential account, so you can sign in as them |
env |
Local bindings and variables (D1, R2, .dev.vars) |
log |
A line of output |
Use db.insert(...) for a handful of rows you’ve written out, and
insertMany for anything larger. They differ by a lot: Drizzle binds every
value as a parameter and D1 allows 100 of those per statement, so 10,000 rows
become thousands of round trips. insertMany writes the values into the SQL
instead, which measured about 50× quicker.
Seeds run in Node against the local D1 database through Wrangler’s
getPlatformProxy(), sharing state with flare dev, flare start and
flare migrate. For the deployed database, use seed:resource --remote, or
wrangler d1 execute --remote --file for one-off production data.
Generated tables default id to crypto.randomUUID(), so seeds insert
without supplying one.
Somebody to sign in as
Section titled “Somebody to sign in as”A seeded app you cannot log into is not much of a demo. seedUser writes the
user and the credential account together:
export default defineSeed(async ({ seedUser, log }) => { const admin = await seedUser({ email: "admin@example.com", password: "correct-horse-battery", name: "Demo admin", role: "admin", }); log(`sign in as ${admin.email}`);});The same call works on both stacks. It hashes the password the way the sign-up
route does and marks the address verified, since an unverified user cannot sign
in when the app requires verification. role is optional and takes the same
values as flare user:role.
Worth knowing if you ever write those two rows by hand: Better Auth finds a
password account by its accountId column, and for a credential account that
column holds the user’s own id — not the provider’s name, and not the email.
Put the email there and everything looks right, the hash even verifies, and
signing in still answers User not found.
Seed traps worth knowing
Section titled “Seed traps worth knowing”Three things that cost time the first time, all found writing real seeds:
seeds/ doesn’t exist until you make one. flare seed:make <name>
creates the folder and writes a seed with the rows filled in from the
descriptor. Writing the file by hand first fails with “No seeds found”.
Dates differ between the stacks. Prisma maps a date field to
DateTime @db.Date and wants a DateTime; fake.date() returns a date-only
string like "2026-06-27", which SQLite accepts and Postgres rejects with
“premature end of input. Expected ISO-8601 DateTime”. On the Next.js stack,
wrap it:
dateOfBirth: new Date(fake.date()),A failed seed leaves what it already wrote. Seeds are not wrapped in a transaction, so a failure halfway through keeps the rows before it — and the next run then fails on a unique constraint, which hides the error you were trying to read. Clear the tables before re-running:
# Postgrespsql "$DATABASE_URL" -c 'TRUNCATE orders, products, categories CASCADE;'A belongsTo back-reference is named after the model. Order plus
OrderItem gives you order.orderItems, not order.items. Prisma refuses
one-sided relations, so the generator synthesises the other half; check
prisma/schema/resources.prisma if a nested write complains about an unknown
argument.