From 1657fc18c80a9decd8305b5715990b0733720ec8 Mon Sep 17 00:00:00 2001 From: Kiran Muddukrishna Date: Wed, 28 Oct 2026 12:03:10 +1100 Subject: [PATCH 1/2] docs: explain why --desired and --alter treat a destructive drop differently The constraint-rename row said running the statements with --alter converges, which predates --allow-destructive; the drop now needs the acknowledgement. --- docs/execute-and-introspect.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/docs/execute-and-introspect.md b/docs/execute-and-introspect.md index 1cc05f1e..75122733 100644 --- a/docs/execute-and-introspect.md +++ b/docs/execute-and-introspect.md @@ -14,6 +14,7 @@ it does not fold, and what the approach requires from the database. - [How pg-sprite avoids reimplementing the rules](#how-pg-sprite-avoids-reimplementing-the-rules) - [Compared with Spirit](#compared-with-spirit) - [What the catalog does not fold](#what-the-catalog-does-not-fold) + - [Why `--desired` and `--alter` treat the drop differently](#why---desired-and---alter-treat-the-drop-differently) - [What the approach requires](#what-the-approach-requires) ## The problem @@ -215,13 +216,35 @@ ALTER TABLE public.accounts ADD CONSTRAINT accounts_email_uniq UNIQUE (email); | --- | --- | --- | --- | | `DEFAULT now()` vs `DEFAULT CURRENT_TIMESTAMP` | `now()` vs `CURRENT_TIMESTAMP` | a `SET DEFAULT` | converges: the next `diff` is empty | | `DEFAULT 1 + 1` vs `DEFAULT 2` | `(1 + 1)` vs `2`; the server does not evaluate the expression | a `SET DEFAULT` | converges | -| a named `CONSTRAINT accounts_email_uniq UNIQUE` vs an unnamed `UNIQUE` | `accounts_email_uniq` vs `accounts_email_key` | a destructive `DROP CONSTRAINT` of the old name and an `ADD CONSTRAINT` under the new one; constraints are matched by name, so a rename is a drop and an add | `migrate --desired` refuses the whole plan with reason `destructive-change`; running the statements with `migrate --alter` converges | +| a named `CONSTRAINT accounts_email_uniq UNIQUE` vs an unnamed `UNIQUE` | `accounts_email_uniq` vs `accounts_email_key` | a destructive `DROP CONSTRAINT` of the old name and an `ADD CONSTRAINT` under the new one; constraints are matched by name, so a rename is a drop and an add | `migrate --desired` refuses the whole plan with reason `destructive-change`; running the drop with `migrate --alter --allow-destructive public.accounts`, then the add, converges ([why the two differ](#why---desired-and---alter-treat-the-drop-differently)) | | `name text COLLATE "C"` vs `name text` | collation `"C"` vs the type's default | refused: `unsupported schema change: column "name" collation change ("" vs "pg_catalog.\"C\"")` | does not converge through pg-sprite; a collation change rewrites the column and rebuilds its indexes, which the diff does not derive | The same table written with `int8`, `TEXT`, `TIMESTAMP WITH TIME ZONE`, `current_timestamp`, `DEFAULT (2)`, table-level `PRIMARY KEY (id)` and `UNIQUE (email)`, and `COLLATE "default"` reports no changes against the live table: those differences the catalog does fold. +### Why `--desired` and `--alter` treat the drop differently + +Both front doors refuse to discard live data or structure without being told to; they differ in +who wrote the drop. + +- **`migrate --desired`: pg-sprite derived the drop.** The diff produced + `DROP CONSTRAINT accounts_email_key` and `ADD CONSTRAINT accounts_email_uniq`. A desired file + cannot say whether that is a rename or the deliberate removal of a guarantee, so pg-sprite does + not infer that the loss is intended. Any destructive statement refuses the whole plan, + including the harmless `ADD`, and there is no acknowledgement to lift it: `--allow-destructive` + cannot be combined with `--desired`. +- **`migrate --alter`: the operator wrote the drop.** The intent is explicit, but the drop is still + refused with `destructive-change` until `--allow-destructive public.accounts` names the table. + The `ADD` is not destructive and runs without it. + +```text +--desired diff derives DROP + ADD → destructive statement in the plan → whole plan refused, no override +--alter operator writes the DROP → refused unless --allow-destructive public.accounts + operator writes the ADD → runs (the safer UNIQUE sequence) + rerun diff → no changes: converged +``` + When a reported difference was not meant as a change, write the desired file the way the server stores it. [`pg-sprite pull`](pull.md) exports a live table in that form. From 0ab5630c6943406f5e487d84e3099f4efd0acbe0 Mon Sep 17 00:00:00 2001 From: Kiran Muddukrishna Date: Wed, 28 Oct 2026 16:15:23 +1100 Subject: [PATCH 2/2] docs: run the unique add before the drop in the rename example Dropping first leaves the column without a unique constraint until the add commits. The example now gives the commands in add, drop, desired order, says what to do if the add fails, and that a refused desired plan is no licence to run its statements unattended. --- docs/execute-and-introspect.md | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/execute-and-introspect.md b/docs/execute-and-introspect.md index 75122733..5de7af42 100644 --- a/docs/execute-and-introspect.md +++ b/docs/execute-and-introspect.md @@ -216,7 +216,7 @@ ALTER TABLE public.accounts ADD CONSTRAINT accounts_email_uniq UNIQUE (email); | --- | --- | --- | --- | | `DEFAULT now()` vs `DEFAULT CURRENT_TIMESTAMP` | `now()` vs `CURRENT_TIMESTAMP` | a `SET DEFAULT` | converges: the next `diff` is empty | | `DEFAULT 1 + 1` vs `DEFAULT 2` | `(1 + 1)` vs `2`; the server does not evaluate the expression | a `SET DEFAULT` | converges | -| a named `CONSTRAINT accounts_email_uniq UNIQUE` vs an unnamed `UNIQUE` | `accounts_email_uniq` vs `accounts_email_key` | a destructive `DROP CONSTRAINT` of the old name and an `ADD CONSTRAINT` under the new one; constraints are matched by name, so a rename is a drop and an add | `migrate --desired` refuses the whole plan with reason `destructive-change`; running the drop with `migrate --alter --allow-destructive public.accounts`, then the add, converges ([why the two differ](#why---desired-and---alter-treat-the-drop-differently)) | +| a named `CONSTRAINT accounts_email_uniq UNIQUE` vs an unnamed `UNIQUE` | `accounts_email_uniq` vs `accounts_email_key` | a destructive `DROP CONSTRAINT` of the old name and an `ADD CONSTRAINT` under the new one; constraints are matched by name, so a rename is a drop and an add | `migrate --desired` refuses the whole plan with reason `destructive-change`; running the add with `migrate --alter`, then the drop with `migrate --alter --allow-destructive public.accounts`, then `migrate --desired` for the rest, converges ([why the two differ, and the commands](#why---desired-and---alter-treat-the-drop-differently)) | | `name text COLLATE "C"` vs `name text` | collation `"C"` vs the type's default | refused: `unsupported schema change: column "name" collation change ("" vs "pg_catalog.\"C\"")` | does not converge through pg-sprite; a collation change rewrites the column and rebuilds its indexes, which the diff does not derive | The same table written with `int8`, `TEXT`, `TIMESTAMP WITH TIME ZONE`, `current_timestamp`, @@ -240,11 +240,33 @@ who wrote the drop. ```text --desired diff derives DROP + ADD → destructive statement in the plan → whole plan refused, no override ---alter operator writes the DROP → refused unless --allow-destructive public.accounts - operator writes the ADD → runs (the safer UNIQUE sequence) - rerun diff → no changes: converged +--alter operator writes the ADD → runs (the safer UNIQUE sequence) + operator writes the DROP → refused unless --allow-destructive public.accounts +--desired rerun for the rest → the two SET DEFAULTs run; a fresh plan is empty: converged ``` +To converge the example, run the `ADD` before the `DROP`: + +```sh +pg-sprite migrate --alter 'ALTER TABLE public.accounts ADD CONSTRAINT accounts_email_uniq UNIQUE (email)' +pg-sprite migrate --alter 'ALTER TABLE public.accounts DROP CONSTRAINT accounts_email_key' \ + --allow-destructive public.accounts +pg-sprite migrate --desired accounts.sql +``` + +The order matters. While both constraints stand, both enforce uniqueness on `email`, so the +column is never left unenforced. Dropping first would leave a window in which concurrent writes +can insert duplicates, and the `ADD` would then fail on them. The `ADD` runs as the safer +sequence the plan shows: `CREATE UNIQUE INDEX CONCURRENTLY`, then `ADD CONSTRAINT … UNIQUE USING +INDEX`. If it fails, `accounts_email_key` still enforces uniqueness and nothing is lost. A failed +concurrent build can leave an invalid index under the new name; the +[invalid-index runbook](invalid-index-recovery.md) says how to clear it before you run the `ADD` +again. + +The refusal of a desired plan is not permission to run its statements one by one. An +orchestrator should not turn a refused plan into acknowledged `--alter` statements on its own; +running the drop is a separate decision an operator makes. + When a reported difference was not meant as a change, write the desired file the way the server stores it. [`pg-sprite pull`](pull.md) exports a live table in that form.