schemadef

package
v0.1.45 Latest Latest
Warning

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

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

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.

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

View Source
const (
	FillStrategyULID    = "ulid"
	FillStrategyHandler = "handler"
)

FillStrategyULID and FillStrategyHandler are the only two values `forge:fill=` may take. See ColumnMarkerFill.

View Source
const (
	ColCreatedAt = "created_at"
	ColUpdatedAt = "updated_at"
	ColDeletedAt = "deleted_at"
)

managed column names recognized by convention.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

Variables

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.

View Source
var KnownTableMarkers = []string{TableMarkerAppendOnly}

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

func AppendOnlyGuardTrigger(table string) string

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

func ApplyAndIntrospectShadowAt(migDir, baseURL string) ([]Table, *Shadow, error)

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 SplitStatements

func SplitStatements(sqlText string) []string

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
}

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
	// 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) FillStrategy

func (c Column) FillStrategy() (strategy string, ok bool)

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) HasMarker

func (c Column) HasMarker(marker string) bool

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.

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)
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

func (ix Index) KeyList() []IndexKey

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 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

func OpenShadowAt(migDir, baseURL string) (*Shadow, error)

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

func (s *Shadow) DB() *sql.DB

DB is the open connection to the shadow database, with every migration under the directory already applied.

func (*Shadow) TryApply

func (s *Shadow) TryApply(ctx context.Context, sqlText string) error

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
	// 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

func ApplyAndIntrospect(migDir string) ([]Table, error)

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

func ApplyAndIntrospectAt(migDir, baseURL string) ([]Table, error)

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

func Introspect(db Queryer) ([]Table, error)

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 (Table) AppendOnly

func (t Table) AppendOnly() bool

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.

  1. 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.

  2. 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

func (t Table) AppendOnlyDeclared() bool

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.

Jump to

Keyboard shortcuts

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