Documentation
¶
Overview ¶
Package schemadef projects a project's APPLIED database schema — db/migrations/*.up.sql executed in order against a REAL ephemeral postgres — into a typed model that code generation consumes.
SQL is the schema language in forge: migrations are the single source of truth for what tables and columns exist. Everything else (entity structs, the ORM, CRUD wiring, frontend gating) is a projection of the schema this package reports.
Shadow strategy ¶
forge is postgres-pinned. Migrations are applied verbatim — byte for byte, no rewriting — to a real ephemeral postgres (pkg/pgtest), then the resulting schema is read back through postgres's own catalog (information_schema / pg_catalog). WHICH server hosts that shadow is not schemadef's decision: the codegen/env layer (internal/shadowdb) resolves a reachable configured server and passes it to ApplyAndIntrospectAt, falling back to pgtest's embedded default only when none is available.
A caller that ALREADY has a migrated database skips the shadow entirely and calls Introspect on its own handle. Both entries return the same model; they differ only in who applied the migrations.
A caller with several readers of one schema applies it once instead: ReadMigrationSet names the files by content digest, and MigrationSet.ApplyTemplateAt builds a Template whose Tables serve the model and whose Open hands out private copies of the applied database (template.go). internal/shadowdb uses it to make one `forge generate` apply each migration set once.
Why this ships in pkg ¶
The model this package produces is the input to forge's seed planner (pkg/seedplan), and that planner has to be callable from a user's test — a flow test seeds a foreign-key spine into its own database by calling a Go function, not by shelling the forge binary or importing SQL that was frozen at the last `forge generate`. A package the generator alone can reach cannot do that, so schema introspection and seed planning both live in the shipped module. Nothing here is server code: it reads catalogs and builds structs.
This is a hard improvement over the previous in-memory SQLite shadow, which approximated postgres by exploiting SQLite's permissive type affinity and required a normalization pass (DEFAULT (now()) wrapping, '::type' cast stripping, multi-ADD splitting) to coax idiomatic postgres DDL through SQLite's parser. That approximation broke the moment a project used a construct SQLite couldn't parse — most notably schema-qualified DDL (CREATE TABLE controlplane.foo), which froze cp-forge's ORM. Real postgres needs none of that: it IS the target, so the schema the generator sees is exactly the schema production runs.
Statements that fail to apply are still skipped when they cannot affect the table/column model (DML data movement, CREATE FUNCTION / TRIGGER / EXTENSION / VIEW, COMMENT, SET ...) — postgres rejects some of these in the bare ephemeral DB (e.g. an extension that isn't installed) and that must not abort introspection. A failing CREATE TABLE / ALTER TABLE / DROP TABLE / CREATE INDEX is a hard error: those define the schema being projected, so silently skipping one would generate an ORM that lies.
Index ¶
- Constants
- Variables
- func AppendOnlyGuardTrigger(table string) string
- func ApplyAndIntrospectShadowAt(migDir, baseURL string) ([]Table, *Shadow, error)
- func CheckMarkers(tables []Table) error
- func MarkerTokens(comment string) []string
- func SiteMarkers(site MarkerSite) []string
- func SplitStatements(sqlText string) []string
- type CanonicalType
- type CheckConstraint
- type Column
- func (c Column) DatabaseSupplied() bool
- func (c Column) DeclaresNoSoftDelete() bool
- func (c Column) DeclaresSecret() bool
- func (c Column) FillStrategy() (strategy string, ok bool)
- func (c Column) GoTypeOverride() string
- func (c Column) HasMarker(marker string) bool
- func (c Column) ReadableReason() (reason string, declared bool)
- type Conventions
- type ForeignKey
- type Index
- type IndexKey
- type MarkerProblem
- type MarkerSite
- type MigrationSet
- type Queryer
- type Shadow
- type Table
- type Template
Constants ¶
const ( FillStrategyULID = "ulid" FillStrategyHandler = "handler" )
FillStrategyULID and FillStrategyHandler are the only two values `forge:fill=` may take. See ColumnMarkerFill.
const ( ColCreatedAt = "created_at" ColUpdatedAt = "updated_at" ColDeletedAt = "deleted_at" )
managed column names recognized by convention.
const ColumnMarkerClosedSet = "forge:closed-set"
ColumnMarkerClosedSet declares that a CHECK constraint's value list mirrors a closed Go set: `COMMENT ON CONSTRAINT access_tokens_scopes ON access_tokens IS 'forge:closed-set accesstoken.AllScopes'`. Like ColumnMarkerRef it is a constraint comment — on the CHECK, not the column. `forge lint`'s closed-sets lane compares the list the migrations last defined with the named set and prints the migration that reconciles them (internal/closedset).
const ColumnMarkerFill = "forge:fill"
ColumnMarkerFill declares WHO fills a `forge:read-only` column that the generated Create/Update requests never carry a value for — the gap the unsatisfiable-column lint (internal/cli/lint/lint_unsatisfiable_columns.go) exists to close. Takes exactly one of two argument values, spelled `forge:fill=<strategy>`:
forge:fill=ulid forge generates a ULID for this column at Create,
the same chokepoint (pkg/crud.Repo.Create) that
already ULID-generates an empty string PK. For a
non-PK column only — a share token, an invite code.
forge:fill=handler pure acknowledgement: "I stamp this myself." Changes
no codegen behavior; its only mechanical effect is
suppressing the lint (and, where the CRUD shim is
scaffolded fresh, seeding an op.Entity FORGE_SCAFFOLD
reminder instead of a bare delegation).
`forge:fill=uuid` is deliberately NOT a strategy: forge pins postgres:17-alpine, where uuidv7() (time-ordered, postgres 18+) does not exist — only gen_random_uuid() (UUIDv4, random) is available. Forge's keyset pagination (pkg/crud's orderKeysetSafe) assumes pk-ascending insertion order; a random id would make every list page's order unpredictable. ULIDs are time-ordered and sort in creation order, which is why forge offers that strategy and not a UUID one.
const ColumnMarkerGoType = "forge:go-type"
ColumnMarkerGoType declares the Go type a column projects to on the generated entity struct, in place of the one forge derives from its SQL type: `forge:go-type=github.com/acme/app/gen/app/v1;appv1.ContentBlockType` for an enum kept as an integer, `forge:go-type=map[string]any` for a json document read as a map. The type must scan from and bind to the column (Bun's own rules: a named integer for an integer column, a map or struct for json). A qualified type names its import path, with `;<name>` when the package's name is not the path's last element — the go_package convention. Read with Column.GoTypeOverride.
const ColumnMarkerImmutable = "forge:immutable"
ColumnMarkerImmutable declares that a column must not be written by a full-replace UPDATE. It projects to Bun's `skipupdate` tag option.
const ColumnMarkerNoSoftDelete = "forge:no-soft-delete"
ColumnMarkerNoSoftDelete declares that a table's `deleted_at` column is an ordinary nullable timestamp, not forge's soft-delete tombstone: `COMMENT ON COLUMN worktrees.deleted_at IS 'forge:no-soft-delete'`.
By convention a time-typed deleted_at IS the soft-delete declaration — the generated Get, List and Update skip rows where it is set, and Delete stamps it instead of deleting. That is the right default and stays the default. But the same column name is also how a schema spells an ARCHIVE stamp, where an archived row must stay readable and writable and a delete must delete. Without a declaration the only way out was to route every query through the builder with WhereAllWithDeleted and ForceDelete, re-deriving by hand what the generated store should have said.
With the marker the column projects like any other nullable timestamp: no Bun ,soft_delete tag, no ListAll<E>, the column is writable by Update, every row is visible to Get and List, and Delete is a real DELETE. Only meaningful on deleted_at — on any other column it is rejected, because it would do nothing (see CheckMarkers).
const ColumnMarkerOwner = "forge:owner"
ColumnMarkerOwner declares that a column names WHO a row belongs to: the rows of this table are the property of distinct principals, and this is the column that says whose.
Forge ships no ownership of its own — no implicit scope, no ambient principal — and this marker does not change that. It injects no WHERE clause and changes no codegen, because only the application knows how a caller's claims map onto this column's values (a direct user id, a company id read from a membership table, an org resolved from a subdomain). Inventing that mapping is exactly the domain policy forge deliberately does not own. What the marker changes is what forge is willing to let SHIP.
It ARMS the `unscoped_auth` audit category. That category has always detected an authenticated RPC whose handler never resolves the caller, and has always reported it as advice, on the sound reasoning that a freshly-scaffolded project is entirely unscoped by construction: forge emits the delegations, the developer writes the scoping, and erroring would make forge's own output fail forge's own gate.
That reasoning conflated two different claims. "Unscoped" is a fact about handlers; "these rows belong to someone" is a fact about data, and forge could not previously observe the second one. A project that never says any row belongs to anyone has no boundary to breach. A project that declares this marker has stated the exact thing the fresh-scaffold argument assumed was unknowable — so from that point an authenticated RPC over this table that reads no caller is not a nudge, it is one principal reading (or writing) another's rows, and it fails the audit.
Measured, against a real downstream app: forge reported "63 of 74 authenticated RPCs never resolve the caller" and exited 0. Any authenticated user could read, update, delete and create rows belonging to every other one. The diagnosis was exactly right and nothing gated on it.
const ColumnMarkerReadable = "forge:readable"
ColumnMarkerReadable declares a column whose name looks like a credential but is not one, so the read-only role may read it: `COMMENT ON COLUMN usage_events.token_hash IS 'forge:readable — a content hash of the prompt, not of a credential'`. The reason is mandatory; it is the review record of an exception to the role's fail-closed default. Secret wins when both are declared.
const ColumnMarkerRef = "forge:ref"
ColumnMarkerRef declares which side of a two-paths-to-one-parent reference is authoritative (values: authoritative, derived-from=<col>, independent). Unlike ColumnMarkerImmutable it is declared on the FOREIGN KEY constraint, not the column — COMMENT ON CONSTRAINT, not COMMENT ON COLUMN — but it shares the same `forge:*` catalog-comment vocabulary, so it lives in this registry alongside it. Consumed by `forge db seed`'s diamond resolution; see pkg/seedplan/diamond.go for the full grammar and pkg/seedplan's `db/seeding` skill for the authoritative docs.
const ColumnMarkerSecret = "forge:secret"
ColumnMarkerSecret declares a column the database's read-only role (pkg/dbgrants) must never read: `COMMENT ON COLUMN git_credentials.access_token IS 'forge:secret'`. It is the SQL spelling of the proto `// forge:secret` field marker and the only one a column with no wire field can use — most credential tables have none. Either spelling withholds the column from the role; only the proto one also strips it from read responses.
const ColumnMarkerVersion = "forge:version"
ColumnMarkerVersion opts a column into optimistic concurrency control: pkg/crud's generic Repo requires Update/UpdateMasked's WHERE clause to still match the caller's last-read value and increments it atomically on a successful write. A write that loses the race — the value moved under a concurrent writer between that writer's read and this one's — fails with svcerr.ErrAborted (Connect code Aborted, "re-read and retry") instead of silently clobbering the other writer's change or reporting the row missing.
Unlike forge:immutable this column is NEVER caller-settable, not even via an explicit update_mask path: the repo owns the increment, and a client-supplied value would defeat the guarantee the marker exists to give. See forge/pkg/crud's meta.versionColumn for the write-path detail and internal/generator's bunTag for how the marker also becomes Bun's `,skipupdate` — defense in depth against a hand-rolled full-struct Bun query that bypasses the generic Repo entirely.
const TableMarkerAppendOnly = "forge:append-only"
TableMarkerAppendOnly declares that a table is an immutable ledger: rows may be inserted and read, never rewritten or erased. It is the STORAGE half of the `forge:append-only` proto marker, and it is what every post-birth pass reads.
It is declared in the migration rather than inferred from the proto for the same reason forge:immutable is: whether a row may be rewritten is a fact about STORAGE. The proto marker is a birth-time instruction that stops existing the moment the birth is over — it writes the trigger and omits the Update/Delete RPCs, and then nothing carries it forward. That is precisely how an append-only entity ended up with a generated store exposing UpdateX, UpdateXMasked and DeleteX: the only pass that had ever heard of the marker was the one that wrote the migration.
Declaring it on the table closes that, because the applied schema is the one truth every generate-time pass already reads. It is also honest about the general case: a table can be append-only because a hand-written migration made it so, with no proto marker anywhere.
const TableMarkerEntityName = "forge:entity-name"
TableMarkerEntityName declares the Go/wire name of the entity a table projects to: `COMMENT ON TABLE daemon_attachment IS 'forge:entity-name=DaemonAttachment'`. Without it the name is derived from the table (the singular, camel-cased), and a CRUD rpc's entity finds its table by that name, its plural or its singular. The declaration is for a table whose name derives to something else — an irregular plural, a prefix, a legacy spelling — so the entity, its store and any `Get<E>` rpc over it are named for what the table holds.
const TableMarkerNoEntity = "forge:no-entity"
TableMarkerNoEntity opts one table out of the generated data layer: no entity struct, no store, no delegates, no factory. Every other applied BASE TABLE is projected, whether or not a service declares CRUD rpcs for it — SQL is the schema truth, and the store follows the schema, not the wire. A table carrying it is plain schema reached through hand-written SQL (a migration bookkeeping table, a partition parent, a table another tool owns).
It is the per-table twin of the project-level `//forge:no-orm`, and it removes the table from the CRUD join too: a CRUD-named rpc over it is custom, as if the table did not exist.
const ViewMarkerEntity = "forge:entity"
ViewMarkerEntity opts a VIEW into the generated data layer as a READ-ONLY entity: `COMMENT ON VIEW chats_with_activity IS 'forge:entity'`. It gets the row struct plus List/Count, and Get<E>ByID by its key column — `id` when the view has one, or the one `forge:entity key=<column>` names. No write is generated: a view is a derived read. Views are opt-in because most are reporting conveniences nothing in Go reads.
Variables ¶
var KnownColumnMarkers = []string{ColumnMarkerImmutable, ColumnMarkerRef, ColumnMarkerClosedSet, ColumnMarkerVersion, ColumnMarkerFill, ColumnMarkerOwner, ColumnMarkerGoType, ColumnMarkerNoSoftDelete, ColumnMarkerSecret, ColumnMarkerReadable}
KnownColumnMarkers is the complete set of `forge:*` marker NAMES a COMMENT ON COLUMN or COMMENT ON CONSTRAINT may carry. It is the single source of truth shared by `forge project annotations --kind column` (internal/cli/annotations.go) and the unknown-column-marker lint check (internal/cli/lint) — both project from this slice rather than each maintaining its own copy, which is exactly the discoverability gap forge:ref fell into (undiscoverable outside two skill files) that this registry exists to close.
var KnownTableMarkers = []string{TableMarkerAppendOnly, TableMarkerNoEntity, TableMarkerEntityName, ViewMarkerEntity}
KnownTableMarkers is the complete set of `forge:*` marker NAMES a COMMENT ON TABLE may carry — the table-level twin of KnownColumnMarkers, and registered for the same reason: a vocabulary nothing enumerates is a vocabulary a typo silently leaves inert.
Functions ¶
func AppendOnlyGuardTrigger ¶
AppendOnlyGuardTrigger returns the name of the guard trigger forge installs on an append-only table. Exported because the upgrade path has to name the same trigger forge writes, and two spellings of one name is how a migration ends up looking for a guard that is really there.
func ApplyAndIntrospectShadowAt ¶
ApplyAndIntrospectShadowAt is ApplyAndIntrospectAt that KEEPS the shadow database open and returns it. The schema model is identical — this is the same apply-then-introspect pass — but the caller can additionally interrogate the applied schema directly.
A non-nil *Shadow is returned whenever one was opened, INCLUDING on a later error, so the caller's deferred Close always reclaims the scratch database.
func CheckMarkers ¶ added in v1.0.0
CheckMarkers checks every catalog comment of the applied schema — table, view, column, foreign-key and CHECK constraint — and returns an error naming each forge marker that does nothing where it is declared, nil when there is none. It is what makes a misspelled or misplaced marker fail the generate that reads it, instead of shipping the behaviour it was written to change.
func MarkerTokens ¶ added in v1.0.0
MarkerTokens returns every forge marker name a comment carries, in order.
func SiteMarkers ¶ added in v1.0.0
func SiteMarkers(site MarkerSite) []string
SiteMarkers returns the marker names a comment on site may carry.
func SplitStatements ¶
SplitStatements splits SQL text into individual statements, honoring single/double quotes, line and block comments, postgres dollar-quoted strings ($tag$ ... $tag$), and trigger bodies (BEGIN ... END;).
Types ¶
type CanonicalType ¶
type CanonicalType string
CanonicalType is the dialect-neutral type vocabulary the generators consume. The mapping from declared SQL types is documented on MapDeclaredType.
const ( TypeString CanonicalType = "string" TypeInt CanonicalType = "int64" TypeFloat CanonicalType = "float64" TypeBool CanonicalType = "bool" TypeTime CanonicalType = "time" TypeJSON CanonicalType = "json" TypeBytes CanonicalType = "bytes" )
The Type* constants enumerate every CanonicalType the generators recognise.
func CanonicalTypes ¶
func CanonicalTypes() []CanonicalType
CanonicalTypes returns the canonical type vocabulary, sorted — derived as the VALUE SET of the declared-SQL-type table, never as a restatement of the Type* constants.
Deriving it rather than listing it is what makes it honest in both directions. A canonical type no declared SQL type maps to is a type no column can ever carry, so it does not belong in an obligation downstream projections are held to; and a canonical type introduced with a mapping appears here the moment the mapping does, which is what fails the projection tests that derive their coverage from this function instead of carrying a list someone has to remember to update.
func MapDeclaredType ¶
func MapDeclaredType(decl string) (canonical CanonicalType, isArray bool, known bool)
MapDeclaredType maps a declared SQL column type to the canonical forge type. isArray is true for array types ("TEXT[]"), in which case the canonical type describes the ELEMENT.
Mapping table (case-insensitive, length/precision suffixes ignored):
TEXT, VARCHAR, CHAR, CITEXT, UUID → string BIGINT, INTEGER, INT, SMALLINT, *SERIAL → int64 DOUBLE PRECISION, REAL, FLOAT, NUMERIC, DECIMAL → float64 BOOLEAN, BOOL → bool TIMESTAMPTZ, TIMESTAMP[ WITH(OUT) TIME ZONE], DATE, DATETIME → time JSONB, JSON → json BYTEA, BLOB → bytes
known is FALSE for anything else — MONEY, INET, INTERVAL, TSVECTOR, a native pg enum, an empty udt. The canonical type is TypeString in that case, and callers must treat it as a FALLBACK rather than a mapping: postgres will not accept arbitrary text into any of those columns, so a `string` field projected from one is a wire contract that accepts what the database rejects.
The distinction is the whole point of the third return. Without it every unknown arrived indistinguishable from a real TEXT column, and downstream guards written to catch exactly this could not fire — the loss happened here, upstream of them, in silence.
type CheckConstraint ¶
type CheckConstraint struct {
// Name is the constraint name — postgres auto-derives one
// (`<table>_<col>_check`, `_check1`, …) for CHECKs written inline
// without an explicit CONSTRAINT name, which is how forge emits them.
Name string
// Def is the canonical constraint definition from
// pg_get_constraintdef, e.g. "CHECK (amount_cents >= 0)" or
// "CHECK (status = ANY (ARRAY['A'::text, 'B'::text]))".
Def string
// Columns are the column names the check references (its conkey),
// in key order. Single-column for every check forge itself projects
// (enum vocabulary, protovalidate bounds, the id-non-empty guard).
Columns []string
// Comment is the constraint's catalog comment (COMMENT ON CONSTRAINT
// ... IS '...'), "" when none — where a CHECK declares the closed set
// its value list mirrors (ColumnMarkerClosedSet).
Comment string
}
CheckConstraint is one introspected CHECK constraint on a table.
type Column ¶
type Column struct {
Name string
// DeclType is the declared SQL type verbatim from the migration
// (e.g. "TIMESTAMPTZ", "TEXT[]", "DOUBLE PRECISION").
DeclType string
// Type is the canonical forge type the declared type maps to.
Type CanonicalType
// IsArray is true for declared array types ("TEXT[]"); Type then
// holds the ELEMENT type.
IsArray bool
// TypeKnown is false when DeclType matched no arm of the mapping
// table — MONEY, INET, INTERVAL, TSVECTOR, a native pg enum. Type is
// then TypeString as a fallback, NOT as a mapping, and anything that
// projects a wire contract from this column must say so rather than
// publish a `string` field postgres will reject writes to.
TypeKnown bool
// MaxChars is the declared character cap of a varchar(n)/char(n) column,
// 0 when the type is unbounded. DeclType cannot carry it: it is rebuilt
// from udt_name, which spells both `varchar` and `varchar(4)` as
// "VARCHAR". Anything that must not overflow the column (seed synthesis,
// generated test fixtures) reads this.
MaxChars int
NotNull bool
// Default is the raw default expression, "" when none.
Default string
IsPK bool
// IsGenerated is true for GENERATED ALWAYS AS (...) STORED columns —
// the database computes them, so they must never be written on
// INSERT/UPDATE (postgres rejects writes to generated columns).
IsGenerated bool
// IsIdentity is true for a GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY
// column. The catalog reports no column_default for one (its value comes
// from an implicit sequence), so Default alone cannot say the database
// supplies it; see DatabaseSupplied.
IsIdentity bool
// Comment is the column's catalog comment (COMMENT ON COLUMN), "" when
// none. It carries the `forge:*` column declarations the same way a
// constraint comment carries `forge:ref` — plain SQL, applied by the
// migration, landing in the postgres catalog where forge, `psql \d+` and
// anything else can read it. Only the `forge:` clause is interpreted;
// prose in the same comment is fine.
Comment string
}
Column is one introspected column.
func (Column) DatabaseSupplied ¶ added in v1.0.0
DatabaseSupplied reports whether the database gives the column a value when an INSERT leaves it out: an identity column, or any DEFAULT — a serial's nextval(...), gen_random_uuid(), a literal. It is the applied schema's answer to "is this key server-allocated", which the column's type does not give: `integer PRIMARY KEY` with no DEFAULT is a key the caller must set.
func (Column) DeclaresNoSoftDelete ¶ added in v1.0.0
DeclaresNoSoftDelete reports whether the column's catalog comment carries ColumnMarkerNoSoftDelete, matched as a whole token.
func (Column) DeclaresSecret ¶ added in v1.0.0
DeclaresSecret reports whether the column's catalog comment carries ColumnMarkerSecret, matched as a whole token.
func (Column) FillStrategy ¶
FillStrategy returns the value of a `forge:fill=<strategy>` declaration on the column's catalog comment, and whether one was present. An unrecognized value (neither FillStrategyULID nor FillStrategyHandler) still reports ok=true with the raw string — the caller decides how to report a bad value; this accessor only parses the grammar.
func (Column) GoTypeOverride ¶ added in v1.0.0
GoTypeOverride returns the type a ColumnMarkerGoType declaration names, "" when the column declares none.
func (Column) HasMarker ¶
HasMarker reports whether the column's catalog comment carries marker.
forge:fill takes an argument (`forge:fill=ulid`), so a plain substring check against the bare marker name would never match it; FillStrategy is the accessor for that marker instead. HasMarker still answers "was forge:fill declared at all" correctly, because ColumnMarkerFill (without the `=value` suffix) is itself a substring of the full declaration.
func (Column) ReadableReason ¶ added in v1.0.0
ReadableReason returns the reason a ColumnMarkerReadable declaration gives, and whether the column declares the marker at all. A declaration with no reason returns ("", true): it is declared, and CheckMarkers rejects it.
type Conventions ¶
type Conventions struct {
SoftDelete bool
Timestamps bool
// AppendOnly mirrors Table.AppendOnly (a COMMENT ON TABLE
// `forge:append-only` declaration). Unlike the two above it is a
// DECLARATION rather than a column-shape convention — no arrangement of
// columns can express "this table refuses UPDATE" — but it rides here
// because every consumer of behavior-by-table already reads
// Conventions, and a second channel is how one pass ends up honouring
// what another ignores.
AppendOnly bool
// SearchColumns are the text columns (excluding the PK and managed
// columns) the generated search filter matches against.
SearchColumns []string
}
Conventions are the behavior-by-convention signals derived from real columns. No annotations: the columns ARE the declaration.
deleted_at (time) ⇒ soft delete (UPDATE-not-DELETE,
reads filter IS NULL, ListAll* unfiltered)
unless the column declares
forge:no-soft-delete (ColumnMarkerNoSoftDelete)
created_at + updated_at ⇒ managed timestamps
text columns ⇒ searchable by the generated list filter
func DetectConventions ¶
func DetectConventions(t Table) Conventions
DetectConventions reads the behavior conventions off a table's columns.
type ForeignKey ¶
type ForeignKey struct {
Column string
RefTable string
RefColumn string
// Name is the constraint name postgres knows it by — explicit
// (`CONSTRAINT orders_patient_id_fkey`) or auto-derived from an inline
// REFERENCES. It is what a COMMENT ON CONSTRAINT statement names.
Name string
// OnDelete is the referential action as information_schema spells it:
// "NO ACTION" (postgres's default), "RESTRICT", "CASCADE", "SET NULL"
// or "SET DEFAULT".
//
// It is a behaviour of the reference, not a decoration on it: CASCADE
// means deleting the parent silently deletes these rows. Anything
// reporting that a reference exists without saying what it does on
// delete has described the cheap half.
OnDelete string
// Comment is the constraint's catalog comment (COMMENT ON CONSTRAINT
// ... IS '...'), "" when none. It is where a project DECLARES a domain
// fact about the reference that the schema alone cannot express — see
// seeddata's diamond resolution. Read from the applied catalog, never
// from migration text, so there is one source of truth.
Comment string
}
ForeignKey is a declared REFERENCES constraint.
type Index ¶
type Index struct {
Name string
Columns []string
Unique bool
// Predicate is the WHERE clause of a PARTIAL index, "" for a full one.
//
// It is load-bearing for a UNIQUE index and cannot be dropped: a
// partial `UNIQUE (email) WHERE deleted_at IS NULL` permits duplicate
// emails among soft-deleted rows, and reporting it as plain
// `UNIQUE (email)` describes a stricter table than the one that
// exists. An unqualified claim is worse than no claim.
Predicate string
// Expression is true when at least one index key is an EXPRESSION
// rather than a bare column — `UNIQUE (lower(name))`,
// `UNIQUE (supplier_id, lower(sku))`.
//
// It exists because such an index cannot be described by Columns
// alone, and describing it partially is worse than not describing it.
// pg_catalog stores an expression key as attnum 0, which carries no
// pg_attribute row, so a column-joining introspection query drops that
// key silently: `UNIQUE (lower(name))` came back as an index with NO
// columns, and `UNIQUE (supplier_id, lower(sku))` came back as
// `UNIQUE (supplier_id)` — a DIFFERENT, stricter constraint than the
// one the table has.
//
// Columns holds only the bare-column subset, so it must never be read
// as the whole key: `lower(name)` being unique does not make `name`
// unique, and `(supplier_id, lower(sku))` being unique does not make
// `supplier_id` unique. Read Keys for the full key, in order.
Expression bool
// Keys is every index key in order, as postgres renders it: a bare
// column is its name, an expression key is its SQL text
// (`lower(name)`). It is the only faithful description of an
// expression index, and it is what lets a consumer recognise the
// common case-insensitive form and plan against the underlying column
// instead of giving up on the index entirely.
//
// Populated for every index by introspection. A hand-constructed Index
// may leave it nil, so read it through [Index.KeyList], which falls
// back to Columns.
Keys []IndexKey
}
Index is a non-PK index on a table.
func (Index) KeyList ¶
KeyList returns the index's keys in order, falling back to one plain key per entry in Columns when Keys is unset.
The fallback exists because Index is constructed in two ways: introspection (which fills Keys) and by hand, in tests and in callers that only ever describe bare columns. Requiring both fields at every construction site would make the zero value a trap — an index with keys silently missing — so the accessor derives the one from the other instead.
type IndexKey ¶
type IndexKey struct {
// Expr is the key as postgres renders it: "name" for a bare column,
// "lower(name)" for an expression.
Expr string
// Column is the bare column this key is over, when that is knowable:
// the column itself for a plain key, and the argument for a
// single-column case fold like `lower(name)`. Empty when the key spans
// several columns or uses an expression this package does not read
// (`(a || b)`, `(meta->>'k')`).
//
// A consumer that can only place a value for a real column uses this
// to decide whether the key is reachable at all.
Column string
// Fold names the case-folding function wrapping Column — "lower" or
// "upper" — and is empty for every other key, including a plain one.
//
// This is the one expression form worth reading rather than refusing.
// `UNIQUE (lower(email))` is how case-insensitive uniqueness is
// spelled in postgres and is common in hand-written migrations, and
// its rule is exactly "these values must be distinct ignoring case" —
// which a value generator CAN satisfy without evaluating SQL.
Fold string
}
IndexKey is one key of an index — a column, or an expression over columns.
type MarkerProblem ¶ added in v1.0.0
type MarkerProblem struct {
// Object names where the comment sits: "table worktrees",
// "column worktrees.deleted_at", "constraint x_fkey on worktrees".
// Empty from CommentMarkerProblems, which does not know it.
Object string
// Marker is the offending forge:* name, verbatim.
Marker string
// Problem is the sentence saying why it does nothing and what to do.
Problem string
}
MarkerProblem is one forge marker a catalog comment carries that nothing reads where it is declared.
func CommentMarkerProblems ¶ added in v1.0.0
func CommentMarkerProblems(site MarkerSite, column, comment string) []MarkerProblem
CommentMarkerProblems returns one sentence per forge marker in comment that a comment on site does not read. column is the column's name for SiteColumn, and "" otherwise.
type MarkerSite ¶ added in v1.0.0
type MarkerSite string
MarkerSite is the kind of catalog object a comment is attached to. Each site has its own vocabulary: a column marker on a table comment is read by nothing, exactly like a misspelled one.
const ( SiteTable MarkerSite = "table" SiteView MarkerSite = "view" SiteColumn MarkerSite = "column" SiteConstraint MarkerSite = "constraint" )
The catalog objects a `forge:*` comment can sit on.
type MigrationSet ¶ added in v0.1.46
type MigrationSet struct {
// contains filtered or unexported fields
}
MigrationSet is the ordered *.up.sql files of one migrations directory, read ONCE: the exact bytes a shadow applies, and the digest that names them.
Reading the bytes once is what makes the digest trustworthy as a cache key. A caller that hashed the directory and then applied it by reading the files again could hash one version of a migration and apply another (an editor or a scaffold writing between the two reads), and would then file a schema under a name that does not describe it. A set applies the bytes it hashed, so the two cannot disagree.
func ReadMigrationSet ¶ added in v0.1.46
func ReadMigrationSet(migDir string) (*MigrationSet, error)
ReadMigrationSet reads every *.up.sql under migDir, in lexical order — the order a shadow applies them in. A missing directory is an empty set, not an error: a project with no migrations has no schema yet.
func (*MigrationSet) ApplyTemplateAt ¶ added in v0.1.46
func (m *MigrationSet) ApplyTemplateAt(baseURL string) (*Template, error)
ApplyTemplateAt applies the set to a fresh scratch database on the server at baseURL (pkg/pgtest's default ephemeral server when empty), introspects it, and releases every connection to it so it can be copied.
The model Tables returns is exactly ApplyAndIntrospectAt's for the same files: the same replay, the same statement policy, the same catalog walk.
An empty set is an error rather than an empty template: a project with no migrations needs no postgres at all, and callers check Len first so that one never boots one.
func (*MigrationSet) Digest ¶ added in v0.1.46
func (m *MigrationSet) Digest() string
Digest names the set's content: every file's name and bytes, in apply order. Two sets with the same digest apply the same statements in the same order, wherever their directories are.
func (*MigrationSet) Len ¶ added in v0.1.46
func (m *MigrationSet) Len() int
Len is the number of migration files in the set.
type Queryer ¶
type Queryer interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
}
Queryer is the read-only database handle introspection needs: one method, the catalog query. *sql.DB satisfies it, and so does anything else that can run a query — a *sql.Tx, a pool wrapper, or the ORM handle a forge test already holds. Introspection only ever READS the catalog, and this type says so at the signature: a caller can hand over its live database knowing the model-builder has no way to write to it.
type Shadow ¶
type Shadow struct {
// contains filtered or unexported fields
}
Shadow is a LIVE handle on the applied-schema shadow database, handed to callers that must do more than read the model: ask postgres itself a question about the schema it just applied.
The distinction matters because a schema model is a description and postgres is the authority. A generator that DERIVES a value from the model — a fixture satisfying a CHECK — has an opinion; running that value past the applied constraint turns the opinion into a verified fact, and the only place that can happen is a database with the migrations already on it.
The caller owns Close and must call it; the underlying scratch database lives until then.
func OpenShadowAt ¶
OpenShadowAt opens a scratch shadow database with every migration under migDir applied and returns it live: the replay ApplyAndIntrospectShadowAt performs, without the introspection. Unlike that function it opens a shadow even when migDir holds no migrations yet (or does not exist) — a project's FIRST migration is exactly the one a caller wants to try against postgres before writing it.
On error there is nothing to close: a shadow opened before the replay failed is dropped here, so the caller checks err and only then defers Close — the ordinary Go contract. (It used to return the live shadow beside a replay error, which made `defer shadow.Close()` BEFORE the err check the only correct spelling at the call site.)
func (*Shadow) Close ¶
func (s *Shadow) Close()
Close drops the scratch database and releases the connection. Safe to call on a nil *Shadow, so `defer shadow.Close()` needs no guard.
func (*Shadow) DB ¶
DB is the open connection to the shadow database, with every migration under the directory already applied.
func (*Shadow) TryApply ¶
TryApply runs sqlText against the shadow inside a transaction that is ALWAYS rolled back, and returns postgres's verdict on it: nil when every schema-defining statement applied, else the first one's error, unwrapped, so the caller can attribute it. The shadow is left exactly as it was, so one shadow answers any number of "would this migration apply?" questions — postgres DDL is transactional, which is what makes the rollback complete.
The statement policy is applyMigration's: a statement that cannot affect the table/column model (a function, a COMMENT, a trigger) may fail without failing the try. Each one runs under its own savepoint so its failure does not abort the transaction around the statements that do count.
type Table ¶
type Table struct {
Name string
// Schema is the postgres schema the table lives in ("public",
// "controlplane", …). Tables are still keyed by Name; Schema exists so SQL
// forge emits against the shadow can address the table when its schema is
// not on the connection's search_path.
Schema string
Columns []Column
// Comment is the table's catalog comment (COMMENT ON TABLE), "" when
// none. It is where a TABLE-level `forge:*` declaration lives, the twin
// of Column.Comment one level up.
Comment string
// Triggers lists the table's trigger names, sorted. It exists because a
// declaration forge started writing at some release is absent from
// every table born before it, and a table's ENFORCEMENT outlives the
// release that wrote it: AppendOnly reads the guard trigger as a
// second, older declaration of the same fact. See appendOnlyGuardTrigger.
Triggers []string
// PKCols lists primary-key column names in key order.
PKCols []string
// Indexes lists non-PK indexes (unique and plain).
Indexes []Index
// ForeignKeys lists declared REFERENCES constraints.
ForeignKeys []ForeignKey
// IsView marks a VIEW (IntrospectViews), not a BASE TABLE. A view has
// columns and a comment and nothing else: no key, no constraints, and
// every column nullable as far as the catalog can tell.
IsView bool
// Checks lists the table's CHECK constraints, read back through
// postgres's own catalog (pg_get_constraintdef). Because both the
// applied migration dir and any freshly-projected DDL are introspected
// through the SAME catalog, two semantically-identical CHECKs — however
// they were spelled in the source SQL (`col IN (...)` vs the postgres
// `col = ANY (ARRAY[...])` normal form) — read back byte-identical here.
// That is what lets a drift comparison diff CHECK vocabulary reliably
// without re-implementing postgres's expression normalizer.
Checks []CheckConstraint
}
Table is one introspected table of the applied schema.
func ApplyAndIntrospect ¶
ApplyAndIntrospect applies every *.up.sql under migDir (in lexical order) to a fresh real-postgres shadow database and returns the resulting schema. A missing or empty migrations directory returns (nil, nil): no schema, no entities, nothing to project.
This is the zero-config entry: the shadow lives on pkg/pgtest's default ephemeral server (embedded-postgres, or the FORGE_TEST_POSTGRES_URL server when that env var is set). Callers that have resolved a shadow SERVER from forge's config/environment (internal/shadowdb) call ApplyAndIntrospectAt instead, passing that server down — schemadef itself stays env-agnostic and does not decide which postgres to use.
func ApplyAndIntrospectAt ¶
ApplyAndIntrospectAt is ApplyAndIntrospect against an explicitly resolved shadow SERVER. baseURL is a maintenance-DB DSN (postgres://user:pass@host:port/postgres?...) on an already-running server; the shadow is a freshly-created, uniquely-named scratch database on it, dropped before returning, so the server's application database is never touched. An empty baseURL falls back to pkg/pgtest's default ephemeral server (embedded-postgres / FORGE_TEST_POSTGRES_URL) — the zero-setup / CI path.
The server choice is deliberately the CALLER's: forge's codegen/env layer (internal/shadowdb) resolves a reachable configured server and passes it here, so the fragile embedded default is used only when no configured server is available. schemadef and pkg/pgtest remain dumb mechanics.
func Introspect ¶
Introspect reads the schema model out of a database that ALREADY has the migrations on it, through postgres's own catalog. It is the shadow-free entry: no migration directory, no scratch database, no second postgres — the caller hands over a live handle and gets back the same model ApplyAndIntrospect produces after applying migrations itself.
This is what a TEST uses. A test already owns a migrated database (forge scaffolds app.NewMigratedTestDB for exactly that), so making it name a migrations directory and boot a second postgres to describe the schema it is already connected to would be ceremony, not safety. Booting a shadow stays the generator's path, because a generator starts from files on disk and has no database yet.
func IntrospectViews ¶ added in v1.0.0
IntrospectViews returns every user VIEW (regular and materialized) of the database, each as a Table with IsView set: its columns, in ordinal order, and its catalog comment, where an opt-in (ViewMarkerEntity) lives.
func (Table) AppendOnly ¶
AppendOnly reports whether the table is an append-only ledger: INSERT and SELECT only, no UPDATE and no DELETE.
TWO declarations satisfy it, and the second one is not redundancy — it is the upgrade path.
TableMarkerAppendOnly in the catalog comment. The declaration forge writes at birth, and the one a hand-written migration should use. Matched as a WHOLE token so a longer marker that merely begins with this one (a hypothetical `forge:append-only-soft`) cannot read as this one — the same discipline declaresOwnerMarker applies to forge:owner, and stricter than Column.HasMarker's substring test.
forge's own guard trigger, `<table>_append_only`. Forge wrote that trigger long before it wrote the comment, so EVERY append-only table that predates the declaration has the trigger and no comment. Reading only the comment would mean the tables with the longest-standing immutability guarantee were the ones that silently lost it — the generated store would hand back UpdateX/DeleteX for a table postgres rejects every write to. The trigger is a real declaration of the same fact, made in the only vocabulary that release had.
The trigger arm is matched by forge's exact NAME, never by the shape "some trigger fires on UPDATE". An updated_at stamper is also BEFORE UPDATE and means the opposite. The asymmetry of harm sets that precision: missing the marker silently weakens a guarantee, while inventing one DELETES Update/Delete from a generated store and breaks a build that was correct. A hand-written guard under any other name declares itself with the comment.
func (Table) AppendOnlyDeclared ¶
AppendOnlyDeclared reports whether the table carries the CATALOG declaration specifically — the comment, not the guard-trigger fallback AppendOnly also accepts.
It exists so a test can pin that its fixture really does reproduce the pre-declaration shape. Without it an upgrade test keeps passing if a future change starts writing the comment into the fixture, while proving nothing about the upgrade it was written to cover.
func (Table) DeclaredEntityName ¶ added in v1.0.0
DeclaredEntityName returns the name a TableMarkerEntityName declaration gives the table's entity, "" when it declares none.
func (Table) DeclaresEntity ¶ added in v1.0.0
DeclaresEntity reports whether a view's comment opts it in with ViewMarkerEntity, and the key column a `key=<column>` names ("" for none).
type Template ¶ added in v0.1.46
type Template struct {
// contains filtered or unexported fields
}
Template is a migration set applied ONCE to a scratch database that is afterwards only ever copied, never handed out.
Applying a project's migrations is the expensive half of reading its schema: every statement of every file, in order, on a real postgres, then a catalog walk over every table. One `forge generate` used to pay it once per consumer — the entity projection behind the ORM, the CRUD wiring, the frontend pages, nav and mocks, the test factories — for the same unchanged directory each time. A Template pays it once. Tables is the introspected model; Open hands out a private live copy (CREATE DATABASE … TEMPLATE) to a caller that must execute SQL against the schema, for the cost of a file copy instead of a replay.
The template database itself is never handed out. A caller that wrote to it — even a rolled-back INSERT advances a sequence — would change what the next copy starts from, and the copies are only interchangeable with a fresh replay because nobody touches the original.
The caller owns Close, which drops the template database. Copies already opened are independent databases and survive it.
func (*Template) Close ¶ added in v0.1.46
func (t *Template) Close()
Close drops the template database. Safe to call more than once and on a nil *Template.
func (*Template) Digest ¶ added in v0.1.46
Digest is the digest of the migration set the template was built from.
func (*Template) Open ¶ added in v0.1.46
Open returns a private live copy of the template: a new scratch database holding the applied schema, which the caller may write to freely and must Close. It is indistinguishable from a database the migrations were just replayed into, without the replay.