Documentation
¶
Overview ¶
Package scaffold holds forge's BIRTH-TIME, SCAFFOLD-ONCE emitters: code that renders user-owned artifacts exactly once, at an explicit `forge scaffold ...` moment, and never touches them again.
The package exists as an architectural statement, splitting the two writer families that used to share internal/codegen:
internal/codegen — Tier-1 DETERMINISTIC PROJECTION. Same inputs, same bytes, regenerated on every `forge generate`, stamped with forge:hash, drift-guarded. Forge owns those files forever.
internal/scaffold (this package) — SCAFFOLD-ONCE. Rendered from a truth (the proto descriptor, the applied schema) at an explicit add-time decision, written through checksums.WriteScaffoldIfMissing or a minimal idempotent splice, and USER-OWNED from birth. Deleting the file is the reset; no generate pass ever rewrites it.
Nothing in this package may be called from the generate pipeline: a scaffold-once artifact that regenerates is a contradiction in terms. Conversely, Tier-1 emitters (crud_gen, inject_gen, the ORM, ...) stay in internal/codegen — a projection that stops regenerating is equally wrong. When a helper is genuinely shared (descriptor types, the unwired-stub marker, handler-method scans), it lives in codegen and is imported from here; the dependency is one-directional (scaffold → codegen), never the reverse.
Current residents:
- entityproto.go — the create-table migration born from an already-authored proto message (`forge scaffold entity --from-proto`), user-owned at birth and never re-derived.
Index ¶
Constants ¶
const ConfigRootMessage = "AppConfig"
ConfigRootMessage is the config message a scaffolded component's block is composed on: the project-global config every scaffold declares in proto/<project>/config/v1/config.proto, which pkg/config's `Config` aliases and internal/app/compose.go reads blocks off (infra.Cfg).
Variables ¶
var ErrNoConfigRoot = errors.New("no " + ConfigRootMessage + " message in the config proto directory")
ErrNoConfigRoot reports that the project has no config-directory message named ConfigRootMessage to compose a block on: it has no typed config, or it moved entirely to per-binary configs. The caller refuses before writing adapter files and explains how to declare the required config root.
Functions ¶
func GeneratedColumnRefusal ¶
func GeneratedColumnRefusal(f codegen.SchemaFieldDef, spec EntityFromProtoSpec) string
GeneratedColumnRefusal returns why a `// forge:generated` field cannot be born as a GENERATED column, or "" when it can.
The supported set is every field birth gives ONE single-valued column with a mechanical conversion: a scalar (including a `<x>_id` reference), a same-package enum with known values, and a singular Timestamp. Repeated, map and nested-message fields are JSONB or array columns whose generated form nobody has asked for; refusing them keeps the marker's meaning one sentence long, and the owned migration takes any hand-written generated column after birth. A field birth carries as a TODO has no column to generate at all, and a managed column (id, created_at, …) is the migration's own.
Exported so the CLI refuses the birth BEFORE writing anything, naming the marker's file and line; the renderer applies the same rule as a TODO.
Types ¶
type ConfigBlockLeaf ¶ added in v0.1.46
type ConfigBlockLeaf struct {
Type string // proto scalar type, e.g. "string"
Name string // proto field name, e.g. "base_url"
EnvVar string // (forge.v1.config).env_var
Description string // (forge.v1.config).description
}
ConfigBlockLeaf is one annotated scalar leaf of a block.
type ConfigBlockResult ¶ added in v0.1.46
type ConfigBlockResult struct {
// File is the .proto file that declares the root config (and, when it
// was added, the block).
File string
// AddedMessage / AddedField report the two halves this call wrote.
// Both false is the idempotent no-op: the block was already declared
// and composed.
AddedMessage bool
AddedField bool
// FieldNumber is the root config field number the block was given when
// AddedField; zero otherwise.
FieldNumber int
}
ConfigBlockResult reports what EnsureConfigBlock changed.
func EnsureConfigBlock ¶ added in v0.1.46
func EnsureConfigBlock(configDir string, spec ConfigBlockSpec) (ConfigBlockResult, error)
EnsureConfigBlock makes the proto config in configDir (proto/<project>/config/v1) declare spec's block and compose it on ConfigRootMessage, writing only the half that is missing. It is a birth-time splice into a file the author owns, held to the same invariants as the managed-field injection (entity_managed_fields.go in internal/cli/scaffold):
- IDEMPOTENT. A block message that is already declared anywhere in the package is never re-declared, and a root that already composes it is never given a second field: re-running the scaffold changes nothing.
- NEVER RENUMBERS. The root field takes the root message's high-water mark + 1, and that mark counts `reserved` numbers, so it can neither collide with nor reuse a number the author wrote or retired.
- REFUSES RATHER THAN GUESSES. A root field already named spec.Field with another type, an existing block message lacking a leaf the scaffold's code reads, or a leaf env var another field already binds is an error naming the conflict; the file is left untouched.
It returns ErrNoConfigRoot when there is no root message to compose on.
func PlanConfigBlock ¶ added in v1.0.0
func PlanConfigBlock(configDir string, spec ConfigBlockSpec) (ConfigBlockResult, error)
PlanConfigBlock is EnsureConfigBlock without the write: the same checks and refusals, and a result reporting what EnsureConfigBlock WOULD add (and the field number it would take). A scaffold's --dry-run reports it.
type ConfigBlockSpec ¶ added in v0.1.46
type ConfigBlockSpec struct {
// Message is the block's message name, e.g. "AlpacaConfig".
Message string
// Field is the root config field holding it, e.g. "alpaca" (its Go
// getter is GetAlpaca).
Field string
// Doc is the comment written above the message, one line per element.
Doc []string
// FieldDoc is the one-line comment written above the root field.
FieldDoc string
// Leaves are the block's fields, in declaration order.
Leaves []ConfigBlockLeaf
}
ConfigBlockSpec is one component config block: a `message <Message>` of annotated leaves, composed on the root config as `<Message> <Field> = N;`.
type EntityFromProtoMigration ¶
type EntityFromProtoMigration struct {
UpSQL string
Notes []string
// PendingRefColumns are this table's `<x>_id` columns that resolve to a
// known entity whose table does not exist YET, so no constraint could be
// emitted here. The caller carries them forward as this table's
// ExistingTable.UnconstrainedRefColumns, and the referenced table's own
// birth migration back-fills them.
PendingRefColumns []string
// BackfilledRefColumns names, per earlier table, the reference columns
// THIS migration constrained on its behalf. The caller strikes them off
// that table's pending list so a third birth cannot propose the same
// constraint twice.
BackfilledRefColumns map[string][]string
}
EntityFromProtoMigration is the rendered forward migration (forge writes no down migrations — it rolls forward only) plus the per-field notes (skips, TODOs) the CLI prints.
func RenderEntityMigrationFromProto ¶
func RenderEntityMigrationFromProto(spec EntityFromProtoSpec) EntityFromProtoMigration
RenderEntityMigrationFromProto renders the create-table migration pair for one already-authored proto message, per the mapping table in the file header. Pure: no filesystem, no descriptor loading — the CLI owns resolution and file placement.
type EntityFromProtoSpec ¶
type EntityFromProtoSpec struct {
// Table is the target table name (already pluralized/overridden).
Table string
// MessageFQ is the fully-qualified proto message the entity is born
// from (recorded in the migration header for provenance).
MessageFQ string
// ProtoPkg is the service's own proto package — the boundary for
// cross-package detection.
ProtoPkg string
// Fields is the message's descriptor schema, in declaration order.
Fields []codegen.SchemaFieldDef
// Enums maps fully-qualified enum names to their value names (the
// CHECK-constraint vocabulary).
Enums map[string][]string
// SoftDelete adds the deleted_at column (set by the flag, the
// `// forge:soft-delete` marker, or the message itself carrying a
// deleted_at field).
SoftDelete bool
// Timestamps adds the managed created_at/updated_at columns.
Timestamps bool
// AppendOnly adds a DB guard (a trigger raising an exception) that
// rejects every UPDATE/DELETE on the table — the storage half of the
// `// forge:append-only` marker (the wire half omits Update/Delete
// RPCs). An audit/ledger table: rows are immutable once written.
AppendOnly bool
// KnownTables is the set of table names an `<x>_id` FK may reference:
// the applied schema UNION every table born in this run (descriptor
// CRUD entities + `// forge:entity` messages). A `<x>_id` column whose
// stem names no table in this set is polymorphic/auth
// (user_id/actor_user_id/entity_id in a project with no such entity)
// and gets NO foreign key — a REFERENCES to a table that doesn't exist
// is worse than silence. Empty/nil suppresses every foreign key.
KnownTables map[string]bool
// ExistingTables are the tables that ALREADY EXIST at the moment this
// migration runs — the applied schema plus everything born earlier in
// the same sweep. It is what makes the emitted constraints applyable:
// a REFERENCES may only name a table an EARLIER migration created.
//
// It also carries the reverse direction. A child born before its parent
// could not constrain its own column; the parent's birth migration
// back-fills that constraint, because the parent's migration is the
// first point at which it can be applied. Between the two directions
// every resolvable reference becomes a real constraint exactly once, in
// whatever order the entities are born.
//
// Empty/nil means "nothing exists yet", which suppresses forward
// constraints — the drift renderer runs this way.
ExistingTables []ExistingTable
}
EntityFromProtoSpec is the resolved input to RenderEntityMigrationFromProto. The add command resolves identities (descriptor service, message, table name, flags); this renderer owns the mapping table above and the SQL text.
type ExistingTable ¶
type ExistingTable struct {
// Name is the table name.
Name string
// UnconstrainedRefColumns are its TEXT `<x>_id` columns that carry no
// FOREIGN KEY yet — the columns a later table's birth may still be
// able to constrain, once it creates the table they point at.
UnconstrainedRefColumns []string
}
ExistingTable is one already-created table, as seen by a migration being born after it.
type ProtoSQLMapping ¶
type ProtoSQLMapping struct {
// Proto is the field shape as declared in the message ("string",
// "optional <scalar>", "repeated enum", "map<K, scalar>").
Proto string
// SQL is the column the birth migration emits for it.
SQL string
// Notes is the one-line qualification, or "" when there is none.
Notes string
}
ProtoSQLMapping is one row of the proto→column mapping this renderer applies: the proto field shape as an author writes it, the column it is born as, and the one-line reason where the column is not simply the scalar type.
func ProtoSQLMappings ¶
func ProtoSQLMappings() []ProtoSQLMapping
ProtoSQLMappings returns the full proto→column mapping the birth renderer applies, scalar rows first (iterated from the real scalarSQL function, so adding a kind there shows up here for free) then the structural rows.
It exists so `forge project annotations` can DUMP the mapping rather than transcribe it. The scalar half is genuinely derived; the structural half is a literal projection of this file's own field switch, pinned by TestProtoSQLMappings_MatchTheRenderer.
The kinds come from codegen.ProtoScalarKinds() — the key set of the one table forge writes the fifteen names down in — rather than from a copy kept here. A copy is a guard that cannot fail: a kind missing from it is simply never dumped, and a mapping absent from the dump reads exactly like a mapping that does not exist.
They are GROUPED BY COLUMN rather than listed alphabetically, so the ten integer widths read as the one BIGINT family they are; alphabetical order scatters them through the float and text rows. The grouping is derived from each kind's own mapping, so it needs no second list to stay correct.