Skip to content

Seeding

@cfast/db includes a schema-driven seed generator that introspects your Drizzle schema to produce realistic test data. @faker-js/faker is bundled — you never need to install or import it. All seed functionality lives under the @cfast/db/seed entrypoint so tree-shaking keeps faker out of your production bundle.

import { seed } from "@cfast/db/seed";
import { createDb } from "@cfast/db";
import * as schema from "./schema";
const db = createDb({ d1: env.DB, schema, grants: [], user: null });
await seed(db);

That’s it. seed(db) introspects the schema from the Db instance, topologically sorts tables by FK dependencies, generates realistic data, and inserts rows in the correct order via db.unsafe().

Chain .seed(fn) on any Drizzle column builder to replace the auto-inferred generator:

import { text, integer } from "drizzle-orm/sqlite-core";
import { table } from "@cfast/db/seed";
export const posts = table("posts", {
id: text("id").primaryKey(),
title: text("title").notNull().seed((f) => f.lorem.sentence()),
slug: text("slug").notNull().seed((f) => f.lorem.slug()),
views: integer("views").notNull().seed((f) => f.number.int({ min: 0, max: 10000 })),
authorId: text("author_id").notNull().references(() => users.id),
}).seed({ count: 10 });

The .seed() method is available on every SQLite column builder (text(), integer(), real(), blob()). It accepts a (faker, ctx) => value function called once per generated row and returns this for chaining.

Use table() from @cfast/db/seed instead of sqliteTable() to get a .seed() method on the table:

import { table } from "@cfast/db/seed";
import { text } from "drizzle-orm/sqlite-core";
export const users = table("users", {
id: text("id").primaryKey(),
email: text("email").notNull(),
name: text("name").notNull(),
}).seed({ count: 5 });
export const posts = table("posts", {
id: text("id").primaryKey(),
title: text("title").notNull(),
authorId: text("author_id").notNull().references(() => users.id),
}).seed({ count: 3, per: users });

table() is a drop-in replacement for sqliteTable() — the returned table is a standard Drizzle table in every way. With { count: 3, per: users }, the engine generates 3 posts per user. If there are 5 users, you get 15 posts total, each with authorId automatically set to the parent user’s id.

The older seedConfig() and tableSeed() wrapper functions still work and write to the same internal registries. They are interchangeable with the .seed() methods but considered deprecated:

// Deprecated -- prefer .seed() method API above
import { seedConfig, tableSeed } from "@cfast/db/seed";
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
const posts = tableSeed(sqliteTable("posts", {
title: seedConfig(text("title"), f => f.lorem.sentence()),
}), { count: 5 });

When no .seed() is present, the engine picks a generator based on the Drizzle column type:

Column typeGenerated value
text (PK)faker.string.uuid()
textfaker.lorem.words(3)
integerfaker.number.int({ min: 0, max: 10000 })
realfaker.number.float({ min: 0, max: 1000 })
integer (boolean mode)faker.datatype.boolean()
integer (timestamp mode)faker.date.recent()
blob / bufferfaker.string.alphanumeric(16)
Nullable columnnull ~10% of the time

Columns with .$defaultFn() or a static .default() are skipped entirely — Drizzle fills them at insert time.

Tables named users get special handling: the first five rows receive deterministic emails (admin@example.com, user@example.com, editor@example.com, viewer@example.com, moderator@example.com), and the name column uses faker.person.fullName(). This makes it easy to log in as different roles during development.

The per option on .seed() creates one-to-many relationships automatically. The engine resolves the foreign key between the child table and the per parent, filling it with the parent row’s primary key.

Chain per across multiple levels for deep hierarchies:

export const users = table("users", { ... }).seed({ count: 3 });
export const posts = table("posts", { ... }).seed({ count: 2, per: users });
export const comments = table("comments", { ... }).seed({ count: 4, per: posts });

This produces 3 users, 6 posts (2 per user), and 24 comments (4 per post). The topological sort ensures users are seeded before posts, and posts before comments.

The second argument to a .seed() function is a SeedContext with four properties:

The parent row when the table uses per. undefined for root tables:

const children = table("children", {
id: text("id").primaryKey(),
parentId: text("parent_id").notNull().references(() => parents.id),
label: text("label").seed((_f, ctx) =>
`Child of ${ctx.parent?.name}`,
),
}).seed({ count: 2, per: parents });

Pick a random already-generated row from any table. Useful for cross-references that are not direct FKs:

const reviews = table("reviews", {
id: text("id").primaryKey(),
productId: text("product_id").notNull().references(() => products.id),
reviewerNote: text("reviewer_note").seed((_f, ctx) => {
const product = ctx.ref(products);
return `Review for ${product.name}`;
}),
}).seed({ count: 10 });

Zero-based position within the current batch (per-parent or global):

text("slug").notNull().seed((_f, ctx) => `post-${ctx.index}`);

Returns all generated rows for a table (available after that table has been seeded). Useful for summary or aggregate seed data:

integer("total_items").notNull().seed((_f, ctx) => ctx.all(items).length);

Join tables (tables with 2+ foreign keys) are automatically deduplicated. If the engine would generate a duplicate (postId, tagId) combination for a postTags table, the duplicate row is silently dropped:

export const postTags = table("post_tags", {
id: text("id").primaryKey(),
postId: text("post_id").notNull().references(() => posts.id),
tagId: text("tag_id").notNull().references(() => tags.id),
}).seed({ count: 5 });
// If only 3 unique (post, tag) combos exist, you get 3 rows, not 5.

Use .seed() with ctx.index or ctx.parent for role-based or conditional data:

const users = table("users", {
id: text("id").primaryKey(),
email: text("email").notNull().seed((f, ctx) => {
const roles = ["admin", "editor", "viewer"];
if (ctx.index < roles.length) return `${roles[ctx.index]}@example.com`;
return f.internet.email();
}),
role: text("role").notNull().seed((_f, ctx) => {
const roles = ["admin", "editor", "viewer"];
return roles[ctx.index % roles.length];
}),
}).seed({ count: 5 });

Seed a single table without running the full engine. This is useful for quick one-off data in tests:

import { createSingleTableSeed } from "@cfast/db/seed";
const singleSeed = createSingleTableSeed(schema, posts, 5);
await singleSeed.run(db);

Other tables in the schema get zero rows. Foreign key parents are still generated if needed for referential integrity.

Pass { transcript: "/path/to/seed.sql" } to seed() or .run() to write the generated INSERT statements to a file:

import { seed } from "@cfast/db/seed";
await seed(db, { transcript: "./seed-transcript.sql" });

The transcript file contains one INSERT INTO statement per row, in topological order. In Workers environments where node:fs is unavailable, the transcript write is silently skipped.

For hand-authored fixture data (not generated), use defineSeed() with explicit row arrays:

import { defineSeed } from "@cfast/db/seed";
import { createDb } from "@cfast/db";
import * as schema from "./schema";
const mySeed = defineSeed({
entries: [
{
table: schema.users,
rows: [
{ id: "u-1", email: "ada@example.com", name: "Ada" },
{ id: "u-2", email: "grace@example.com", name: "Grace" },
],
},
{
table: schema.posts,
rows: [
{ id: "p-1", authorId: "u-1", title: "Hello" },
],
},
],
});
const db = createDb({ d1, schema, grants: [], user: null });
await mySeed.run(db);

Row types are inferred from the Drizzle table, so typos in column names are caught by tsc. Entries are inserted in list order — place parent tables before children.