@lunora/seed fills a Lunora database with realistic fake data derived from your defineSchema. It reads every table, maps each column to a generator (field-name aware: a string column named email becomes an email, firstName a first name), inserts foreign-key parents before their children, and lets you override any value.
Generated addresses always sit on the RFC 2606 reserved domain example.com, which accepts no mail — a seeded row can never turn into real email to a real stranger when a welcome job, a digest or an auth verification runs over it. Override the column when you need a domain of your own.
Generation is deterministic. Generation is deterministic in seed alone for every column except the time-valued ones: a number column named like a timestamp (createdAt, expiresAt, …) is generated relative to the wall clock, so two runs with the same seed differ unless you also pin now (--now on the CLI). Ids are unaffected. Pin the (seed, now) pair and a plan is byte-identical across runs and machines. It is built on a vendored, input-hashed generator (a rebuilt copycat) over @faker-js/faker.
Install
pnpm add @lunora/seed@lunora/server and @lunora/values are peer dependencies (you already have them in a Lunora app).
Three ways to seed
| You want to… | Use |
|---|---|
| Generate rows in memory, write them yourself | seedPlan (from @lunora/seed) |
Populate a lunoraTest harness | seed (from @lunora/seed/testing) |
| Author one table at a time, with types | createSeedClient (from @lunora/seed, or _generated/seed) |
| Seed a running dev worker | lunora seed (CLI) |
seedPlan — the pure core
seedPlan(schema, options) returns one TablePlan per seeded table ({ table, rows }), ordered so a table's FK parents come first. It performs no I/O: every adapter (test harness, CLI, client) builds on it.
import { seedPlan } from "@lunora/seed";
import schema from "./lunora/schema";
const plan = seedPlan(schema, {
counts: { posts: 30, users: 10 },
overrides: {
users: { email: (ctx) => `user${ctx.index}@example.com` },
},
seed: 1,
});
// plan === [{ rows: [...], table: "users" }, { rows: [...], table: "posts" }]
// Every post.authorId points at a generated user; each row carries an explicit `_id`.SeedOptions
Prop
Type
An override function receives an OverrideContext: { field, index, row, store, table }. row is the row built so far; store is a read-only view of every table's rows generated this run (use it to copy a value from the parent row a foreign key points at). Returning undefined defers to the generator, so a partial row that omits a field still gets a generated value.
seed — populate a test harness
@lunora/seed/testing runs seedPlan and inserts every row into a lunoraTest harness in FK order, preserving the planned _ids. It returns the inserted ids keyed by table, for assertions.
import { seed } from "@lunora/seed/testing";
import { lunoraTest } from "@lunora/testing";
import schema from "./lunora/schema";
const harness = lunoraTest(schema);
const ids = await seed(harness, schema, { counts: { posts: 20, users: 5 } });
expect(ids.users).toHaveLength(5);createSeedClient — typed, one table at a time
For a Snaplet-style DX, createSeedClient exposes each table as a method. Call it with a count, an inclusive range, explicit partial rows, or a count callback. Foreign keys connect to rows seeded earlier in the run, FK parents are seeded automatically, and state accumulates on $store / $ids (clear it with $reset()).
import { createSeedClient } from "@lunora/seed";
import schema from "./lunora/schema";
const seed = createSeedClient(schema, { seed: 1 });
const { users } = await seed.users(5);
const { posts } = await seed.posts((x) => x([10, 20])); // a deterministic count in [10, 20]
// Explicit partial rows — omitted columns are still generated:
await seed.users([{ name: "Alice" }, { email: "bob@example.com", name: "Bob" }]);
// Per-field overrides for one call:
await seed.posts(3, { overrides: { title: (ctx) => `Post ${ctx.index}` } });
seed.$ids.users; // every user id generated this run
seed.$reset(); // clear store/ids to drive a fresh runGenerated, project-bound client
When @lunora/seed is a declared dependency, codegen emits _generated/seed.ts, a createSeedClient with your schema and InsertModel already bound, so every table method is fully typed without passing a type argument.
import { createSeedClient } from "@/lunora/_generated/seed";
const seed = createSeedClient({ seed: 1 });
const { users } = await seed.users(5); // columns typed from InsertModelWithout codegen, pass the type yourself: createSeedClient<InsertModel>(schema).
SeedClientOptions
Prop
Type
Because every row carries an explicit _id, $ids is known whether or not you persist. A persist hook lets you write batches into the test harness or an admin import as they are generated.
lunora seed — seed a running worker
Generates from lunora/schema.ts and bulk-inserts through the worker's admin endpoint.
lunora seed # every table, default count (10)
lunora seed --table posts --count 100 # one table; FK parents seeded automatically
lunora seed --seed 42 --now 1750000000000 # byte-identical run (pin both)
lunora seed --dry-run # print the NDJSON, insert nothing
lunora seed --reset # wipe local .wrangler/state first (local dev only)Other flags: --batch-size (rows per HTTP request, default 500), --url (worker URL, default http://localhost:8787), --prod (requires an explicit --url), --token / LUNORA_ADMIN_TOKEN (admin bearer; prefer the env var), --yes (skip the non-local confirmation prompt).
--reset clears local .wrangler/state only; it cannot be combined with --prod or a remote --url.Limitations
.unique()columns are dealt distinct values by construction. Each value is a function of the row's absolute index, not a hash of the row, so two rows in the same run never collide — including across the several callsindexOffsetexists to support. A.unique()foreign key (v.id("users").unique(), the way to spell a 1:1) is dealt from its parent pool without replacement, so the pool's size is its domain. A column whose domain is too small to cover the requested count (a boolean, a three-literalv.union(), a parent table with fewer rows than the child) is refused at plan time, with the column named, before anything is inserted.- A column the generator cannot satisfy is refused, not guessed at.
v.from(externalSchema), a.pattern()column, and anyformatwith no generator behind it (onlyemailandurihave one) fail at plan time with the column named, rather than seeding a value the column's own validator rejects on insert. Supply those throughoverrides, or skip the table withonly. - Seeding is deterministic by design. Re-running with the same
seedregenerates identical_ids, which the import path skips as conflicts. Use a differentseedfor fresh rows, or--resetto wipe local state first.
Related
@lunora/server: defines thedefineSchemathat seeding introspects.@lunora/testing: the in-memory harness@lunora/seed/testingseeds.@lunora/cli: ships thelunora seedcommand.