Skip to content
DocspackagesDocumentation

@lunora/seed

Deterministic, schema-driven seeding: realistic fake data generated from defineSchema.

PackagesSeed

@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 yourselfseedPlan (from @lunora/seed)
Populate a lunoraTest harnessseed (from @lunora/seed/testing)
Author one table at a time, with typescreateSeedClient (from @lunora/seed, or _generated/seed)
Seed a running dev workerlunora 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 run

Generated, 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 InsertModel

Without 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 calls indexOffset exists 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-literal v.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 any format with no generator behind it (only email and uri have one) fail at plan time with the column named, rather than seeding a value the column's own validator rejects on insert. Supply those through overrides, or skip the table with only.
  • Seeding is deterministic by design. Re-running with the same seed regenerates identical _ids, which the import path skips as conflicts. Use a different seed for fresh rows, or --reset to wipe local state first.