A deterministic, idempotent seed for two orgs
An empty database is impossible to build on. The reads you write in the next two lessons need rows to page through, the inspector’s banner needs counts to show, and every later unit in the course — CRUD, auth, billing — wants a realistic dataset waiting for it. But “realistic” is not the hard part; repeatable is. The habit that separates a data layer you can trust from one you cross your fingers over is a seed that produces the exact same data every single run, so verification stops being a guess and becomes one glance at a number.
In this lesson you write that seed. One command — pnpm db:seed — fills the database with two organizations, four users with one of them belonging to both orgs, forty customers, and well over a hundred invoices, each with a handful of line items. When it finishes, the inspector banner reads organizations: 2, users: 4, org_members: 5, customers: 40, and invoices comfortably past 100, with invoice_lines proportionate. Run it a second time and nothing moves: the counts are identical and a sampled invoice comes back with the same invoice number it had before. That last property is the whole point — the data is reproducible, so the banner is a contract, not a coincidence.
Your mission
Section titled “Your mission”The data layer needs realistic, repeatable data, and that one word repeatable shapes everything about how you build it. You will write runSeed in scripts/seed.ts as a reset(dbUnpooled, schema) call followed by a sequence of typed inserts — organizations, then users, then orgMembers, customers, invoices, and finally invoiceLines — with every random choice in that sequence drawn from a single small pseudo-random generator seeded by env.SEED. The db:seed script is already wired for you (dotenv -e .env -- tsx scripts/seed.ts); your job is the body of runSeed.
Two non-functional properties define correctness here, and they pull in different directions until you have both. The first is determinism: the fixed seed number is the contract, so the same number must always produce the same data. That only holds if all randomness flows through one generator you seed yourself — the moment a Math.random() or a Date.now() sneaks in, the data drifts run to run and the contract is silently broken. Bumping SEED to a new number is how you deliberately reshape the dataset; changing the insert logic without bumping it, while expecting the same output, is the trap. The second is idempotency: a re-run must leave identical row counts rather than stacking a fresh dataset on top of the old one. That is what the reset(db, schema) call you met in the seeding lesson of the previous chapter buys you — it issues a TRUNCATE ... CASCADE to clear every table before the inserts run, so run two leaves the database looking exactly like run one.
There is a deliberate tool choice woven into this. The previous chapter introduced drizzle-seed, whose generators are the right reach when you want a pile of shapeless fixtures — a thousand rows where each field is independently random and nothing constrains one row against another. This invoicing model is the opposite. It is dense with cross-row invariants: one user has to land in both organizations, invoice numbers run in a per-tenant sequence, each invoice’s line positions are renumbered from one, a dueAt is derived from its own issuedAt, and child rows need the primary keys that the parent inserts just generated. Those constraints are awkward to express through a bulk generator and trivial to express with a small deterministic generator plus direct inserts you control end to end. So this seed uses only reset() from drizzle-seed and writes the inserts by hand. The takeaway worth keeping: drizzle-seed for shapeless fixtures, a fixed-seed generator plus direct inserts the moment the data has structure.
A few more constraints. The seed stays local-only and reaches for dbUnpooled, not db — the same rule migrations follow. reset’s TRUNCATE ... CASCADE and the long insert transaction both hold locks, which is fine against your local Docker Postgres but exactly what you must not point at a shared database; the deployment chapters later in the course make the pooled/unpooled split real against Neon. Primary keys come from the schema’s uuidv7() default, never hand-generated — you capture them back from .returning() so a child row can reference the parent that was just inserted. And the data has a shape to hit: two orgs (Acme and Globex), four users with Ada Lovelace a member of both orgs — the overlapping-membership invariant the whole multi-tenant story rests on — forty customers split across the two orgs, twelve to eighteen invoices per customer (which clears a hundred with room to spare), and two to four line items per invoice. The status mix should look realistic — mostly paid, a few overdue — not a uniform spread.
Out of scope: the reads over this data — the cursor-paginated list and the single-round-trip detail — come in the next two lessons, and any path that writes to this schema from the app belongs to the Server Actions unit later in the course. The queries stay stubbed, so the inspector’s list still renders empty rows over your seeded data until the next lesson; the banner is your proof for now.
SEED reproduces a sampled invoice’s number, and exactly one invoice carries it (determinism).1..n by position with no gaps.dueAt falls exactly 30 days after its issuedAt.Coding time
Section titled “Coding time”Write the body of runSeed in scripts/seed.ts against the brief above and the Lesson 4 tests. When you think it works, run pnpm db:seed twice and compare the banner across both runs — the counts and a sampled invoice number should be identical. Do that comparison yourself before opening the walkthrough; the second-run check is the habit this lesson exists to build, and it only lands if you watch it hold once unaided.
Reference solution and walkthrough
scripts/seed.ts — imports, constants, and the data tables
Section titled “scripts/seed.ts — imports, constants, and the data tables”The file opens with the imports and a block of constants that fix the dataset’s shape up front. reset is the only thing pulled from drizzle-seed; everything else is the schema’s tables and inferred insert types, the dbUnpooled client, and the validated env. The constants below the imports are the levers you would actually turn to reshape the data: how many customers, where the invoice dates start, and the status weights. Pulling them out as named constants keeps the insert code below readable and the tunable numbers in one place.
import { pathToFileURL } from 'node:url';
import { reset } from 'drizzle-seed';
import { dbUnpooled } from '@/db/index';import type { NewCustomer, NewInvoice, NewInvoiceLine, NewOrgMember,} from '@/db/schema';import * as schema from '@/db/schema';import { env } from '@/env';
type InvoiceStatus = (typeof schema.invoiceStatus.enumValues)[number];
const DAY_MS = 24 * 60 * 60 * 1000;const CUSTOMER_COUNT = 40;const SEED_EPOCH = Date.UTC(2025, 0, 1);
const ORG_SEEDS = [ { name: 'Acme Corporation', slug: 'acme' }, { name: 'Globex Industries', slug: 'globex' },] as const;
const USER_SEEDS = [ { name: 'Ada Lovelace', email: 'ada@acme.test' }, { name: 'Grace Hopper', email: 'grace@acme.test' }, { name: 'Alan Turing', email: 'alan@globex.test' }, { name: 'Edsger Dijkstra', email: 'edsger@globex.test' },] as const;
const STATUS_BANDS: readonly { status: InvoiceStatus; weight: number }[] = [ { status: 'paid', weight: 50 }, { status: 'sent', weight: 25 }, { status: 'draft', weight: 15 }, { status: 'overdue', weight: 10 },];A couple of details are doing quiet work. InvoiceStatus is derived from schema.invoiceStatus.enumValues rather than re-typed by hand, so the seed’s notion of a valid status can never drift from the enum in the schema — the same source-of-truth discipline the schema lesson installed. And STATUS_BANDS is what produces a realistic mix instead of an even one: paid is weighted far heavier than overdue, so the seeded data looks like a real invoicing app where most invoices eventually get paid and only a few go overdue. That is requirement 9, expressed as data rather than logic.
The pseudo-random generator
Section titled “The pseudo-random generator”All randomness in the seed flows through one small generator so the whole run is reproducible from the single seed number. It is a linear-congruential generator — a classic one-line recurrence that turns a seed into a repeatable stream of numbers — wrapped in a handful of typed helpers the inserts below reach for. Walk through it part by part.
const createPrng = (seed: number) => { let state = seed >>> 0 || 1; const nextFloat = () => { state = (state * 1103515245 + 12345) & 0x7fffffff; return state / 0x80000000; }; return { int: (min: number, max: number) => min + Math.floor(nextFloat() * (max - min + 1)), money: (min: number, max: number) => (min + nextFloat() * (max - min)).toFixed(2), pick: <T>(items: readonly T[]): T => { const item = items[Math.floor(nextFloat() * items.length)]; if (item === undefined) { throw new Error('seed: cannot pick from an empty list'); } return item; }, weightedStatus: (): InvoiceStatus => { const total = STATUS_BANDS.reduce((sum, band) => sum + band.weight, 0); let roll = nextFloat() * total; let chosen: InvoiceStatus = 'paid'; for (const band of STATUS_BANDS) { roll -= band.weight; if (roll < 0) { chosen = band.status; break; } } return chosen; }, };};The seed becomes the generator’s starting state. >>> 0 coerces it to an unsigned 32-bit integer, and || 1 guards against a seed of 0, which would lock the recurrence at zero forever. From here on, the same seed always replays the same stream.
const createPrng = (seed: number) => { let state = seed >>> 0 || 1; const nextFloat = () => { state = (state * 1103515245 + 12345) & 0x7fffffff; return state / 0x80000000; }; return { int: (min: number, max: number) => min + Math.floor(nextFloat() * (max - min + 1)), money: (min: number, max: number) => (min + nextFloat() * (max - min)).toFixed(2), pick: <T>(items: readonly T[]): T => { const item = items[Math.floor(nextFloat() * items.length)]; if (item === undefined) { throw new Error('seed: cannot pick from an empty list'); } return item; }, weightedStatus: (): InvoiceStatus => { const total = STATUS_BANDS.reduce((sum, band) => sum + band.weight, 0); let roll = nextFloat() * total; let chosen: InvoiceStatus = 'paid'; for (const band of STATUS_BANDS) { roll -= band.weight; if (roll < 0) { chosen = band.status; break; } } return chosen; }, };};The recurrence itself: each call advances state by the textbook LCG formula and returns a float in [0, 1). This is the single source of every random decision below — nothing in the seed reaches for Math.random.
const createPrng = (seed: number) => { let state = seed >>> 0 || 1; const nextFloat = () => { state = (state * 1103515245 + 12345) & 0x7fffffff; return state / 0x80000000; }; return { int: (min: number, max: number) => min + Math.floor(nextFloat() * (max - min + 1)), money: (min: number, max: number) => (min + nextFloat() * (max - min)).toFixed(2), pick: <T>(items: readonly T[]): T => { const item = items[Math.floor(nextFloat() * items.length)]; if (item === undefined) { throw new Error('seed: cannot pick from an empty list'); } return item; }, weightedStatus: (): InvoiceStatus => { const total = STATUS_BANDS.reduce((sum, band) => sum + band.weight, 0); let roll = nextFloat() * total; let chosen: InvoiceStatus = 'paid'; for (const band of STATUS_BANDS) { roll -= band.weight; if (roll < 0) { chosen = band.status; break; } } return chosen; }, };};int(min, max) returns an integer in the inclusive range — used for per-customer invoice counts (12..18) and per-invoice line counts (2..4).
const createPrng = (seed: number) => { let state = seed >>> 0 || 1; const nextFloat = () => { state = (state * 1103515245 + 12345) & 0x7fffffff; return state / 0x80000000; }; return { int: (min: number, max: number) => min + Math.floor(nextFloat() * (max - min + 1)), money: (min: number, max: number) => (min + nextFloat() * (max - min)).toFixed(2), pick: <T>(items: readonly T[]): T => { const item = items[Math.floor(nextFloat() * items.length)]; if (item === undefined) { throw new Error('seed: cannot pick from an empty list'); } return item; }, weightedStatus: (): InvoiceStatus => { const total = STATUS_BANDS.reduce((sum, band) => sum + band.weight, 0); let roll = nextFloat() * total; let chosen: InvoiceStatus = 'paid'; for (const band of STATUS_BANDS) { roll -= band.weight; if (roll < 0) { chosen = band.status; break; } } return chosen; }, };};money returns a .toFixed(2) string, not a number, because Drizzle maps a numeric column to a JS string — passing a float would be a type error at the insert. This is the seam where the money-as-decimal decision from the schema shows up in the seed.
const createPrng = (seed: number) => { let state = seed >>> 0 || 1; const nextFloat = () => { state = (state * 1103515245 + 12345) & 0x7fffffff; return state / 0x80000000; }; return { int: (min: number, max: number) => min + Math.floor(nextFloat() * (max - min + 1)), money: (min: number, max: number) => (min + nextFloat() * (max - min)).toFixed(2), pick: <T>(items: readonly T[]): T => { const item = items[Math.floor(nextFloat() * items.length)]; if (item === undefined) { throw new Error('seed: cannot pick from an empty list'); } return item; }, weightedStatus: (): InvoiceStatus => { const total = STATUS_BANDS.reduce((sum, band) => sum + band.weight, 0); let roll = nextFloat() * total; let chosen: InvoiceStatus = 'paid'; for (const band of STATUS_BANDS) { roll -= band.weight; if (roll < 0) { chosen = band.status; break; } } return chosen; }, };};pick chooses one element from a list. The undefined guard is there to satisfy noUncheckedIndexedAccess — an index into an array is typed as possibly-undefined under that flag — and it doubles as a real safety net against picking from an empty list.
const createPrng = (seed: number) => { let state = seed >>> 0 || 1; const nextFloat = () => { state = (state * 1103515245 + 12345) & 0x7fffffff; return state / 0x80000000; }; return { int: (min: number, max: number) => min + Math.floor(nextFloat() * (max - min + 1)), money: (min: number, max: number) => (min + nextFloat() * (max - min)).toFixed(2), pick: <T>(items: readonly T[]): T => { const item = items[Math.floor(nextFloat() * items.length)]; if (item === undefined) { throw new Error('seed: cannot pick from an empty list'); } return item; }, weightedStatus: (): InvoiceStatus => { const total = STATUS_BANDS.reduce((sum, band) => sum + band.weight, 0); let roll = nextFloat() * total; let chosen: InvoiceStatus = 'paid'; for (const band of STATUS_BANDS) { roll -= band.weight; if (roll < 0) { chosen = band.status; break; } } return chosen; }, };};weightedStatus rolls against the cumulative STATUS_BANDS weights, so paid comes up far more often than overdue. This is what gives the seeded invoices a believable status distribution instead of an even split.
The fixed seed number passed into this factory is the determinism contract in one place. The concept — a fixed-seed generator making a seed reproducible — is the same one the seeding lesson of the previous chapter walked through; here you are applying it, so the rule is what matters: bump SEED and the dataset reshapes on purpose; touch the generator or the insert order without bumping it and you have quietly broken reproducibility.
Reset, then insert parents first
Section titled “Reset, then insert parents first”Now the body of runSeed. It opens by building the generator from env.SEED and clearing the database, and only then inserts. The order of everything that follows is dictated by the foreign keys: a row can only reference a parent that already exists, so organizations and users go in first.
export const runSeed = async (): Promise<void> => { const prng = createPrng(env.SEED);
await reset(dbUnpooled, schema);
const [acme, globex] = await dbUnpooled .insert(schema.organizations) .values(ORG_SEEDS.map((org) => ({ name: org.name, slug: org.slug }))) .returning({ id: schema.organizations.id }); if (!acme || !globex) { throw new Error('seed: expected two organizations'); }
const [ada, grace, alan, edsger] = await dbUnpooled .insert(schema.users) .values(USER_SEEDS.map((user) => ({ name: user.name, email: user.email }))) .returning({ id: schema.users.id }); if (!ada || !grace || !alan || !edsger) { throw new Error('seed: expected four users'); }reset(dbUnpooled, schema) is the idempotency move, called once before any insert: it truncates every table so a re-run starts from the same clean slate, which is why running the seed twice yields identical counts. The mechanics of TRUNCATE ... CASCADE were covered in the seeding lesson of the previous chapter, so they are not re-derived here.
The two inserts capture their generated ids back through .returning({ id }), because the children need them. You do not hand-generate a UUID anywhere — the schema’s uuidv7() default produces the primary key, and .returning() hands it straight back. The guards (if (!acme || !globex)) exist because .returning() is typed as a possibly-empty array; the checks both narrow the type for the code below and fail loudly if an insert somehow came back short, which beats a confusing undefined surfacing three inserts later.
Memberships: Ada in both orgs
Section titled “Memberships: Ada in both orgs”The five membership rows are written out by hand rather than generated, because the central invariant — one user in two organizations — is a specific arrangement, not a random one. Ada is inserted twice, once per org; the other three users each sit in a single org.
// Ada belongs to BOTH orgs (overlapping membership); the rest split per org. const orgMemberRows: NewOrgMember[] = [ { organizationId: acme.id, userId: ada.id, role: 'owner' }, { organizationId: globex.id, userId: ada.id, role: 'admin' }, { organizationId: acme.id, userId: grace.id, role: 'member' }, { organizationId: globex.id, userId: alan.id, role: 'owner' }, { organizationId: globex.id, userId: edsger.id, role: 'member' }, ]; await dbUnpooled.insert(schema.orgMembers).values(orgMemberRows);
const acmeUserIds = [ada.id, grace.id]; const globexUserIds = [ada.id, alan.id, edsger.id];That Ada-in-both-orgs row is exactly why every tenant read in the next two lessons scopes by organizationId and never by user — a query that filtered by “Ada’s data” would cross the tenant boundary, because Ada has data in two tenants. The seed is what makes that failure mode concrete enough to test against. Below the insert, acmeUserIds and globexUserIds are derived from the same memberships so that when an invoice picks a createdBy, it can only pick a user who actually belongs to that invoice’s org.
Customers: forty, alternating orgs
Section titled “Customers: forty, alternating orgs”Forty customers are generated in a loop, alternating organization by index parity — even indices to Acme, odd to Globex — so both orgs end up with a share and neither is left empty.
const customerRows: NewCustomer[] = Array.from( { length: CUSTOMER_COUNT }, (_, i) => { const org = i % 2 === 0 ? acme : globex; const slug = i % 2 === 0 ? 'acme' : 'globex'; return { organizationId: org.id, name: `Customer ${i + 1}`, email: `customer${i + 1}@${slug}.test`, }; }, ); const customers = await dbUnpooled .insert(schema.customers) .values(customerRows) .returning({ id: schema.customers.id, organizationId: schema.customers.organizationId, });The email carries the org slug for a reason. The schema scopes the customer-email uniqueness to (organizationId, email), not to email alone, so customer1@acme.test and customer1@globex.test coexist fine but a collision within one org would be rejected — namespacing the email by slug keeps every address unique within its tenant and respects that constraint. The .returning() here pulls back both the id and the organizationId, because the invoice loop needs both: the id to reference, the org to keep the invoice in the right tenant and pick a valid author.
The invoice loop
Section titled “The invoice loop”Each customer gets twelve to eighteen invoices. The loop walks the returned customers, draws a per-customer count from the generator, and pushes a fully-formed invoice row for each — with a monotonically increasing invoice number, a weighted status, a money total, and dates anchored to a fixed epoch.
let invoiceNumber = 0; const invoiceRows: NewInvoice[] = []; const lineCounts: number[] = []; for (const customer of customers) { const userIds = customer.organizationId === acme.id ? acmeUserIds : globexUserIds; const invoiceCount = prng.int(12, 18); for (let i = 0; i < invoiceCount; i += 1) { invoiceNumber += 1; const issuedAt = new Date(SEED_EPOCH + prng.int(0, 364) * DAY_MS); invoiceRows.push({ organizationId: customer.organizationId, customerId: customer.id, createdBy: prng.pick(userIds), number: `INV-${String(invoiceNumber).padStart(5, '0')}`, status: prng.weightedStatus(), total: prng.money(50, 5000), currency: 'USD', issuedAt, dueAt: new Date(issuedAt.getTime() + 30 * DAY_MS), }); lineCounts.push(prng.int(2, 4)); } }
const invoices = await dbUnpooled .insert(schema.invoices) .values(invoiceRows) .returning({ id: schema.invoices.id });There is a lot riding on small details here. The invoiceNumber counter increments across the whole run and is formatted INV-##### with padStart(5, '0'), which makes every number unique within its org and gives the determinism test a stable target to sample. createdBy is picked only from the current org’s userIds, so an invoice’s author is always one of its own org’s members. issuedAt is drawn as an offset of zero to 364 days off the fixed SEED_EPOCH — anchoring to a constant rather than Date.now() is part of what keeps the dataset reproducible — and dueAt is computed as exactly thirty days after that issue date, which is requirement 8.
The one line that looks out of place is lineCounts.push(...) inside the invoice loop. The number of line items each invoice gets is drawn here, while building the invoices, and stashed in a parallel array — even though the lines are not inserted until after the invoices come back with their ids. Drawing it once and reusing it matters for determinism: if the line loop re-rolled the count from the generator, it would consume the stream in a different order and shift every random value downstream. Draw once, reuse, and the run stays reproducible.
Line items, by position
Section titled “Line items, by position”With the invoices inserted and their ids returned, the line items are built with a flatMap — each invoice expands into its two-to-four lines, numbered by position starting at one.
const lineRows: NewInvoiceLine[] = invoices.flatMap((invoice, index) => { const lineCount = lineCounts[index]; if (lineCount === undefined) { return []; } return Array.from({ length: lineCount }, (_, i) => { const position = i + 1; return { invoiceId: invoice.id, description: `Line item ${position}`, quantity: prng.money(1, 10), unitPrice: prng.money(20, 500), position, }; }); }); await dbUnpooled.insert(schema.invoiceLines).values(lineRows);};flatMap is the right shape because each invoice produces a variable-length array of lines and they all need to flatten into one list to insert in a single round trip. The index lines up with the lineCounts array filled during the invoice loop, so each invoice gets the exact count drawn for it. position runs 1..n per invoice, which both respects the (invoiceId, position) uniqueness constraint and is what requirement 7 checks — every invoice’s lines numbered from one with no gaps. The lineCount === undefined guard is there only to satisfy noUncheckedIndexedAccess on the array access; in practice the arrays are the same length, so it never returns the empty array.
Running it from the command line
Section titled “Running it from the command line”The last block lets the file run as a script while still exporting runSeed for the tests to import. The guard is more careful than it first looks.
// Run as a CLI: pathToFileURL normalizes the entry path so the guard fires even// when the project path contains a space (import.meta.url percent-encodes it// while process.argv[1] keeps it literal — a naive compare would silently skip).const entry = process.argv[1];if (entry && import.meta.url === pathToFileURL(entry).href) { runSeed() .then(() => process.exit(0)) .catch((e) => { console.error(e); process.exit(1); });}The usual idiom for “am I being run directly?” compares import.meta.url against the entry path, but those two are not in the same format: import.meta.url is a file:// URL that percent-encodes a space as %20, while process.argv[1] is a plain path that keeps the space literal. On a machine where the project lives under a path with a space in it, a naive string compare never matches and the script silently does nothing. Running process.argv[1] through pathToFileURL normalizes both sides to the same URL form, so the guard fires regardless. On success the script exits 0; on a thrown error it logs and exits 1, so a broken seed fails the command rather than passing quietly.
Why this clears a hundred
Section titled “Why this clears a hundred”The invoice count is not a magic number you tune — it falls out of the loop. Forty customers, each with twelve to eighteen invoices, lands somewhere between 480 and 720 invoices total, comfortably past the hundred the data layer needs to make pagination and plans worth exercising. You get there by shaping the inputs, not by hard-coding the output.
Official reference for the reset() call this seed leans on for idempotency, with the per-dialect truncation behavior.
The multi-row insert and selective .returning() pattern used to capture generated ids for the child rows.
Moment of truth
Section titled “Moment of truth”The test suite drives your own runSeed and then reads the database back through a separate connection to confirm what actually landed — counts, the both-orgs membership, the line-item position runs, and the twice-run idempotency and determinism checks. It seeds itself inside the suite (it calls runSeed twice in setup), so you do not need to run pnpm db:seed first, but it does need the live local Postgres up and the schema migrated. If you have torn the database down, bring it back with docker compose up -d and pnpm db:migrate before running the tests.
pnpm test:lesson 4Expect all seven requirement groups to pass:
✓ tests/lessons/Lesson 4.test.ts (13) ✓ seeds exactly two organizations and four users (req 1) (2) ✓ inserts exactly two organizations ✓ inserts exactly four users ✓ models overlapping membership: five members, one user in both orgs (req 2) (2) ✓ inserts exactly five org_members ✓ has exactly one user that belongs to both organizations ✓ seeds forty customers split across both orgs (req 3) (2) ✓ inserts exactly forty customers ✓ places customers in both organizations, none orphaned ✓ seeds at least one hundred invoices, all tenant-owned (req 4) (2) ✓ inserts one hundred or more invoices ✓ attaches every invoice to one of the two seeded organizations ✓ is idempotent: a second run leaves every row count unchanged (req 5) (1) ✓ matches all six table counts across two runs ✓ is deterministic: same SEED reproduces a sampled invoice’s data (req 6) (2) ✓ reproduces the sampled invoice number across two same-SEED runs ✓ reproduces exactly one invoice carrying the sampled number ✓ numbers invoice lines 1..n per invoice with two to four lines each (req 7) (2) ✓ gives every invoice between two and four line items ✓ numbers each invoice’s lines 1..n by position with no gaps
Test Files 1 passed (1) Tests 13 passed (13)The suite covers requirements 1 through 7 — the counts, the overlapping member, the tenant ownership, idempotency, determinism, and the line-item position runs. The determinism check is worth understanding precisely: an invoice’s primary key comes from the column’s uuidv7() default, so it is freshly generated on every insert and is not byte-identical across two seed runs — that is by design, not a bug. What the fixed seed guarantees, and what the test asserts, is that the PRNG-driven business data is reproduced: the sampled invoice’s number is the same run to run, and exactly one invoice carries it. Confirm the rest by hand, ticking each as you go:
pnpm db:seed, then read the inspector banner: organizations: 2, users: 4, org_members: 5, customers: 40, and invoices at 100 or more.pnpm db:studio and eyeball org_members — Ada Lovelace appears in both organizations (requirement 2, seen directly).status spread leans heavily toward paid with only a few overdue, not an even split (requirement 9).1, 2, 3... by position (requirement 7, seen directly).dueAt is exactly 30 days after its issuedAt (requirement 8).pnpm db:seed a second time and confirm the banner counts are identical to the first run, and a sampled invoice’s number is unchanged.