Seedfast

Seedfast

Writing a Scope: Four Runs on One Schema

The scope examples on this page were all run against the same eight tables, held in two schemas named crm and billing. Keys, NOT NULL, UNIQUE and a few CHECK lists are the whole of the DDL, which leaves the shape of the data to the sentence you write.

CREATE SCHEMA crm;
CREATE SCHEMA billing;

CREATE TABLE crm.customers (
  id          bigserial PRIMARY KEY,
  name        text NOT NULL UNIQUE,
  domain      text NOT NULL UNIQUE,
  segment     text NOT NULL CHECK (segment IN ('startup', 'smb', 'enterprise')),
  created_at  timestamptz NOT NULL
);

CREATE TABLE crm.contacts (
  id             bigserial PRIMARY KEY,
  customer_id    bigint NOT NULL REFERENCES crm.customers (id),
  full_name      text NOT NULL,
  email          text NOT NULL UNIQUE,
  phone          text,
  job_title      text,
  date_of_birth  date,
  created_at     timestamptz NOT NULL
);

CREATE TABLE crm.addresses (
  id            bigserial PRIMARY KEY,
  customer_id   bigint NOT NULL REFERENCES crm.customers (id),
  kind          text NOT NULL CHECK (kind IN ('billing', 'shipping')),
  street_line   text NOT NULL,
  city          text NOT NULL,
  region        text,
  postal_code   text NOT NULL,
  country_code  char(2) NOT NULL
);

CREATE TABLE crm.support_tickets (
  id           bigserial PRIMARY KEY,
  customer_id  bigint NOT NULL REFERENCES crm.customers (id),
  subject      text NOT NULL,
  priority     text NOT NULL CHECK (priority IN ('low', 'normal', 'high', 'urgent')),
  status       text NOT NULL CHECK (status IN ('open', 'pending', 'resolved', 'closed')),
  opened_at    timestamptz NOT NULL
);

CREATE TABLE crm.ticket_messages (
  id           bigserial PRIMARY KEY,
  ticket_id    bigint NOT NULL REFERENCES crm.support_tickets (id),
  author_kind  text NOT NULL CHECK (author_kind IN ('customer', 'agent')),
  body         text NOT NULL,
  sent_at      timestamptz NOT NULL
);

CREATE TABLE billing.plans (
  id             bigserial PRIMARY KEY,
  code           text NOT NULL UNIQUE,
  name           text NOT NULL,
  monthly_price  numeric(10, 2) NOT NULL CHECK (monthly_price >= 0),
  currency       char(3) NOT NULL,
  seat_limit     integer CHECK (seat_limit > 0)
);

CREATE TABLE billing.subscriptions (
  id           bigserial PRIMARY KEY,
  customer_id  bigint NOT NULL REFERENCES crm.customers (id),
  plan_id      bigint NOT NULL REFERENCES billing.plans (id),
  status       text NOT NULL CHECK (status IN ('trialing', 'active', 'past_due', 'canceled')),
  seats        integer NOT NULL CHECK (seats > 0),
  started_at   date NOT NULL
);

CREATE TABLE billing.invoices (
  id               bigserial PRIMARY KEY,
  subscription_id  bigint NOT NULL REFERENCES billing.subscriptions (id),
  number           text NOT NULL UNIQUE,
  status           text NOT NULL CHECK (status IN ('paid', 'overdue', 'disputed')),
  issued_at        date NOT NULL,
  due_at           date NOT NULL,
  total_amount     numeric(12, 2) NOT NULL CHECK (total_amount >= 0),
  currency         char(3) NOT NULL
);

Each run below started on an empty database with only that file applied, using the published CLI, version 2.6.3, and SQL checked what came out. If you have not seeded yet, seed your database covers install and connection.

Running with no scope

Type the command with nothing after it and Seedfast reads the schema and proposes a scope of its own. The session below is real, trimmed of spinner frames and account lines.

seedfast seed
Seeding scope

  • billing.invoices       50 records
  • billing.plans          15 records
  • billing.subscriptions  150 records
  • crm.addresses          50 records
  • crm.contacts           50 records
  • crm.customers          15 records
  • crm.support_tickets    50 records
  • crm.ticket_messages    50 records

  Total: 430 records

Do you agree with this scope?

> Yes
  No, seed 500 records per table

↑↓ to navigate · Enter to select · Ctrl+C to cancel seeding

Read the ratios before you press Enter. Fifteen customers carrying 150 subscriptions works out at ten each, and a price book of 15 plans for those same 15 customers comes close to one plan per account, which is the shape a schema suggests when nobody has yet said what the data is for. The second menu item exists because you are expected to move those numbers.

Choosing No opens a text field. Whatever you type comes back on screen as your feedback, and the plan is rebuilt around it.

> No, I need at least 500 invoices, keep everything else the same

Your feedback: I need at least 500 invoices, keep everything else the same

Seeding scope

  • billing.invoices       500 records
  • billing.plans          15 records
  • billing.subscriptions  150 records
  • crm.addresses          50 records
  • crm.contacts           50 records
  • crm.customers          15 records
  • crm.support_tickets    50 records
  • crm.ticket_messages    50 records

  Total: 880 records

Do you agree with this scope?

> Yes
  No, social media with posts and followers

One table moved, the other seven stayed put, and the total rose to 880. Approving from there ran the seed to the end.

✓ Seeded 8 tables (880 rows) in 1m 20s

One query counts every table here, and it is the block behind each set of counts below.

SELECT 'crm.customers' AS t, count(*) FROM crm.customers
UNION ALL SELECT 'crm.contacts', count(*) FROM crm.contacts
UNION ALL SELECT 'crm.addresses', count(*) FROM crm.addresses
UNION ALL SELECT 'crm.support_tickets', count(*) FROM crm.support_tickets
UNION ALL SELECT 'crm.ticket_messages', count(*) FROM crm.ticket_messages
UNION ALL SELECT 'billing.plans', count(*) FROM billing.plans
UNION ALL SELECT 'billing.subscriptions', count(*) FROM billing.subscriptions
UNION ALL SELECT 'billing.invoices', count(*) FROM billing.invoices;
           t           | count
-----------------------+-------
 crm.customers         |    15
 crm.contacts          |    50
 crm.addresses         |    50
 crm.support_tickets   |    50
 crm.ticket_messages   |    50
 billing.plans         |    15
 billing.subscriptions |   150
 billing.invoices      |   500

Each of the eight numbers matches the approved plan. Scoping covers the rest of the session, and the runs below pass the scope up front.

What one sentence buys

seedfast seed --scope "seed 50 customers with contacts and invoices"

With --scope there is no menu, which is the point of it in a script. The --output plain log is trimmed.

[2026-09-06T17:16:03+02:00] INFO: Tables in scope: crm.customers, crm.contacts, billing.plans, billing.subscriptions, billing.invoices
[2026-09-06T17:16:03+02:00] INFO: Planned: 205 records across 5 tables
[2026-09-06T17:16:03+02:00] INFO: Auto-approving plan (scope provided)
[2026-09-06T17:16:30+02:00] INFO: Table crm.customers completed: 50 rows in 14.677s
[2026-09-06T17:16:44+02:00] INFO: Table crm.contacts completed: 50 rows in 13.619s
[2026-09-06T17:16:52+02:00] INFO: Seeding completed: 5/5 tables succeeded, 205 rows, 84.60s

Three tables were named and five were filled, since billing.invoices carries a NOT NULL reference to billing.subscriptions, which carries one to billing.plans.

           t           | count
-----------------------+-------
 crm.customers         |    50
 crm.contacts          |    50
 crm.addresses         |     0
 crm.support_tickets   |     0
 crm.ticket_messages   |     0
 billing.plans         |     5
 billing.subscriptions |    50
 billing.invoices      |    50

The number the sentence states, 50 customers, held in all three runs. Counts it does not fix are left to the run, which is the gap the longer scopes below close by naming a figure per table.

A sentence with character

seedfast seed --scope "seed 50 enterprise customers based in Germany, each with 2 to 5 active subscriptions on the growth plan, invoices over the last 6 months, mostly paid"

That sentence adds a country, a plan name, a date window and a status skew. It ran three times, with the following identical in all three.

  • 50 customers, enterprise on 50 of 50 rows
  • 50 addresses, country code DE and a five-digit postal code on 50 of 50
  • one plan row named Growth, priced in EUR
  • 175 subscriptions, 3 for 25 customers and 4 for the other 25
  • 1,050 invoices, exactly 6 per subscription, 1,050 of 1,050 in EUR
  • invoices inside a six-month window ending near the run date (2026-03-11 to 2026-09-04, 2026-03-01 to 2026-08-31, 2026-03-16 to 2026-09-06)
  • "mostly paid" as 88.6%, 87.6% and 88.6% paid

"2 to 5 active subscriptions" came back as 3 or 4 for each of the 50 customers, in each of the three runs. Read a range as one or two values near its middle, and state the number outright when the spread is what a test asserts on.

The contact and address rows from run 1 read like this, three rows each, with the contacts trimmed to the name and title columns.

   full_name    |    job_title
----------------+-----------------
 Anna Schmidt   | Geschäftsführer
 Lukas Fischer  | Vertriebsleiter
 Felix Schäfer  | Leiter Einkauf

     street_line     |       city        |       region        | postal_code | country_code
---------------------+-------------------+---------------------+-------------+--------------
 Friedrichstraße 18  | Berlin            | Berlin              | 10117       | DE
 Königsallee 47      | Hamburg           | Hamburg             | 20095       | DE
 Leopoldstraße 63    | München           | Bayern              | 80333       | DE

An eight-sentence brief

Seed the crm and billing schemas for a B2B analytics product sold to mid-sized European companies. 120 customers across Germany, Austria and the Netherlands, mostly smb, with a few enterprise accounts and a handful of startups. Company names read like real companies in those countries and each customer has its own email domain. Each customer has 2 to 4 contacts with emails on the company domain; job titles are heads of data, analytics leads and engineering managers; leave dates of birth empty. Three plans priced in EUR: Starter at 99, Growth at 399 and Scale at 1,490 per month. Every customer has exactly one subscription: about 80% active, 10% past due, 10% canceled. Invoices are issued monthly from January 2026 to August 2026, numbered INV-2026-0001 upward, 70% paid, 20% overdue, 10% disputed. Support tickets are mostly normal priority and were opened in the last 60 days; subjects read like real questions about dashboards, CSV exports and SSO. Each ticket has exactly 2 messages: a customer question followed by an agent reply.

Run it three times and the figures the brief states come back exact in each run.

  • 120 customers, 360 contacts, 120 addresses, 3 plans, 120 subscriptions, 960 invoices
  • exactly 1 subscription per customer and 8 invoices per subscription
  • subscription statuses 96 active, 12 past due, 12 canceled, the 80 / 10 / 10 asked for
  • invoice statuses 672 paid, 192 overdue, 96 disputed, the 70 / 20 / 10 asked for
  • plans at 99.00, 399.00 and 1490.00 EUR
  • invoice numbers INV-2026-0001 through INV-2026-0960, 960 of 960 matching ^INV-2026-\d{4}$
  • issued dates monthly, 2026-01-01 to 2026-08-01
  • addresses 40 DE, 40 AT, 40 NL
  • contact emails on the company domain, 360 of 360
  • no contact carrying a date of birth, as asked
  • exactly 2 messages per ticket, customer first and agent second
  • tickets mostly normal priority, 48 of 60 in run 1

The brief fixes where the companies are based and leaves the people to the run, so put the locale for names, phone format and street lines in a sentence of its own. Localized test data has the scopes for that.

Asking for one table

seedfast seed --scope "seed only the ticket_messages table, 200 rows"

A row in crm.ticket_messages needs a ticket, and a ticket needs a customer, so the plan came back with three tables in scope and 225 rows.

[2026-09-06T18:06:45+02:00] INFO: Tables in scope: crm.ticket_messages, crm.support_tickets, crm.customers
[2026-09-06T18:06:45+02:00] INFO: Planned: 225 records across 3 tables
[2026-09-06T18:07:04+02:00] INFO: Table crm.customers completed: 10 rows in 7.244s
[2026-09-06T18:07:13+02:00] INFO: Table crm.support_tickets completed: 15 rows in 8.881s
[2026-09-06T18:07:22+02:00] INFO: Table crm.ticket_messages completed: 200 rows in 8.353s
[2026-09-06T18:07:22+02:00] INFO: Seeding completed: 3/3 tables succeeded, 225 rows, 82.94s
           t           | count
-----------------------+-------
 crm.customers         |    10
 crm.contacts          |     0
 crm.addresses         |     0
 crm.support_tickets   |    15
 crm.ticket_messages   |   200
 billing.plans         |     0
 billing.subscriptions |     0
 billing.invoices      |     0

The 200 messages landed exactly, and the five tables outside that chain stayed empty. Their two parents were sized by the run, 10 customers and 15 tickets, since the sentence puts a number on the messages alone. This scope ran once, so read those parent numbers as a single observation.

Something vaguer gets a plan of its own, and that scope was run once as well. seed the stuff planned 430 records across 8 tables, the same total the session at the top of this page proposed, and wrote them. With --scope the plan is approved from the sentence itself, so read the two log lines naming the tables in scope and the planned total before a pipeline trusts the seed.

What these scope examples say about writing one

Six things to take into a scope of your own.

  • Name every table you care about. Tables a scope names hold the counts it gives them, and tables it leaves out are sized by the run.
  • Put a number on anything a test reads. The single figure in "seed 50 customers with contacts and invoices" held at 50 across the three runs.
  • State the number when the spread matters. A range lands as one or two values near its middle, so "2 to 4 contacts" produced exactly 3 for each of the 120 customers.
  • Give the locale for people its own sentence, apart from where the company is based. Naming Germany put DE and a five-digit postal code on 50 of 50 addresses.
  • State shares as percentages. All three runs of the brief split invoice status 70 / 20 / 10, the figures it named.
  • Say what money looks like. The three prices written into the brief came back to the cent at 99.00, 399.00 and 1490.00 EUR, and amounts nobody writes down are chosen for you.

Take one table next and hold it to an exact number. Exact row counts and ratios covers two heavily numbered scopes over three runs each, with the queries for counts per table, children per parent and named status shares.