You need: a project with tables. There is no separate fixture format to learn.
The mental model
pithy seed is the command, the data layer is where a fixture’s schema comes from, and live fixtures is the variant that talks to a real account.
A seed set is the direct analog of a migration. A migration describes schema changes; a seed set describes rows, entries and assets.
Both are namespaced per capability, ordered, composed library-before-app into one registry, and run by one command against a named environment.
Authoring one
import { defineSeed, d1SeedGroup } from "@pithy-sh/core/src/seed/seed";
import { BoardEntry } from "./data/entry";
export const demoBoardSeed = defineSeed({
name: "demo_board",
order: 1,
environments: ["dev", "staging"],
d1: [
d1SeedGroup("app", "boardEntries", BoardEntry, [
{ id: "demo-1", boardId: "weekly", userId: "demo-user-1", score: 4200, createdAt: new Date() },
{ id: "demo-2", boardId: "weekly", userId: "demo-user-2", score: 3100, createdAt: new Date() },
]),
],
});Four things there are worth noticing.
Rows are app-shape values. That date is a real Date, not an epoch number — the same shape your application code produces.
The schema binds the row type at compile time and is not stored. The write path resolves the live schema itself from the composed registry, so a fixture can never drift from the real table shape.
order composes exactly like a migration’s, libraries before the app, ties broken on the namespaced key.
environments is an allowlist. This set is only ever composed into a run targeting one of those. Listing production is the only way a set ever reaches production — there is no default-on environment.
Contribute it on your capability’s seeds, beside its databases and migrations.
Validation, then insertion
Every row is validated before anything is written — through the exact inverse of the parse your application reads with. A single bad fixture fails its whole group with a field-level error, and nothing for that group lands.
Then the write is deliberately boring:
- D1 rows insert ignoring conflicts
- KV entries write by key, which is naturally idempotent
- Nothing is ever updated or deleted
Which is why seeding is safe to run repeatedly, and safe as a step in a pipeline that provisions an ephemeral environment.
It is also why editing a fixture and re-running does nothing. The row already exists, so it is left alone.
--redo, and what it actually costs
pithy seed --env dev --redoNot a per-row refresh. A full schema reset: every migration rolled back, every one reapplied, then a normal seed.
Because every table comes back empty, the ordinary non-destructive writes just work — there is nothing left to special-case.
And nothing left of what was there. It destroys every row in every table the registry owns, not just the rows a fixture wrote. Data you inserted by hand does not survive.
Its gate is stricter than the seed gate
--yes means yes, this is not dev, and it was designed to authorize writing seed rows, which is additive and harmless. A reset drops every table first.
Letting one flag authorize both would mean a script that knew only to pass --yes could destroy an environment’s entire dataset.
| Environment | Plain seed | --redo |
|---|---|---|
dev | Free | Free |
| Anything else | --yes | The exact phrase, naming that environment |
| Production | --yes plus the seed phrase | The reset phrase, refused headlessly without it |
The phrase names its environment, so one typed for staging cannot be pasted into a command targeting another.
A non-dev reset is audited at critical severity, truthfully — recorded as a failure, with the command still failing, if it dies partway.
Fixture size
Make a fixture as big as the thing it has to prove. A paged list needs more rows than a page.
D1 accepts 100 bound parameters per statement, and an insert binds one per column per row — so a seven-column table fits about fifteen rows. Each group is chunked from that table’s own column count, so the limit is never a fixture’s problem and never a number to look up.
A group is not atomic across chunks, and ignoring conflicts is what makes that safe: a run that dies partway is re-run, landed rows are ignored, and the rest go in.
Media fixtures
Some fixtures need real bytes — an avatar, a demo thumbnail — uploaded to a real store rather than just a row.
once uploads on its first run and skips forever after, writing the store’s minted id back so the row and the asset stay paired. always re-uploads.
Every asset a fixture creates is stamped with the project and environment, because those stores key by an id they mint rather than by a name you choose — so there is nowhere to put a project segment, and metadata is the only attribution available.
Minting a dev session
The one fixture you will use daily. Seeding mints a real, signed-in session for a seeded user, so pithy dev starts signed in rather than making you round-trip a magic link.
Opt in per machine, never per repo — name a seeded user in a file in the config directory. No file, no session.
The set is dev-only, so it can never compose into staging or production.
Dev secrets can never reach a managed environment
Not by flag, not by argument. Seeding production seeds none of them and says so.
The refusal is structural — no signature in that path accepts a managed environment — because seeding that file into production would rotate every secret at once, with no undo.
Check it worked
- A run reports one line per set, and a set the environment disallows is reported rather than silently dropped
- Running twice writes nothing the second time
- A bad fixture fails its group with a field-level error
--dry-runprints the plan and touches nothing- Pressing
lunderpithy devopens a signed-in browser