schemadiff: render the canonical model back to a desired schema file - #52
Conversation
First slice of live-schema export (pull): Render turns an introspected Model into a declarative file that ParseDesired provably admits and that round-trips through IntrospectDesired to an identical model with an empty diff. Serial columns render back to their pseudo-type; any other sequence-backed default fails closed. The pull CLI follows separately.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
A rendered baseline must never look complete while silently dropping partition topology or foreign-key relationships, so the model now carries what it refuses on (partition key/attachment, incoming FKs) and the renderer fails closed with typed errors. docs/limitations.md gains the declarative-model boundaries table and AGENTS.md the capability statement rule. Review coverage: serial pseudo-type mapping is tested per integer type, and a quoted/mixed-case/reserved-word fixture round-trips per TM-4 (both proven to catch their mutations).
|
🤖 Adversarial correctness review, requested by @aparajon and performed by his agent. Reviewed at head Verdict: the round-trip oracle is the right design and the implementation is clean and readable — but both of the proofs Findings1. Neither
The partitioned case is the one I'd fix first: 2. Two tables backed by the same sequence get opposite treatment, and the one that slips through is converted from sharing a sequence to owning a private one — so pulling baselines for both and applying them elsewhere yields two independent sequences and silently breaks the shared-ID invariant the sequence existed to provide. The refusal on SELECT pg_get_serial_sequence('probe.events', 'id'); -- NULL (standalone)
SELECT pg_get_serial_sequence('probe.orders', 'id'); -- NULL (standalone)
SELECT pg_get_serial_sequence('probe.owned', 'id'); -- probe.owned_id_seq3. -CREATE TABLE "events" (
- "id" bigint NOT NULL,
- "name" character varying(50) NOT NULL,
- CONSTRAINT "events_pkey" PRIMARY KEY (id)
-);
-
+CREATE TABLE events (id bigint NOT NULL, name varchar(50) NOT NULL, CONSTRAINT events_pkey PRIMARY KEY (id));
CREATE INDEX events_name_idx ON events USING btree (name);
4. (nit) A zero-column table renders an empty line between the parentheses. Action items
Verified (tried to break, couldn't)Everything the model does carry survives the round trip, and I pushed on the parts most likely to be subtly wrong. Identifier handling is genuinely safe: every rendered name goes through This review was generated by Claude Code (claude-opus-5). |
|
🤖 Second pass, same head ( Lens 1 — OSS adoptionThis is the feature that decides whether anyone with an existing database can adopt declarative schema management at all, and it deserves to be framed that way. Every team pg-sprite wants already has tables. The first thing they must do is produce a file that describes what they already have — and today that means hand-writing The single highest-leverage addition: make the baseline say what it left out. Right now the artifact is silent about its own coverage, and per finding 1 of the correctness comment there are real facts it cannot carry. A human reading a pulled file has no way to know whether it is the whole truth about their table — and the failure mode is the worst kind, because everything looks complete. A short generated header would fix this permanently and cost almost nothing: -- Generated by pg-sprite from public.events. Verified: diffs to zero against
-- the source table. Not represented: <partitioning | collation | ...>.That turns the omissions from a trap into a checklist, and it also solves the trust question an evaluator asks in the first ten minutes ("how do I know this is right?") by putting the proof in the artifact instead of in the docs. Once a describability gate exists this header is mostly the empty case, which is exactly when it is most reassuring. The papercut that will hit in the first five minutes is the What I'd want in the follow-up PR beyond the CLI wiring: the docs framing. A Lens 2 — the seam an orchestrator consumesExporting The error taxonomy is the part that needs to grow with the gates, and it is easier to get right now than later. One seam question the follow-up should answer deliberately: what does an orchestrator do with a table it cannot render? For a CLI the answer is easy — print the refusal and let a human decide. For an automated onboarding flow over dozens of tables, refusing one table shouldn't abort the batch, and the useful output is "here are the 40 baselines, and here are the 3 tables that need a human, with the reason for each." That is a shape decision about the layer above Small note in favor of the current design, since it is the sort of thing that gets refactored away later: proving admissibility by parsing the output through This review was generated by Claude Code (claude-opus-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving: the round-trip oracle is the right design, the implementation is clean, and nothing here is reachable from a CLI yet. Two correctness items should land before the pull CLI exposes Render to an operator — a describability gate for facts the model cannot carry (partitioning, collation, unlogged), and real sequence-ownership verification in serialType. Details in the two comments above.
This review was generated by Claude Code (claude-opus-5).
|
Review response from Kiran's (@Kiran01bm) code review assessment agent (Amp / Claude Opus 4.5) Summary: both load-bearing correctness findings (describability gate, sequence ownership) plus the zero-column nit are fixed in the stacked follow-up #55; the
|
…enders Address the #52 adversarial review: Render's two proofs (ParseDesired admissibility, diff-to-zero) are structurally blind to omission, so the facts the model cannot carry now refuse instead of silently vanishing from the baseline. serialType requires a genuine pg_depend OWNED BY edge (Column.SequenceOwned), not the serial-style sequence name; unlogged tables and explicit column collations refuse on both render and diff with their own typed sentinels; a zero-column table renders (). Also excludes partition clones from ReferencedBy (conislocal), matching introspectConstraints.
Summary
Adds
schemadiff.Render: the canonicalModelrendered back into a desired-state schema file (oneCREATE TABLEplus the model'sCREATE INDEXstatements). This is the building block for live-schema export — pointing pg-sprite at an existing table and getting a declarative baseline file that diffs to zero against it. ThepullCLI command lands in a follow-up PR.First slice of live-schema export (pull): Render turns an introspected Model into a declarative file that ParseDesired provably admits and that round-trips through IntrospectDesired to an identical model with an empty diff. Serial columns render back to their pseudo-type; any other sequence-backed default fails closed. The pull CLI follows separately.
What
pkg/schemadiff/render.go—Render(Model) (string, error), reusing the existingcolumnDefrenderer for columns and the server-decompiledDeftext for constraints and indexes. All identifiers go throughpgx.Identifier.Sanitize().statement.ParseDesiredbefore returning — anything a desired file refuses (foreign keys) surfaces as that gate's typed error, so admission rules stay in one place.serial/bigserial/smallserialwhen the default is the canonical owned-sequence form; any other sequence-backed default fails closed withErrUnrenderableDefault(a rendered file could never recreate the sequence it references on the scratch schema).Why
Onboarding an existing database to declarative schema management needs a trustworthy baseline: a file whose only proof of correctness is that the engine itself diffs it to zero against the live table. Rendering from the introspected model (server-decompiled types, defaults, constraint and index text) rather than from any AST keeps the execute-and-introspect principle: PostgreSQL remains the canonicalizer on both sides of the round trip.