v0.5.0
Three things the adoption review ranked, built: a computed column, a
declared action, and the exit. Between them they answer the two
objections that were not about missing features — that one derived value
pushed an entity off the generated path entirely, and that sqlb owns too
much to be reversible.
One break, and it is a rename. schema.Action is no longer the
foreign-key referential type; that noun went to the domain verb below,
and the type is schema.RefAction. The constants every call site
actually writes — schema.Cascade, schema.SetNull and the rest — are
unchanged, so a schema breaks only if it named the type, and the
mechanical edit is schema.Action -> schema.RefAction in a foreign-key
position. compatibility.md announced it under Will move and now records
that it landed.
A computed column is an expression. ADR-0041, three of its four tiers:
schema.Computed("is_overdue", schema.TypeBool,
schema.FromSQL("due_date < current_date AND open_tasks > 0")).
Filterable()
One interception point, as the record's trace predicted: every consumer
already resolves through a *ColumnInfo and renders through the
compiler's column, so substituting the expression there puts the value
in the projection, the WHERE and the ORDER BY at once. The parameterised
tier takes ADR-0030's shape — Needs("viewer") declares the bind, a
BeforeQuery hook supplies it through Builder.Bind, and rest.Resource
refuses to mount when nothing does. Without that refusal an unbound
expression renders member_id = NULL, returns false for every row
forever, and looks exactly like a feature that works. No DDL in either
direction, so converting a stored column into a computed one proposes
the drop. FromGo was not built — the record already called it the tier
most likely to be cut, and nothing in example/computed reaches for it.
A declared action generates the envelope, and the verb stays plain Go.
ADR-0043, against the 26 item verbs and ~20 collection verbs the
evaluated application had, and the ~30 lines of identical envelope
written four times over before any domain logic:
Task.Action(schema.Action{
Name: "complete",
Body: schema.Body(schema.Text("note").Nullable()),
Writes: []string{"status", "completed_at"},
})
serves POST /tasks/{id}/complete and asks Register for one func. The id,
the scoped fetch, the 404, the body, the transaction, the row lock, the
write set and the response are generated; the transition is not. Writes
is enforced rather than documented — exactly those columns, off the row
the verb mutated — and it is what makes the fetch take FOR UPDATE, since
every one of these is a read-modify-write across a round trip. The verb
reaches the TypeScript, Dart and CLI emitters, sqlb.json and the sqlb impact diff, where removing one is breaking and adding one is additive.
There is no Method field: every legal value was POST. And the hole is
named rather than papered over — a collection action fetches nothing, so
it obliges no hook, and that is two in five of the measured verbs.
sqlb eject writes the way out. ADR-0042, and the answer to the
objection a pre-1.0 library with no consumers cannot answer with a
promise: sqlc and chi are cheap to reverse because they own almost
nothing, while sqlb owns the schema, the migrations, the wire format, the
client and the CLI. sqlb eject ./schema generates a package that
imports pgx and the standard library and nothing else — the DDL, the row
structs without their sqlb tags, one function per statement with the SQL
written out, net/http handlers, and a README saying what came out and
what did not. Deleting sqlb from go.mod afterwards is a supported end
state.
The fidelity line is between the surface and the engine. Out whole: CRUD
and list at the same paths with the same status codes and the same
envelope, every filter operator that is one SQL fragment, ?sort, ?search,
?page, ?per_page, ?count=exact, the declared ceilings and the RFC 9457
error shape. Not out, and refused with a 400 that says so rather than
ignored: keyset cursors, ?select, ?expand, the JSON filter tree, and the
array and document operators — reproducing those would mean emitting a
copy of sqlb, which is a fork with a different import path rather than an
exit. Two properties survive the loss of the machinery they were
implemented in: capabilities stay opt-in, so a column that never declared
Filterable is not filterable in the exit and a Hidden one has no spelling
at all, and ADR-0030's obligation stays compulsory. The load-bearing half
is pgtest/eject_test.go, which stands the committed exit beside the
generated resources it came from, points both at one database, sends both
the same requests and compares the bodies byte for byte.
Adopting an existing database is where the rest of the work went. Each
of these made a schema-vs-database gate propose migrations nobody asked
for, which is the failure that teaches people to stop reading the gate:
IndexNamedandUniqueIndexNameddeclare an index under the name
the database already gave it. The name is not inert — Postgres
reports a violated constraint by name, so renaming a unique index
turns a handled collision into an unhandled 500 without touching the
code that handled it. The generated migration says so now.ExternalRef(...).Enforced()emits a real FOREIGN KEY against a
table this schema has not declared, which is the thing an incremental
adoption always has to say and had no spelling for. What it gives up
is what ADR-0015 bought by refusing the constraint: two modules
joined this way can no longer be migrated independently, so it is
opt-in and unenforced stays the default. introspect imports foreign
keys this way, which is what stops a gate proposing DROP CONSTRAINT
forever.- A jsonb default is compared as a document, so
'{"a":1,"b":2}'::jsonb
and'{"b": 2, "a": 1}'are one default — which is what Postgres
thinks too, since jsonb stores a parsed value rather than the text it
arrived as. Only for jsonb; on a text column those are two strings,
and the test says so.
The round trip is a fixpoint, asserted rather than assumed. introspect,
RenderSchema and Diff were each well tested and nothing checked that they
agreed with one another about one schema, which is why none of their own
tests could see the three disagreements that fell out. RenderSchema could
not write a vector column at all, so a 69-table database could not be
turned into 69 declarations to review on account of one column; an index
lost its operator class and storage parameters, which for pgvector
Postgres rejects outright, since the class selects the distance function
and there is no default; and an enum's CHECK lost its name, so every
later diff proposed dropping and re-adding it. The gate applies an
awkward schema, reads it, renders it back to source that must compile,
rebuilds a second database from what was read, and compares the two
through pg_catalog — databases rather than registries, because two
registries agree about everything they both dropped.
A family of codegen import bugs, in both directions and all with one
cause: format.Source parses without type-checking, so an import that is
named but missing, or present but unused, is valid Go source that fails
only at the consumer's compiler. Three were jsonb-shaped and the rest
were found by auditing the whole set — a read-only resource importing
time, a table whose patchable columns are all nullable importing errors,
a schema whose only uuid column is a primary key importing google/uuid,
a hidden timestamp named by the typed update with nothing importing time,
and a nullable vector matched against a hand-maintained list of type
spellings that was one short. Beside them, a nullable jsonb create body
assigning a pointer into a non-pointer field. The general guard is
TestGeneratedGoCompiles: eight schema shapes generated into a scratch
package and handed to one go build, so the compiler decides rather than
a substring assertion naming the mistake in advance.