Skip to content

fix(diff): Tenki follow-ups — enum ADD VALUE isolation/neighbors, composite mid insert, rename on renamed table + references, sequence START, --using, -token privileges - #1968

Merged
devin-ai-integration[bot] merged 8 commits into
mainfrom
feat/diff-tenki-fixes
Nov 5, 2026
Merged

devin-ai-integration[bot] merged 8 commits into
mainfrom
feat/diff-tenki-fixes

Conversation

@pyramation

@pyramation pyramation commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #1967: fixes the five Tenki findings and the three items that were left unverified there. Failing tests first (f8025c9), then the fix (abb4284).

What was broken → what works now

# Finding Before Now
1 Enum value added and used in the same migration Deploy fails with 55P04 unsafe use of new value (Postgres cannot use a value before the adding transaction commits; pgpm deploy runs in one transaction) Hard error naming the enum/value and the fix (--no-tx, or split the migration); --allow-incomplete emits it with a -- pgpm: marker on the consumer
2 Several enum values inserted at the head ADD VALUE 'x' BEFORE 'y' with y not yet added BEFORE/AFTER only a value that already exists
3 Composite attribute inserted mid-list ADD ATTRIBUTE appends → (a,b,c) instead of (a,c,b), silently Incomplete (hard error unless --allow-incomplete) — Postgres can only append
4 --rename app.old.col=app.new.name (column on a table renamed in the same diff) Rejected as a cross-table move Declared with either table name; emits the table rename then the column rename. A move to another table/schema is still rejected
5 Table rename references renameRelation rewrote only RangeVar/pktable/typevar Rewrites qualified ColumnRefs, COMMENT/SECURITY LABEL targets, grants, policies, regclass literals; renames constraints Postgres named after the old table (old_pkey → new_pkey, with their indexes); a changed SQL-function body (text Postgres does not rewrite) is replaced forward and its inverse folded into the rename's inverse so revert works
6 Sequence NO MINVALUE/NO MAXVALUE Dropping MINVALUE 5 left START WITH 5 (catalog mismatch vs fresh v2) ALTER SEQUENCE .. START reset to what Postgres derives
7 --using "a, b" Silently took the first expression Error: must be one expression (no top-level comma); parse errors name --using
8 diffPrivileges -token state Dropping an explicit REVOKE ALL .. FROM PUBLIC on a table emitted GRANT ALL ON TABLE .. TO PUBLIC; a revoke of a held privilege could emit twice Dropping an explicit revoke re-grants only what PUBLIC holds by default (EXECUTE on functions, USAGE on types); each token revokes once

Review round (failing tests 2a0a8cb → fix 23da4cb):

# Finding Before Now
9 View/policy rebuilt around a breaking change on a renamed table Revert ran ALTER COLUMN .. TYPE with the view still in place (its drop had been folded into the rename's inverse) and recreated the view from the rename-rewritten text (FROM fu.new) after the table was named old again Only replace-in-place objects fold into the rename's inverse; a drop/create pair reverts in order, and the recreate uses the from-side text as written (ObjectUnit.inverseTexts)
10 SELECT old.id FROM app.other AS old when old is the renamed table old.id rewritten to new.id → spurious view rebuild whose revert fails (missing FROM-clause entry) old.col is rewritten only in statements that read relation old itself and never use old as an alias
11 Enum value consumer vs its ADD VALUE Ordered by rank only The consumer depends on the ADD VALUE change (and the diagnostic is whitespace-safe)
12 PUBLIC defaults around an explicit revoke (cdbe9fd → 3530612) Inverse of an added REVOKE ALL ON SCHEMA/TABLE .. FROM PUBLIC was GRANT ALL .. TO PUBLIC (privileges nobody held, granted on revert); a dropped REVOKE .. ON DATABASE restored nothing; a dropped function revoke emitted GRANT ALL Both directions restore exactly what PUBLIC holds by default per the GRANT docs: EXECUTE on functions/procedures, USAGE on types/domains/languages, CONNECT+TEMPORARY on databases, nothing on tables/sequences/schemas (the inverse is then a comment)

Checked against the PG18 GRANT docs for that review's finding 4: PUBLIC has no default privilege on schemas (only the public schema's USAGE, which no module declares), so PUBLIC_DEFAULT_PRIVILEGES deliberately has no schema entry; the database entry was missing and is added.

Also: CLIDeployTestFixture now accumulates repeated flags (--rename a=b --rename c=d) like minimist does; it used to keep only the last.

Decisions made

  • Enum same-transaction use (1) is a hard error by default, not a forced change boundary: pgpm deploy wraps the whole run in one transaction, so splitting changes would not help; the message says to deploy with --no-tx or split the migration.
  • Composite mid-list insert (3) is incomplete rather than DROP+CREATE of the type (that would be data loss for every dependent).
  • Constraints Postgres named after the old table are renamed only when the derivation is unambiguous and under 63 chars; otherwise the migration is incomplete and asks for an explicit name.
  • On PG18 a NOT NULL constraint is named <table>_<col>_not_null; a table rename leaves old_id_not_null (pg_dump-visible, cosmetic). Not renamed: PG<18 has no such constraint, and pgpm diff has no server to ask.
  • A replaced function that depends on a table rename restores its old body from the rename's inverse (reverting only the function change leaves the new body, which is the only body that is valid while the table has its new name).

Verification

  • pgpm/transform: 173 tests (13 new in semantic-diff-hardening.test.ts).
  • pgpm/cli/__tests__/diff-followups-e2e.test.ts (new, live PG18): each case deploys v1, deploys the diff, compares the catalog with a fresh v2, reverts, compares with v1 — enum head insert, composite mid insert, sequence MINVALUE/MAXVALUE drop, REVOKE FROM PUBLIC drop, table+column rename with rows, rename references (comment/grant/policy/view/function), view rebuilt around a renamed+retyped table, alias named like the renamed table, enum add+use, added REVOKE FROM PUBLIC reverting to the effective privileges PUBLIC held.
  • pgpm-projections outputs regenerated with this build: unchanged (no projections PR).

Link to Devin session: https://app.devin.ai/sessions/88795a13d3834106a2ac3a6782348b40
Open in Devin Desktop: https://app.devin.ai/desktop/session/88795a13d3834106a2ac3a6782348b40?variant=devin
Requested by: @pyramation

…/same-tx use, composite mid insert, sequence MINVALUE reset, --using comma, -token privileges, rename on renamed table, rename references)
… mid-list insert, sequence START after MINVALUE/MAXVALUE drop, --using single expression, -token privilege state, column rename on a renamed table, rename references and implicit constraint names

- alterEnum: BEFORE/AFTER only a value that already exists; a value added and
  used by another step of the same migration is incomplete (Postgres 55P04)
- alterComposite: a mid-list ADD ATTRIBUTE is incomplete (it appends)
- alterSequence: resets the START Postgres derives from a dropped MINVALUE/MAXVALUE
- parseExpression: --using must be exactly one expression (no top-level comma)
- diffPrivileges: -token state revokes once; dropping it re-grants only PUBLIC defaults
- --rename app.old.col=app.new.name declares a column rename on a renamed table
- table rename rewrites qualified refs in views/policies/comments/grants/regclass,
  renames Postgres-derived constraint names (old_pkey -> new_pkey), and folds a
  replaced function's inverse into the rename's inverse so revert works
- CLIDeployTestFixture: repeated flags accumulate like minimist
…d of a third database per case (connection pressure in the parallel cli lane)
@devin-ai-integration

Copy link
Copy Markdown
Contributor

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@tenki-reviewer

tenki-reviewer Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Review complete. 🟠 1 high · 🟡 3 medium

💬 Inline comments (4)

🧹 Nitpicks (2) — 🟢 2 low
  • 🟢 Doc says "never GRANT ALL" but code emits it (SKILL.md:130) — The updated .agents/skills/pgpm-projections/SKILL.md (line 130-132) states that dropping an explicit REVOKE .. FROM PUBLIC re-grants "only what PUBLIC holds by default ... never GRANT ALL", but diffPrivileges re-emits the original -all token whenever the object type has PUBLIC default privileges, producing GRANT ALL ON FUNCTION ... TO PUBLIC (pgpm/transform/src/annotations.ts:221).
  • 🟢 Escape enum literal before embedding in SQL comment (semantic-diff-driver.ts:1673) — The same-migration enum-use check captures the raw quoted enum literal and interpolates it into a -- pgpm: ... comment prepended to the consuming statement's SQL (pgpm/transform/src/semantic-diff-driver.ts:1682).

This PR extends the pgpm transform engine so that a schema diff between two declared states can emit ALTER steps for renames and privilege revocations instead of treating every difference as drop-and-recreate. The driver now detects table/column renames from annotations, emits RENAME steps with folded inverse actions, folds same-migration enum-use hazards into advisory comments, and re-grants PUBLIC default privileges when an explicit revoke token disappears. object-alters.ts and annotations.ts carry the new ALTER TYPE/SEQUENCE and GRANT/REVOKE diff logic; semantic-diff-driver.ts wires rename detection, inverse folding, and the incomplete-migration policy. Coverage comes from semantic-diff-hardening.test.ts plus a new e2e harness (CLIDeployTestFixture.ts, diff-followups-e2e.test.ts) that deploys and reverts real fixture modules.

Files Change
pgpm/transform/src/semantic-diff-driver.ts Rename detection, inverse folding, enum-use hazard notes, and completeness gating in the diff pipeline.
pgpm/transform/src/object-alters.ts Emits ALTER TYPE/SEQUENCE and related object-alter steps with correctness flags.
pgpm/transform/src/annotations.ts PUBLIC default-privilege map and GRANT/REVOKE re-grant diffing.
pgpm/transform/__tests__/semantic-diff-hardening.test.ts Unit coverage for rename, enum, and privilege hardening paths.
pgpm/cli/test-utils/CLIDeployTestFixture.ts, pgpm/cli/__tests__/diff-followups-e2e.test.ts e2e fixture and tests exercising real deploy/revert of diffed migrations.
.agents/skills/pgpm-projections/SKILL.md Documents the new rename/revoke semantics (needs a GRANT ALL wording fix).

Reviewed commit: 9143e53

@tenki-reviewer tenki-reviewer Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adds rename-aware ALTER generation and GRANT/REVOKE re-granting to the pgpm semantic-diff pipeline, with hardening tests and an e2e CLI fixture, but the new rename inverse-folding can drop dependent views on revert.

Key findings

Comment on lines +185 to +192
const PUBLIC_DEFAULT_PRIVILEGES: Record<string, string[]> = {
OBJECT_FUNCTION: ['execute'],
OBJECT_PROCEDURE: ['execute'],
OBJECT_ROUTINE: ['execute'],
OBJECT_TYPE: ['usage'],
OBJECT_DOMAIN: ['usage'],
OBJECT_LANGUAGE: ['usage']
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bug · medium

PUBLIC default privileges omit schema/database entries

PUBLIC_DEFAULT_PRIVILEGES (pgpm/transform/src/annotations.ts:185-192) lists only functions, procedures, routines, types, domains, and languages, but Postgres also grants PUBLIC default privileges on schemas (USAGE) and databases (CONNECT, TEMP). When a diff drops an explicit REVOKE ALL ON SCHEMA ... FROM PUBLIC (a -all token in the from-state), unrevoked at line 221 evaluates to empty because byDefault is empty, so no re-grant is emitted. The migration cannot reach the target state and deploy verification reports a residual privilege difference.

📋 Prompt for AI Agents

In pgpm/transform/src/annotations.ts lines 185-192, extend PUBLIC_DEFAULT_PRIVILEGES so dropping an explicit REVOKE ... FROM PUBLIC re-grants everything PUBLIC holds by default: add OBJECT_SCHEMA: ['usage'] and OBJECT_DATABASE: ['connect', 'temporary'] (plus foreign-data-wrapper/server USAGE if those object types are handled). Currently only functions/procedures/routines/types/domains/languages are listed, so a diff that removes REVOKE ALL ON SCHEMA ... FROM PUBLIC emits no GRANT and the migration cannot reach the target state. When adding these, also make the all re-grant emit the individual byDefault privileges (see the GRANT ALL finding) so GRANT ALL ON SCHEMA does not hand PUBLIC CREATE.

Comment on lines +1115 to +1121
if (key === 'ColumnRef') {
const cr = value as { fields?: unknown[] };
if (cr.fields && cr.fields.length >= 2) {
out[key] = { ...cr, fields: renameNames(cr.fields, 1) };
continue;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bug · medium

ColumnRef rename rewrites table aliases named like the old table

The ColumnRef branch of renameRelation rewrites any 2-field reference whose first field equals the old table name, with no check that it is actually a relation reference (pgpm/transform/src/semantic-diff-driver.ts:1115-1120). A view or policy whose SQL aliases another table with the old table's name — SELECT old.id FROM app.other AS old — is rewritten to reference new.id on a relation that was not renamed, producing a broken CREATE OR REPLACE VIEW (or a spurious replacement of a view that did not need one). Quoted identifiers ("Old") are also missed by the same branch.

📋 Prompt for AI Agents

In pgpm/transform/src/semantic-diff-driver.ts around lines 1098-1121, tighten the ColumnRef rewriting in renameRelation: before rewriting a 2-field reference old.col, verify that old is actually the renamed relation — collect the relname and alias of every RangeVar/DeleteStmt/UpdateStmt/InsertStmt relation encountered in the same statement and only rewrite when the head field matches the relation name or an explicit alias OF that relation. Table aliases of other tables (e.g. FROM app.other AS old) must be left untouched, since Postgres stores view/policy definitions by OID and never rewrites them on rename.

Comment on lines +1705 to +1712
for (const renameAtom of atoms) {
if (!renameAtom.rename || renameAtom.sub?.kind !== 'table') continue;
for (const atom of atoms) {
if (atom.phase !== 'create' || atom.added || !atom.inverse || !atom.deps.has(renameAtom.id)) continue;
renameAtom.inverse = `${renameAtom.inverse}\n${atom.inverse}`;
atom.inverse = '';
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 bug · high

Rename inverse folding drops rebuilt dependents on revert

The new inverse-folding loop moves the inverse of every create-phase atom that depends on a table rename into the rename atom's inverse (pgpm/transform/src/semantic-diff-driver.ts:1705-1711), but it does not distinguish replace-in-place atoms (whose inverse restores the old definition) from drop-and-recreate atoms (whose inverse is only a DROP). A view rebuilt around a breaking table change under a declared rename has its DROP VIEW inverse folded into the rename inverse; on revert the rebuilt view is recreated first (via its drop atom's inverse) and the rename change's revert then runs RENAME ... back followed by the folded DROP VIEW, permanently dropping the object the revert was supposed to restore. Reverting such a migration leaves the catalog missing the dependent object.

📋 Prompt for AI Agents

In pgpm/transform/src/semantic-diff-driver.ts around lines 1705-1712, tighten the inverse-folding loop so it only folds atoms whose inverse restores the object's old definition (replace-in-place cases, e.g. CREATE OR REPLACE FUNCTION inverses). Detect this by checking that the atom's inverse re-creates the same object identity (or that the atom has no sibling drop atom in the same unit) instead of accepting any create-phase atom with a non-empty inverse that deps the rename atom. Why: a rebuilt view/policy/trigger under a declared table rename currently gets its DROP inverse folded into the rename inverse; on revert the object is recreated by its drop atom first and then dropped by the folded statement, so the reverted database loses the object.

Comment on lines +1675 to +1683
for (const atom of atoms) {
const added = /^ALTER TYPE (\S+) ADD VALUE ('(?:[^']|'')*')/.exec(atom.sql);
if (!added) continue;
const users = atoms.filter(x => x !== atom && x.phase !== 'drop' && !/^ALTER TYPE \S+ ADD VALUE /.test(x.sql) && x.sql.includes(added[2]));
if (users.length === 0) continue;
const note = `enum value ${added[2]} of ${added[1]} is added and used in the same migration; Postgres cannot use a new enum value before the transaction that added it commits (deploy with --no-tx, or split the migration)`;
incomplete.push(`${atom.name}: ${note}`);
for (const user of users) user.sql = `-- pgpm: ${note}\n${user.sql}`;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bug · medium

Enum add-before-use gets a comment but no ordering edge

The 55P04 detection loop only prepends an advisory -- pgpm: comment to consuming atoms and pushes an incomplete warning; it never adds a dependency edge from the consuming atom to the ALTER TYPE .. ADD VALUE atom (pgpm/transform/src/semantic-diff-driver.ts:1675-1683). Ordering between same-phase (alter) atoms of different units falls back to rank alone, so a table's SET DEFAULT 'c' or constraint step can still be emitted before the ADD VALUE it consumes. Even when the operator obeys the note and deploys with --no-tx, the migration can then fail with invalid input value for enum because the value does not exist yet when the consuming statement runs.

📋 Prompt for AI Agents

In pgpm/transform/src/semantic-diff-driver.ts around lines 1675-1683, after computing users for a detected ALTER TYPE .. ADD VALUE atom, add a dependency edge from each user atom to the ADD VALUE atom (for (const user of users) user.deps.add(atom.id);) alongside the comment annotation. Why: without the edge the topological sort orders same-phase atoms by rank only, so a consuming ALTER TABLE step can be emitted before the ADD VALUE; with --no-tx the deploy then fails with invalid input value for enum even though the tool detected the hazard.

… renamed table reverts out of order with the rewritten text, alias named like the renamed table is rewritten, enum consumer has no edge to its ADD VALUE)
… and from its text as written; rewrite old.col only where old names the table, not an alias; a consumer of a new enum value depends on its ADD VALUE
…ke (inverse of an added REVOKE grants ALL back; database CONNECT/TEMPORARY not restored; GRANT ALL where PUBLIC only held EXECUTE)
…efault privileges in both directions (EXECUTE, USAGE, CONNECT/TEMPORARY on databases; nothing on schemas/tables), never GRANT ALL
… owner's default) in the added-revoke e2e case
@devin-ai-integration
devin-ai-integration Bot merged commit 7d26c32 into main Nov 5, 2026
19 checks passed
@devin-ai-integration
devin-ai-integration Bot deleted the feat/diff-tenki-fixes branch November 5, 2026 21:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant