seedplan

package
v1.0.12 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 31, 2026 License: Apache-2.0 Imports: 24 Imported by: 0

Documentation

Overview

Package seeddata materializes deterministic, FK-coherent demo data directly into a development database — at RUNTIME, never as a persisted seed file in the user's project.

Why runtime, not a file

The vertical-scaffolding law forbids forge from writing new forge-owned (regenerated) files into the user's project space. An earlier design persisted db/seeds/gen/*.sql; that is disqualified. Instead this package introspects the APPLIED schema (schemadef.ApplyAndIntrospect against the real ephemeral-postgres shadow, including the real introspected foreign keys), synthesizes deterministic per-cell values, builds a foreign-key topological INSERT order, and executes the INSERTs against the target dev database. No .sql seed file touches the user project.

Who may call this, and what keeps it out of production

This package SHIPS. It lives in forge/pkg so that a project's tests can seed a foreign-key spine by calling SeedGraph — an ordinary Go function against the test's own database — rather than shelling the forge binary or importing INSERT SQL frozen at the last `forge generate`.

It used to live under internal/, where the module boundary made it unreachable from any generated app, and a flow test that needed a seeded spine got a GENERATED file of pre-rendered INSERTs instead: SQL that was correct only until the next migration, and that grew a spec per table. The unreachability was real but it was not the thing keeping seeds out of production, and paying for it in frozen generated SQL was the wrong trade.

What keeps seeds out of production is that nothing in a production system calls a seeder. The migrate path — the one component that runs against non-dev databases — has no seed code path at all, which is asserted, not assumed (TestGeneratedMigrateTemplateHasNoSeedPath). The `forge db seed apply`/`reset` CLI additionally refuses any non-dev environment with no override flag (see internal/cli). A server binary seeds nothing for the same reason it sends no mail: no call site.

Nothing here runs at application startup, and nothing here is wired into a generated server. Every entry point takes the database it should act on as an argument, so the blast radius of a call is exactly the handle passed in.

Planning and applying

A PLAN is pure computation: given the schema model and a Config, it decides every cell of every row, and it touches nothing. APPLYING executes that plan's INSERTs against a database. They are separate functions with separate inputs — BuildPlan/BuildPlanFromDB/BuildLivePlan produce a *Plan, Apply/Reset consume one — and a caller that only wants to look uses Plan.Render or Plan.Statements and never opens a transaction.

They are not separate PACKAGES, because the boundary they would draw is not the one that matters. Apply is a loop over the plan's own table order, reading the statement each table plan renders and the diamond refusal the plan computed; splitting it out would mean exporting that internal structure purely to hand it across a directory line, turning a private detail into a public API to express a distinction the function signatures already express. The rule is worth more than the directory: PLANNING NEVER TOUCHES A DATABASE, APPLYING NEVER BOOTS ONE.

Where the schema comes from is the other axis, and it is the one that decides which entry a caller wants:

  • BuildPlan — caller already has the []schemadef.Table.
  • BuildPlanFromDB — caller has a MIGRATED database; the schema is read from it. No shadow, no migrations directory. This is what tests use.
  • BuildLivePlan — caller has migration FILES; they are applied to a shadow server (whose address is a parameter, not something this package resolves) and introspected. This is what the CLI uses.

Determinism

Every cell value is a pure function of (salt, table, column, rowIndex) plus the column's DECLARED type and constraints — stateless per cell, no sequential PRNG. The DECLARED type is not a detail of that list: postgres parses it before it evaluates anything else, so a uuid column gets a real (deterministic, self-evidently invented) uuid and a timestamp key member gets a timestamp, rather than the one shape a key used to be assumed to have. Consequences: identical (schema, config) yields byte-identical INSERTs; adding a column never reshuffles other columns' values (each column's value depends only on its own name); adding rows never changes existing rows. Foreign-key values are derived from the referenced table's own primary-key function, so every reference resolves by construction.

Constraints that span two columns

Determinism above is per-CELL, so a rule ABOUT TWO CELLS OF ONE ROW is invisible to it: drawing each column independently makes such a rule true only by coincidence, and postgres then aborts the whole seed. Two shapes cover almost every one an application schema actually declares, and each gets a pass that PLACES it rather than hoping:

  • an ORDERING CHECK (`expires_at > issued_at`) ranks the columns and steps their values apart — see ordering.go;
  • a DISCRIMINATED UNION (`(kind = 'a' AND x IS NOT NULL AND y IS NULL) OR (kind = 'b' AND …)`) picks one branch of the OR and satisfies it whole, so a row's columns all come from the same branch — see union.go.

Both are narrow matchers over the constraint text postgres hands back, and both bail LOUDLY — naming the constraint and the reason — on the expressions they cannot read, rather than writing rows that contradict a rule the schema states.

A COMPOSITE UNIQUE index (`UNIQUE (org_id, user_id)`) is the third such rule, and the one that is not a CHECK at all. It says nothing about either column — each may, and must, repeat — only that the PAIR may not, so per-column distinctness is neither necessary nor sufficient. tuple.go deals the members out as an ODOMETER over the distinct values each can supply (a reference supplies its parent's rows; a data column supplies what the one-column UNIQUE machinery would give it), which makes the tuple distinct by construction, caps the table when the supplies run short, and refuses in the same wording as the two above when no member supplies anything.

Two paths to one parent

The other place per-cell determinism goes blind is a diamond: when a table references a parent both directly and through another parent (orders.patient_id, and orders.prescription_id -> prescriptions.patient_id), every edge is individually valid and the two routes still name different rows. forge detects that from the foreign-key graph and REFUSES to seed until the project declares which route is authoritative, as a COMMENT on the foreign-key constraint. See diamond.go — it carries the reasoning, the vocabulary, and why the declaration lives on the constraint rather than in a forge-owned file.

Domain vocabulary

Built-in synthesis invents values that are OBVIOUSLY invented: a string column nothing describes gets SyntheticStringPrefix + its own name + the row number. A project teaches the seeder its own vocabulary through the user-owned db/seeds/vocab.yaml overlay (see Vocab/LoadVocab/ApplyVocab): per-column value pools, validated against the introspected constraints, drawn with the same deterministic hash-pick. forge owns the seeding MECHANICS; the overlay supplies the domain WORDS.

The overlay is not a nicety, it is the whole declaration surface, so it has to be findable: the scaffolded db/seeds/vocab.yaml says so in its header and the seeding skill says so in its second paragraph. A database full of `sample_*` is forge reporting that nobody told it anything, not forge failing.

A pool of ONE is how a project pins an exact value: `users.email: ["known@example.com"]` puts that address in every row, and a `uuid`-typed column's pin is checked against the canonical UUID spelling before the seed runs (see vocabValueProblem) rather than aborting the transaction at INSERT. That is the whole of what the overlay decides — every value forge would otherwise have invented.

Semantic types

An entry may also declare what KIND of value a column holds, instead of listing values:

columns:
  orders.customer_email:   {type: email}
  orders.shipping_country: {type: country_code}
  products.sku:            {type: uuid}

The values come from gofakeit (see vocabtype.go), expanded into an ordinary pool at load, so everything downstream — validation against the column's constraints, the deterministic per-column draw, the UNIQUE assignment — treats them exactly like a hand-written list. VocabTypeNames() is the supported set, derived from the same table the resolver uses.

This exists because constraint-derivation has a floor. forge satisfies the constraints it can INVERT — lengths, IN-lists, tractable patterns — but a regex postgres accepts that Go's RE2 cannot compile has nothing to invert, and `char_length(country) = 2` is honestly satisfied by "00" even though the column means an ISO code. Neither gap is closed by guessing: a wrong fixture that happens to pass is how a bad value ships silently. What closes them is the author declaring a type, and — for the generated CRUD lifecycle test — codegen's generate-time guard, which asks postgres to evaluate each fixture against the applied CHECK and REFUSES to emit a file its own migration would reject, naming the column, the constraint, and the type to declare.

What the overlay cannot say, and what stands in for it

KEYS are not overridable. ApplyVocab refuses an entry on a primary-key or foreign-key column, because referential coherence is the one thing the seeder guarantees by construction and a hand-written key would be a reference forge cannot prove resolves. Two consequences worth knowing before reaching for the overlay:

  • A project cannot DECLARE the id of a particular row — not its dev principal's, not any other's. Ids come from the key function (deterministicUUID), which is a pure function of (salt, table, row).
  • A project cannot point one row at another BY NAME. There is no way to write "this order belongs to that user" in vocab.yaml, because both halves are keys.

What stands in for the second is the row-0 spine: every non-self foreign key on row 0 resolves to its parent's row 0 (see fkParentRowIndependent), so one fully-connected dataset always exists — row 0 of every table belongs to row 0 of every table it references. Plan.SeedValue(table, column, row) reads any of those values back out, which is how a caller obtains the id it could not name. That is a discovery mechanism, not a declaration one, and the gap is real: a value that has to exist BEFORE the seed runs — a principal id baked into an issued token, say — has nowhere in this overlay to come from.

What the seeder will not infer

No IDENTIFIER says anything to the seeder — not a table's name, not a column's. An identifier is evidence of what someone called a thing, never of what the thing means, and a generator that reads one is right for the vocabulary it was written against and silently wrong everywhere else. Which row is an authenticated principal, which entity is "the user", what a `last4` column holds, which currency an app trades in, what a JSON document contains — all of it is domain knowledge the project declares (in the overlay, in a constraint COMMENT, or in db/seeds/custom/).

The rule that decides every case: DERIVABLE → DERIVE, DECISION → DECLARE. A column's type, its CHECK vocabulary, its pattern, its length and range bounds, its NOT NULL and its foreign keys are all derivable, and synthesis is built from every one of them. What noun belongs in the column is a decision, it is not in the schema, and forge does not invent it. See synth.go for what that costs and what it bought.

Index

Constants

View Source
const OrderStepDays = 30

OrderStepDays is how far apart consecutive ranks of a TIME chain sit — a month, which reads as a plausible validity window rather than an artifact. Exported because the scaffolded lifecycle test's create fixtures place their timestamps the same way (see codegen's crudTestFixtures): there is ONE ordering model, so a born fixture and the seeded row it mirrors can never disagree.

View Source
const SyntheticStringPrefix = "sample_"

SyntheticStringPrefix is the stamp every string forge INVENTS carries.

It is what makes a seeded database readable at a glance: a value carrying the stamp was made up by the generator, and a value without it came from somewhere that knows the domain — a CHECK vocabulary, db/seeds/vocab.yaml, or db/seeds/custom. Exported so that assertions about synthesized data derive the set they check from the emitter's own mark instead of a copied list of literals that dies silently the day the emitter changes.

View Source
const SyntheticUUIDPrefix = "5eed"

SyntheticUUIDPrefix is the stamp every UUID forge INVENTS for a plain data column carries in its first field: `5eed` — "seed", in the hex digits a UUID is spelled with.

It is the UUID column's version of SyntheticStringPrefix, and it exists for the same reason: a value that announces itself as invented can be told apart from data the app produced, at a glance, without a query. A UUID has no room for `sample_`, but it has sixteen bytes and the first four characters are free — the version and variant bits postgres validates live further in, so stamping the head costs nothing and the result is still a well-formed UUID.

KEY columns are deliberately NOT stamped: their ids are a documented stable surface callers paste into fixtures, and re-spelling them would change every primary key in every existing seeded database.

Variables

This section is empty.

Functions

func AllSeedableTablesEmpty

func AllSeedableTablesEmpty(ctx context.Context, db *sql.DB, plan *Plan) (bool, error)

AllSeedableTablesEmpty reports whether every seedable table has zero rows — the first-boot signal the auto-seed hook uses so it never touches a dev DB that already has data.

func FKClosure

func FKClosure(tables []schemadef.Table, rootTable string) []string

FKClosure returns rootTable plus every table reachable from it over declared foreign keys — its transitive ancestors, root INCLUDED — restricted to tables present in the model. Self-references and edges back into the closure resolve per-row inside BuildPlan, so they are not walked. The result is sorted, so a closure is stable across runs.

Ancestors, not descendants: the closure answers "what must already exist for a row of rootTable to be insertable", which is exactly the set a foreign key makes mandatory. A child table is never dragged in, because nothing requires a row to have children.

func FitLength

func FitLength(s string, minLen, maxLen int) string

FitLength forces s into [minLen, maxLen] CHARACTERS — 0 means unbounded on that side — by truncating, or padding with '0'. Counted in runes, not bytes, because that is what postgres's char_length() and a varchar(n) cap count. Shared by seed synthesis here and codegen's CRUD lifecycle-test fixtures.

func InferVocabType

func InferVocabType(t schemadef.Table, col schemadef.Column) (string, bool)

InferVocabType returns the semantic type a column UNAMBIGUOUSLY has, given its schema constraints and its name. ok is false whenever the answer would be a guess — and that is the point of the function.

The rule is narrow on purpose: a name alone is never enough. `country` on a free-text column could hold "United States"; only the combination of the name and a constraint the type would SATISFY (a 2-char length for an ISO code, a regex mentioning `@` for an email) makes the reading forced rather than plausible. Everything else returns false and the author is asked to declare, because a fixture that is wrong-but-passing is worse than one that fails loudly: it ships.

func IsVocabType

func IsVocabType(name string) bool

IsVocabType reports whether name is a supported semantic type.

func LengthBounds

func LengthBounds(t schemadef.Table, col schemadef.Column) (minLen, maxLen int)

LengthBounds merges char_length CHECK constraints and the declared varchar/char cap into (min, max) character lengths for a column. 0 means unbounded on that side; an exact `char_length(col) = N` sets both to N. Shared by vocab validation here and codegen's lifecycle-test fixtures.

func MinimalUnionPlacement

func MinimalUnionPlacement(t schemadef.Table, pools EnumPools) map[string]UnionCell

MinimalUnionPlacement is UnionPlacement for a minimal row: what every placeable discriminated-union CHECK on t requires of the branch a minimal plan takes (see minimalBranchIndex) — the same branch on every row. nil when the table carries no such constraint, or none forge can place.

It exists for the same reason UnionPlacement does: the generated CRUD create-request fixtures mint rows through the create RPC rather than this planner, and must land on the same branch the minimal factory row does, or the two would disagree about one schema. Columns the create leaves unset take their DEFAULT, so the branch a create can actually satisfy is the default-closest one, not whichever the dataset's rotation reaches first.

func OrderRanks

func OrderRanks(t schemadef.Table) map[string]int

OrderRanks returns the ordering rank of every column of t that a PLACEABLE ordering CHECK governs: 0 for a chain's root (which keeps its natural value) and r for a column that must sit r steps above it. A column absent from the map is governed by no ordering constraint forge can place, and takes its independent value.

This is the same resolution the seed plan performs, exposed for the OTHER half of forge's generated data — the create-request fixtures in the born lifecycle test, which mint rows against the very same schema.

func QualifiedTable

func QualifiedTable(t schemadef.Table) string

QualifiedTable is the SQL spelling of a table's name: schema-qualified when the table lives outside `public`, bare otherwise. A seed statement that names only the table fails with `relation "users" does not exist` against a project whose tables are `controlplane.users`, because the connection's search_path is not the project's.

func ScopeTables

func ScopeTables(tables []schemadef.Table, roots []string) []schemadef.Table

ScopeTables narrows a schema to the tables a scoped seed may write: every root that exists, plus the transitive closure of the tables they reference through a NOT NULL foreign key.

Only NOT NULL references pull a parent in. A row cannot be inserted without its required parent, so leaving one out would make the plan unsatisfiable (BuildPlan refuses a NOT NULL reference to an unseeded table). A NULLABLE reference is different — BuildPlan writes it NULL — and following it would widen the seed into exactly the tables the scope exists to leave alone.

Unknown roots are ignored: the caller's list may name an entity whose table a later migration has not created yet. The result keeps the input's table order.

func SeedEnumChoices

func SeedEnumChoices(pool []string) []string

SeedEnumChoices returns the enum/CHECK pool values worth seeding: it drops the proto zero-value sentinel (…_UNSPECIFIED / …_UNKNOWN, or a bare UNSPECIFIED/UNKNOWN) so seed rows carry real, meaningful values rather than the "unset" placeholder — which surfaces as "Unspecified" labels throughout a UI and leaves status/species facets matching nothing. Order is preserved (deterministic draw). Falls back to the full pool when filtering would leave it empty (a pool that is only the sentinel), so a draw always succeeds.

func SynthString

func SynthString(t schemadef.Table, col schemadef.Column, row int) string

SynthString is the string forge invents for a column no vocabulary claims.

A column whose CHECK declares a PATTERN gets a value derived from that pattern; every other column gets the placeholder. Both are derivations from the declaration — neither reads what the column is called for anything but the label — and the pattern branch is strictly more general than the name heuristics it replaced: those satisfied an email-format CHECK on a column spelled `email` and violated the identical CHECK on one spelled `contact`, while knowing nothing at all about a project's own `^SKU-[0-9]{4}$`.

Exported for the generated CRUD lifecycle test, whose create fixtures mint rows against the very same schema and must satisfy the very same CHECKs.

func UnionPlacement

func UnionPlacement(t schemadef.Table, pools EnumPools, row int) map[string]UnionCell

UnionPlacement returns what every PLACEABLE discriminated-union CHECK on t requires of row i: column -> cell. nil when the table carries no such constraint, or none forge can place.

This is the union counterpart of OrderRanks (ordering.go), and it exists for the identical reason. The seed plan is not forge's only generated dataset: the scaffolded CRUD lifecycle test mints its own rows against the very same schema, through create requests, and a value chosen per FIELD cannot satisfy a constraint that spans several. Before this, a project whose schema carried a union got a born test that failed on its first run —

--- FAIL: TestCRUD_Coupon_Lifecycle
    create #1: invalid_argument: create coupon: a field value violates a constraint

— in a scaffold-once file the author is told not to rewrite.

The branch is chosen ROUND ROBIN by row index — the seeder's own rule, and the reason there is one model rather than two: row 0 resolves to the same branch here as it does in the plan, so a born fixture and the dev dataset can never disagree about one schema.

A caller that mints rows through a SHARED request shape (which is what the lifecycle test's two creates are: one field list, two sets of values) must ask for one row and use it for all of them. Which columns a branch requires to be ABSENT is a property of the field list, not of the values in it, so two rows on two different branches cannot be expressed by one list.

pools carries the CHECK/enum vocabularies (PoolsFromTables, or the live introspection); a pin outside a column's vocabulary is unsatisfiable and its branch is dropped, exactly as it is for the seeded rows.

func VocabPath

func VocabPath(migDir string) string

VocabPath returns the conventional overlay location for a project's migrations directory — db/seeds/vocab.yaml, sibling of the db/seeds/custom SQL overlay (both derive from the configured migrations dir the same way).

func VocabTypeNames

func VocabTypeNames() []string

VocabTypeNames returns every supported type name, sorted. Derived from the same table the resolver uses, so documentation, error messages and the validator cannot drift from what is actually supported.

func VocabTypeValues

func VocabTypeValues(typeName string, salt int, table, column string, n int) ([]string, error)

VocabTypeValues generates n deterministic values of the named type.

Determinism is forge's standing guarantee for seed data — the same (schema, config, vocab) must render byte-identically — so the faker is seeded from the same stateless per-cell hash every other draw uses, keyed by (salt, table, column). A column's values therefore depend only on its own identity: adding a typed column never reshuffles another one.

The values are de-duplicated, because a caller asking for n values wants n USABLE values and a repeated one is unusable for a UNIQUE column. Fewer than n may be returned when the generator cannot produce that many distinct values (a country code has ~250; a boolean-ish type far fewer), which the caller handles the same way it handles a short CHECK vocabulary.

Types

type CheckBounds

type CheckBounds map[string]map[string]NumBound

CheckBounds maps table -> column -> the numeric range a column is pinned to by `col >= N` / `col <= M` / `BETWEEN` CHECK constraints — the protovalidate gte/lte projections. Synthesis must generate within them or the INSERT violates the constraint (the seed is transactional: one bad row aborts the whole seed). Introspected from the target DB (see apply.go); nil is fine (an unbounded column falls back to unclamped synthesis).

func BoundsFromTables

func BoundsFromTables(tables []schemadef.Table) CheckBounds

BoundsFromTables builds CheckBounds purely from an introspected schema model (schemadef.Table.Checks) — the no-live-DB sibling of IntrospectCheckBounds, for generate-time callers.

func IntrospectCheckBounds

func IntrospectCheckBounds(ctx context.Context, db schemadef.Queryer) (CheckBounds, error)

IntrospectCheckBounds reads, from the live target DB, the inclusive numeric range each column is pinned to by range CHECK constraints (`col >= N`, `col <= M`, `col BETWEEN a AND b`, and the split gte/lte pair protovalidate emits). An INSERT must satisfy these; the seed is transactional, so one out-of-range value aborts the whole seed. Enum `= ANY (...)` pools and function/regex checks (char_length(col)=N, col ~ '...') are NOT numeric bounds here — those are handled by IntrospectEnumPools and by the per-column length and pattern models (LengthBounds, patternsOf).

type Config

type Config struct {
	// Now is the instant relative time values are anchored to: the
	// `{from: -90d, to: +30d}` ranges and `now` / `-3d` tokens of
	// db/seeds/vocab.yaml, and the four-week window built-in synthesis
	// spreads undescribed timestamp columns over.
	//
	// The zero value selects a FIXED reference instant (EffectiveNow), so a
	// plan built without one renders byte-identically on every day it runs.
	// That is what every generate-time consumer wants — the frontend mock
	// fixtures, entity factories and CRUD test fixtures are checked-in code,
	// and a value that moved with the calendar would rewrite them daily. The
	// seed CLI passes the current day, so a seeded dev database reads as
	// current rather than as January 2024.
	Now time.Time
	// Rows is the default number of rows per table (default 20 — fills a
	// default page and exercises pagination).
	Rows int
	// Salt perturbs synthesis: change it for a different-but-stable dataset.
	Salt int
	// RowsPerTable overrides Rows for specific tables.
	RowsPerTable map[string]int
	// Tables scopes the plan to these tables plus every table they reach
	// through a NOT NULL foreign key (a row there cannot exist without a
	// parent row). nil means every table in the schema. A nullable
	// reference to a table outside the scope is written NULL, exactly as a
	// reference to any unseedable table is.
	//
	// Scoping exists because "fill every table" is the wrong default for a
	// schema that holds more than CRUD demo data: a payments ledger, an
	// idempotency log, a reservations table whose rows mean money moved.
	Tables []string
	// Minimal plans the SMALLEST row the schema accepts instead of the
	// fullest one. A column is written only when the database will not
	// supply an acceptable value itself — a key, a NOT NULL column with no
	// DEFAULT, a NOT NULL reference, a UNIQUE or ordered NOT NULL column, a
	// DEFAULT the column's own CHECK rejects — and every other column is
	// left to its DEFAULT, or NULL. A discriminated-union or status-guard
	// CHECK takes the branch closest to that default row, the same branch on
	// every row. See minimal.go.
	//
	// The dev dataset wants the opposite (every column populated, every
	// branch covered), so this is off for `forge db seed`. It is what a test
	// factory wants: a freshly created row, at its initial lifecycle state,
	// with nothing set that the schema did not ask for.
	Minimal bool
}

Config controls seed volume and determinism. It is a plain value with Effective* accessors so a zero Config yields sane defaults — the CLI maps the project's forge.yaml database.seed block onto it.

func DefaultConfig

func DefaultConfig() Config

DefaultConfig returns the canonical defaults. Salt defaults to 0 so it aligns with an unset forge.yaml database.seed.salt; change it for a different-but-stable dataset.

func (Config) EffectiveNow

func (c Config) EffectiveNow() time.Time

EffectiveNow returns the instant relative time values are anchored to: Config.Now in UTC, or the fixed reference instant when it is unset.

func (Config) EffectiveRows

func (c Config) EffectiveRows(table string) int

EffectiveRows returns the row count for a table, honoring RowsPerTable then Rows then the built-in default. It never returns < 1 so any table referenced by a NOT NULL foreign key has a parent row to point at.

func (Config) EffectiveSalt

func (c Config) EffectiveSalt() int

EffectiveSalt returns the determinism salt.

type DB

type DB interface {
	Exec(ctx context.Context, query string, args ...any) (sql.Result, error)
	Query(ctx context.Context, query string, args ...any) (*sql.Rows, error)
}

DB is the database handle SeedGraph seeds into: read a catalog, run an INSERT. It is deliberately the shape forge's own orm.Context already has, so the handle a flow test is holding — app.NewMigratedTestDB(t) — can be passed straight in with no unwrapping, no type assertion, and no import of the ORM from this package. A raw *sql.DB has the ...Context spelling of these methods instead; wrap it with StdDB.

func StdDB

func StdDB(db *sql.DB) DB

StdDB adapts a database/sql handle to DB, for a caller holding a *sql.DB rather than a forge orm.Context.

type EnumPools

type EnumPools map[string]map[string][]string

EnumPools maps table -> column -> the allowed string values for a column constrained by a native Postgres enum or a simple CHECK (col IN (...)) constraint. Synthesis must draw enum/CHECK columns from this pool or the INSERT would violate the constraint. Introspected from the target DB (see apply.go); nil is fine (an unpooled column falls back to synthesis).

func IntrospectEnumPools

func IntrospectEnumPools(ctx context.Context, db schemadef.Queryer) (EnumPools, error)

IntrospectEnumPools reads, from the live target database, the allowed string values for every column constrained by a native Postgres enum or a simple single-column CHECK (col IN/ = ANY (...)) constraint. Synthesis must draw such columns from these pools or the INSERT would violate the constraint.

func PoolsFromTables

func PoolsFromTables(tables []schemadef.Table) EnumPools

PoolsFromTables builds EnumPools purely from an introspected schema model (schemadef.Table.Checks) — no live database. It covers the CHECK vocabulary half of IntrospectEnumPools (native postgres enum TYPES are not part of the schemadef model and are only visible to the live introspector). Used at generate time, where the introspected model is all there is.

type Fingerprint

type Fingerprint struct {
	// Hex is the digest, or "" when there was nothing to hash (a project
	// with no migrations yet).
	Hex string
	// Files is how many input files contributed, so a caller can tell
	// "nothing changed" from "nothing was read". A guard asserting on a
	// fingerprint built from zero files proves nothing.
	Files int
}

Fingerprint identifies the INPUTS a seed plan is derived from, so a consumer that cached the plan's OUTPUT can tell whether the cache is still answering the same question.

Why this exists

The plan itself can only be built against a live postgres: BuildPlan takes []schemadef.Table, and the only way to get those is to apply db/migrations to a shadow database and introspect it. That is fine for `forge generate` and for a Go test, and impossible for the two places that consume the plan's output without a database — a browser running the mock transport, and `npm test` running vitest with no server.

Those consumers hold a COPY of the plan's output, written at generate time. A copy is a cache, and a cache with no way to detect invalidation is a trap: edit a migration, skip `forge generate`, and the fixtures keep serving rows that describe a schema that no longer exists. Nothing fails — the data is merely wrong, which is the expensive kind of wrong.

A fingerprint closes that hole without needing a database. The inputs are ordinary files on disk, so anything that can read the project directory can recompute it — including a vitest process. Recompute, compare to the value recorded when the output was generated, and a mismatch is a loud failure naming the stale artifact instead of silently wrong data.

What it covers, and why exactly this set

The two files that decide every cell of the plan:

  • db/migrations/*.up.sql — the schema. Column types, CHECK vocabularies, regex CHECKs, length and range bounds, UNIQUE, NOT NULL, and every foreign key are read off the applied result of these.
  • db/seeds/vocab.yaml — the domain vocabulary overlay, the one input that changes a value without changing the schema.

Down migrations are deliberately EXCLUDED: schemadef applies only *.up.sql, so a down file cannot move a single seeded value, and hashing it would invalidate correct fixtures for an edit that provably cannot affect them. A fingerprint that reports change where none can occur trains its reader to ignore it.

db/seeds/custom/ is likewise excluded — those are the user's own INSERT statements applied after the plan, not inputs to it.

The salt and row counts (Config, from forge.yaml) are NOT hashed here. They belong to the caller's own identity rather than to the schema, and FingerprintWithConfig folds them in for a caller that needs the stronger property.

func FingerprintInputs

func FingerprintInputs(migDir string) (Fingerprint, error)

FingerprintInputs computes the fingerprint of the seed plan's inputs for the project whose migrations live at migDir.

The digest covers each input file's PATH and CONTENT, in sorted path order, with explicit length framing between fields. Framing matters: without it, concatenation is ambiguous and two different file sets can hash equal.

Missing inputs are not errors. A project with no db/seeds/vocab.yaml is the scaffolded default, and a project with no migrations yet is a project that has not reached this problem — both yield a well-defined result rather than a failure the caller must special-case. A file that EXISTS but cannot be read IS an error: silently treating an unreadable input as absent would produce a fingerprint that matches when the underlying schema might not.

func FingerprintWithConfig

func FingerprintWithConfig(migDir string, cfg Config) (Fingerprint, error)

FingerprintWithConfig is FingerprintInputs with the seed Config folded in, for a consumer whose cached output would also change if the salt or the row counts moved.

The frontend mock fixtures are exactly such a consumer: they are the first N rows of the planned dataset, so a changed salt reshuffles every value and a changed row count changes how many exist — neither of which touches a migration. Hashing the schema alone would call that cache fresh when it is not.

func (Fingerprint) Empty

func (f Fingerprint) Empty() bool

Empty reports whether the fingerprint covers no input files at all.

This is a distinct state from "some digest", and consumers must treat it as such: a project with no migrations has no schema for fixtures to go stale against, and a guard that failed on it would fail every project from `forge project new` until its first migration.

type MigrationBlock

type MigrationBlock struct {
	// Dirty is set when golang-migrate recorded a migration that failed
	// part-way. The schema is in an unknown state and no further migration
	// will run until the flag is cleared.
	Dirty bool
	// Version is the version recorded in schema_migrations — for a dirty
	// database, the migration that failed. Empty when none is recorded.
	Version string
	// Latest is the highest version present on disk.
	Latest string
	// Reason is the human-readable diagnosis for the non-dirty cases.
	Reason string
}

MigrationBlock describes WHY a database is not in a state seeds may be applied to. It is structured rather than a prose string because the two states need opposite advice and the caller cannot tell them apart from prose: a PENDING database is fixed by `forge db migrate up`, while a DIRTY one refuses that command again and is fixed by `forge db migrate force <version>`. Version and Latest carry the numbers the advice has to name — telling someone to force a version without saying which one leaves most of the dead end in place.

func MigrationsPending

func MigrationsPending(ctx context.Context, db *sql.DB, migDir string) (*MigrationBlock, error)

MigrationsPending reports why the target DB is not seedable, or nil when it is. Seeds apply only against a fully-migrated schema, so the CLI refuses on any non-nil result.

type NumBound

type NumBound struct {
	Min *int64
	Max *int64
}

NumBound is the inclusive numeric range a column is constrained to by range CHECK constraints. A nil end is unbounded on that side.

type Plan

type Plan struct {
	// contains filtered or unexported fields
}

Plan is a fully-resolved, deterministic seed plan: tables in FK-topological order, each with its insertable columns and row count. Render(Plan) and exec are pure functions of it.

func BuildLivePlan

func BuildLivePlan(ctx context.Context, db *sql.DB, migDir, shadowBaseURL string, cfg Config) (*Plan, error)

BuildLivePlan builds the deterministic plan from the applied schema + target DB enum pools, and overlays the project's domain vocabulary (db/seeds/vocab.yaml) when present — a malformed vocab file is a hard error, invalid values degrade to plan warnings (VocabWarnings). Exposed so status/auto-seed can inspect the plan without applying it.

shadowBaseURL names the server hosting the throwaway database the migrations are applied to. It is a PARAMETER rather than something this function resolves, because resolving it means reading forge.yaml, .env and the project's KCL config — knowledge of a forge project's layout that the CLI has and a library must not assume. The CLI passes shadowdb.Resolve(projectDir); an empty string falls back to pgtest's embedded postgres.

A caller that already HAS the migrated schema in front of it wants BuildPlanFromDB instead: it introspects the live database and needs no shadow at all.

func BuildPlan

func BuildPlan(tables []schemadef.Table, pools EnumPools, cfg Config) (*Plan, error)

BuildPlan resolves the introspected schema into a deterministic, FK-topological seed plan. A NOT NULL foreign-key cycle is a hard error naming the cycle (it is equally unsatisfiable for the app itself).

func BuildPlanFromDB

func BuildPlanFromDB(ctx context.Context, db *sql.DB, cfg Config) (*Plan, error)

BuildPlanFromDB builds a seed plan for a database that is ALREADY migrated, by introspecting it directly. No migrations directory, no shadow database, no second postgres: the schema is read from the handle the caller passes.

This is the counterpart to BuildLivePlan, and the difference is only WHERE the schema comes from. BuildLivePlan starts from migration FILES, so it has to apply them somewhere first, and that somewhere is the shadow. A caller holding a migrated database has already paid that cost, and asking it to pay again — to boot a throwaway postgres describing the schema it is connected to — would be the tail wagging the dog.

The vocabulary overlay is not read here, because there is no project directory to read it from. Callers wanting the project's domain words on a live-schema plan load them and call plan.ApplyVocab themselves.

func (*Plan) ApplyVocab

func (p *Plan) ApplyVocab(v *Vocab) []string

ApplyVocab attaches a vocabulary overlay to the plan: each valid (table.column, values) entry becomes the column's draw pool, taking precedence over built-in synthesis for that column. Values are validated against the column's introspected constraints — call it AFTER bounds are attached (BuildLivePlan does) so numeric range CHECKs are visible.

A value it can skip never fails the seed: an invalid value is skipped, a column whose vocab is entirely invalid falls back to built-ins, and PK/FK columns are never overridable — each with a warning naming table.column and the constraint. Warnings are returned AND kept on the plan (VocabWarnings) so every consumer of a built plan can surface them. The exception is a null on a NOT NULL column: it is skipped here too, but recorded so Validate refuses to write the plan. nil vocab is a no-op.

Boolean and time columns take values too: `true`/`false`, and instants written absolutely or relative to the plan's Config.Now (`now`, `-3d`, `{from: -90d, to: +30d}`).

func (*Plan) NaturalValue

func (p *Plan) NaturalValue(table, column string, row int) (string, bool)

NaturalValue is SeedValue without the minimal plan's omission: the raw value the planner places at (table, column, row) whether or not the INSERT names the column. A caller that writes rows through some other path — a create RPC whose plain proto field always writes a value, set or not — needs a valid value for a column the database would otherwise have defaulted. ok is false when the cell is NULL or not a plain scalar.

func (*Plan) Render

func (p *Plan) Render() string

Render returns the full deterministic SQL: a stable header (no timestamps) plus every INSERT in topological order. Byte-identical for identical (schema, pools, config).

func (*Plan) SeedValue

func (p *Plan) SeedValue(table, column string, row int) (string, bool)

SeedValue reports the raw (unquoted) value the plan seeds at (table, column, row) — the value a consumer can hand back to the application layer to reference a seeded row (e.g. a generated test's create request pointing at a seeded FK parent). ok is false when the plan doesn't seed that cell, the cell is NULL, or the cell isn't a plain scalar literal (arrays, bytea casts).

A column a minimal plan leaves out (see Writes) reports ok=false too: the plan seeds nothing there, the database does. NaturalValue reads the value the planner WOULD write, for a caller that has to supply one anyway.

func (*Plan) SeedableTables

func (p *Plan) SeedableTables() []string

SeedableTables returns the set of table names the plan will seed. Used by status/auto-seed to decide "are all seedable tables empty".

func (*Plan) SetBounds

func (p *Plan) SetBounds(b CheckBounds)

SetBounds attaches range-CHECK bounds to the plan so numeric synthesis stays inside them. BuildLivePlan wires the live-introspected bounds; generate-time callers wire BoundsFromTables.

func (*Plan) Statements

func (p *Plan) Statements() []string

Statements returns the per-table INSERT statements in topological order.

func (*Plan) Tables

func (p *Plan) Tables() []string

Tables returns the planned table names in insert (topological) order.

func (*Plan) Validate

func (p *Plan) Validate() error

Validate reports the conditions under which forge refuses to write this plan's rows: a db/seeds/vocab.yaml null declared for a NOT NULL column, and a foreign-key reference reachable by two paths whose authority nobody declared (see diamond.go). Both are checked at APPLY time rather than at BuildPlan, so `forge generate` — which builds one-row plans for entity factories and fixtures — is never blocked by a decision that only affects a real dataset.

func (*Plan) VocabWarnings

func (p *Plan) VocabWarnings() []string

VocabWarnings returns the warnings the last ApplyVocab produced. The seed never fails on a value it can skip; the CLI surfaces these instead. The one vocab problem that DOES refuse the seed — a null on a NOT NULL column — is reported by Validate.

func (*Plan) Warnings

func (p *Plan) Warnings() []string

Warnings returns every plan-level warning: vocabulary validation problems plus the constraint-satisfaction notes finalize records (a row target capped to a UNIQUE column's vocabulary, a value fitted to a length cap, a multi-column CHECK forge could not place — see ordering.go). The seed never fails on these — the CLI surfaces them. An undeclared diamond is NOT among them: that one refuses the seed (see Validate / diamondRefusal).

func (*Plan) Writes

func (p *Plan) Writes(table, column string) bool

Writes reports whether the plan's INSERT for table names column. A full plan writes every non-GENERATED column it plans; a minimal plan (see Config.Minimal) leaves out the columns the database fills on its own — those take their DEFAULT, or NULL. A column the plan does not know is not written.

type Result

type Result struct {
	Tables []TableResult
}

Result is the outcome of Apply/Reset.

func Apply

func Apply(ctx context.Context, db *sql.DB, plan *Plan) (*Result, error)

Apply executes the plan's INSERTs in a single transaction, in topological order, and reports rows inserted per table. Idempotent (ON CONFLICT DO NOTHING).

It REFUSES a plan that would write rows contradicting an invariant the schema implies but does not state — an undeclared diamond (see diamond.go). The gate is here, at the one function that writes rows, rather than in BuildPlan: `forge generate` builds one-row plans for the entity factories, where two routes to a parent cannot disagree, and blocking codegen on a decision that only affects a real dataset would be wrong.

func Materialize

func Materialize(ctx context.Context, db *sql.DB, migDir, shadowBaseURL string, cfg Config) (*Result, error)

Materialize is the one-call entry: introspect the applied schema (real foreign keys), read enum/CHECK pools from the target DB, build the deterministic FK-topological plan (with the db/seeds/vocab.yaml overlay when present), and INSERT it. migDir is the project's migrations directory; db is the already-migrated target. shadowBaseURL is the postgres SERVER the migrations get applied to for introspection (empty selects pgtest's embedded default). Callers that must surface vocab warnings use BuildLivePlan + Apply instead (the CLI does).

func Reset

func Reset(ctx context.Context, db *sql.DB, plan *Plan) (*Result, error)

Reset wipes seeded rows and re-applies. It TRUNCATEs all seeded tables in a single CASCADE statement rather than issuing per-table DELETEs: a child-first DELETE aborts on the first // forge:append-only table (its BEFORE DELETE guard raises), whereas TRUNCATE bypasses that row-level trigger, resolves FK order via CASCADE, and RESTART IDENTITY resets sequences for a deterministic reseed. A dev convenience; destructive (removes ALL rows, as the DELETE did).

The plan is validated BEFORE the TRUNCATE. Apply validates too, but by then the rows are gone: a refusal (a vocab null on a NOT NULL column, an undeclared diamond) that fires after the wipe leaves the database empty and reports an error, which is the worst of both outcomes.

func (*Result) Total

func (r *Result) Total() int64

Total returns the total rows inserted across all tables.

type SeededGraph

type SeededGraph struct {
	// contains filtered or unexported fields
}

SeededGraph is the handle SeedGraph returns: the row-0 primary keys and column values of the seeded root and its foreign-key ancestors, so a test can reference the rows it just created.

func SeedGraph

func SeedGraph(t TB, db DB, rootTable string) *SeededGraph

SeedGraph seeds rootTable and its transitive foreign-key ancestors into db as one connected happy-path spine — row 0 of every table references row 0 of its parents — and returns a handle to the seeded rows.

db is the caller's OWN already-migrated database (in a forge project, app.NewMigratedTestDB(t)). The schema is introspected from it, the plan is computed from that schema, and the INSERTs run against it in one transaction. Every seeded row satisfies the migrations' CHECK / enum / char_length / range constraints, because the values come from forge's seed planner — the same one behind `forge db seed` — evaluated now, against the schema this database actually has.

Root at the deepest table you want pre-seeded. To test a CREATE path, root at the PARENT (the spine is seeded; you create the child through its own RPC, referencing g.PK("<parent>")). To test a read/derived/update path, root at the leaf, so the whole graph is present.

g := seedplan.SeedGraph(t, db, "cart_items")
resp, err := svc.PlaceOrder(ctx, connect.NewRequest(&pb.PlaceOrderRequest{
    CartId: g.PK("carts"),
}))

func SeedGraphErr

func SeedGraphErr(ctx context.Context, db DB, rootTable string) (*SeededGraph, error)

SeedGraphErr is SeedGraph for a caller that wants the error rather than a failed test — the same work, without the testing-shaped wrapper.

func (*SeededGraph) PK

func (g *SeededGraph) PK(table string) string

PK returns the seeded row-0 primary-key value of table — hand it to an RPC to reference the seeded row. Panics if table was not part of this graph, which is a test-authoring error (the wrong table name), not a runtime condition worth returning.

func (*SeededGraph) Root

func (g *SeededGraph) Root() string

Root is the table SeedGraph was rooted at.

func (*SeededGraph) Tables

func (g *SeededGraph) Tables() []string

Tables lists the tables that were seeded — the root and its foreign-key ancestors, sorted.

func (*SeededGraph) Value

func (g *SeededGraph) Value(table, column string) string

Value returns the seeded row-0 value of table.column, so an assertion reads the REAL seeded input instead of hard-coding a copy of it. Panics if the table or column was not seeded.

type TB

type TB interface {
	Helper()
	Fatalf(format string, args ...any)
}

TB is the subset of *testing.T the seeding helpers use. It is declared here rather than taking testing.TB so this package does not force the `testing` import on a non-test caller that wants the same behavior.

type TableResult

type TableResult struct {
	Table    string
	Inserted int64 // rows actually inserted (ON CONFLICT skips excluded)
}

TableResult reports what one table's INSERT did.

type TableStatus

type TableStatus struct {
	Table    string
	Count    int64 // live row count
	Expected int   // rows the plan would insert
}

TableStatus reports seeded-vs-expected counts for one table.

func Status

func Status(ctx context.Context, db *sql.DB, plan *Plan) ([]TableStatus, error)

Status returns live-vs-expected row counts for every seedable table.

type UndeclaredDiamondError

type UndeclaredDiamondError struct {
	// Stanzas is one paste-ready block per undeclared reference.
	Stanzas []string
	// Quiet names the undeclared references whose two routes happen to
	// AGREE in the rows this run planned, so they produced no stanza.
	// They are the same decision waiting to be made: a later run with
	// different random values will refuse on them too.
	Quiet []string
}

UndeclaredDiamondError is the refusal. It is returned instead of writing rows whose two routes to one parent disagree, because such rows are worse than no rows: they are fixtures a correct implementation of the very rule they describe must reject, and a run already built four units against them.

Error() is the runbook: what was expected, what was found, and the literal statement to paste.

func (*UndeclaredDiamondError) Error

func (e *UndeclaredDiamondError) Error() string

type UnionCell

type UnionCell struct {
	// Null is true when the branch requires the column to hold no value.
	Null bool
	// Value is the raw (undecorated) literal the branch pins the column to,
	// "" when it pins none.
	Value string
	// Bound is the range the branch narrows the column to; HasBound says
	// whether it states one.
	Bound    NumBound
	HasBound bool
	// Present is true when the branch requires the column to be non-NULL
	// and says nothing else about it — `status <> 'RESOLVED' OR resolved_at
	// IS NOT NULL` on its RESOLVED branch. Any ordinary value satisfies it;
	// a caller that would otherwise leave the column unset must not.
	Present bool
}

UnionCell is what a discriminated-union CHECK requires of ONE column of one row. Exactly one of the four states holds: the column must be absent (Null), it must carry a specific value (Value), it must stay inside a narrowed numeric range (Bound), or it must merely hold SOME value (Present).

type Vocab

type Vocab struct {
	// Columns maps "table.column" to its resolved value pool (named-pool
	// references are flattened by LoadVocab). A YAML null in a list is kept
	// as an entry that renders NULL.
	Columns map[string][]string
	// Warnings names the entries LoadVocab could resolve but not exactly as
	// written — today, a numeric range whose declared step is too fine to
	// cover it within numericRangeMaxValues. These are load-time notes about
	// the FILE, distinct from ApplyVocab's per-value validation warnings, and
	// ApplyVocab folds them into the plan's warnings so one surface reports
	// both.
	Warnings []string
	// contains filtered or unexported fields
}

Vocab is the optional, user-authored domain-vocabulary overlay for seed synthesis: per-column value pools keyed "table.column", resolved from db/seeds/vocab.yaml. The app author (usually an LLM, right after writing the migrations while domain context is fresh) teaches the seeder the project's vocabulary ONCE; matched columns then draw from these pools with the exact same deterministic column-local hash-pick the built-ins use, so every stability guarantee holds — same (schema, config, vocab) renders byte-identically, and adding vocab for one column never reshuffles another.

The overlay supplies VOCABULARY only. Referential machinery (primary keys, foreign keys) stays the seeder's job and is never overridable; values are validated against the applied schema's constraints at ApplyVocab time so a bad value degrades to a warning, never a failed seed.

func LoadVocab

func LoadVocab(path string) (*Vocab, error)

LoadVocab reads and resolves the vocabulary overlay. A missing file returns (nil, nil) — exactly the built-in behavior. A malformed file (bad YAML, an entry that is neither a list nor {pool: name}, a reference to an undefined pool, an empty pool, a key that is not table.column) is a hard error naming the problem. A fully-commented scaffold parses as empty and is a no-op.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL