diff --git a/docs/cli-output-examples.md b/docs/cli-output-examples.md index b5e62e64..b26597dc 100644 --- a/docs/cli-output-examples.md +++ b/docs/cli-output-examples.md @@ -54,7 +54,9 @@ and applies to `insufficient-privileges`, both exit 2. The JSON report schema is - [Migrate](#migrate) - [Runs as written (`metadata-only`) — exit 0](#runs-as-written-metadata-only--exit-0) - [Safer-sequence substitution (`safer-idiom`) — exit 0](#safer-sequence-substitution-safer-idiom--exit-0) - - [Real execution](#real-execution) + - [Real execution — exit 0](#real-execution--exit-0) + - [Accepted blocking refusal (`index-statement`) — exit 3](#accepted-blocking-refusal-index-statement--exit-3) + - [Failed mid-sequence (`execution-failed`) — exit 1](#failed-mid-sequence-execution-failed--exit-1) - [Refused: no online rewrite exists (`rewrite-required`) — exit 2](#refused-no-online-rewrite-exists-rewrite-required--exit-2) - [Routed to copy-and-swap (`type-rewrite`) — exit 0](#routed-to-copy-and-swap-type-rewrite--exit-0) - [Refused: the engine does not carry the operation yet (`backend-unavailable`) — exit 2](#refused-the-engine-does-not-carry-the-operation-yet-backend-unavailable--exit-2) @@ -239,7 +241,7 @@ $ pg-sprite migrate --alter 'ALTER TABLE users ADD CONSTRAINT users_email_key UN } ``` -### Real execution +### Real execution — exit 0 The same statement without `--dry-run` runs the substituted sequence for real. The verdict names what was executed and every step that committed @@ -259,6 +261,8 @@ $ pg-sprite migrate --alter 'ALTER TABLE users ADD CONSTRAINT users_email_key UN } ``` +### Accepted blocking refusal (`index-statement`) — exit 3 + An operator-accepted blocking refusal has a distinct marked outcome and exit 3; exit 0 remains exclusive to online-safe execution. `--accept-blocking` takes the schema-qualified table whose lock the operator accepts, must match @@ -290,6 +294,53 @@ $ pg-sprite migrate --alter 'DROP INDEX public.users_email_idx' --accept-blockin } ``` +### Failed mid-sequence (`execution-failed`) — exit 1 + +Exit 1 means execution started and PostgreSQL refused a step, or the command +stopped before anything ran. Here the safer sequence for a `CHECK` constraint +adds it `NOT VALID`, which commits, and then fails to validate it because a +row has an upper-case email. The verdict on stdout names the failed step and +the committed prefix that remains (`executed_sql`); the PostgreSQL error, +with its SQLSTATE, goes to stderr. The constraint now exists `NOT VALID`, so +fix the rows and then run the failed step (`failed_step_sql`) to finish. Do +not rerun the original `ADD CONSTRAINT`: the constraint already exists, so it +fails with `duplicate_object` (see the +[retry paths](execution-model.md#the-committed-prefix)): + +```console +$ pg-sprite migrate --alter 'ALTER TABLE users ADD CONSTRAINT users_email_lower CHECK (email = lower(email))' --json +{ + "outcome": "failed", + "code": "execution-failed", + "failed_step": 2, + "failed_step_sql": "ALTER TABLE \"public\".\"users\" VALIDATE CONSTRAINT \"users_email_lower\"", + "statement": "ALTER TABLE public.users ADD CONSTRAINT users_email_lower CHECK (email = lower(email))", + "table": "public.users", + "detail": "sequence step 2 of 2 failed; the 1 committed steps' state remains — the planner sequence's partial-failure contract says how a retry resumes", + "executed_sql": [ + "ALTER TABLE public.users ADD CONSTRAINT users_email_lower CHECK (email = lower(email)) NOT VALID" + ] +} +``` + +```text +pg-sprite: error: run schema change on public.users: sequence step 2 of 2 (validate-constraint) failed; steps before it committed and their state remains: optimistic attempt: ERROR: check constraint "users_email_lower" of relation "users" is violated by some row (SQLSTATE 23514) +``` + +When `code` is `commit-outcome-unknown`, the failed step's `COMMIT` may have +landed; inspect the catalog before retrying. + +The other exit 1 is an error before anything runs — bad flags, an unreachable +database, or an acknowledgement that names the wrong table. It prints no +verdict, even with `--json`; stdout is empty and the error is on stderr: + +```console +$ pg-sprite migrate --alter 'ALTER TABLE users DROP COLUMN email' --allow-destructive public.orders +pg-sprite: error: the destructive acknowledgement must name the resolved target table "public.users", got "public.orders"; nothing was executed +$ echo $? +1 +``` + ### Refused: no online rewrite exists (`rewrite-required`) — exit 2 The column and its constraint arrive in one statement, so no online diff --git a/internal/cli/docs_test.go b/internal/cli/docs_test.go index 6e9578ee..d9961cfb 100644 --- a/internal/cli/docs_test.go +++ b/internal/cli/docs_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/block/pg-sprite/pkg/executor" "github.com/block/pg-sprite/pkg/lint" "github.com/block/pg-sprite/pkg/migrate" "github.com/block/pg-sprite/pkg/plan" @@ -76,7 +77,7 @@ func TestCLIOutputExamplesMatchPipelineOutput(t *testing.T) { raw, err := os.ReadFile(cliOutputExamplesDoc) require.NoError(t, err) blocks := regexp.MustCompile("(?s)```console\n\\$ pg-sprite [^\n]*--json\n(.*?)```").FindAllStringSubmatch(string(raw), -1) - require.Len(t, blocks, 13, "the doc publishes thirteen captured --json outputs") + require.Len(t, blocks, 14, "the doc publishes fourteen captured --json outputs") metadataOnly := alterReport(t, "ALTER TABLE users ADD COLUMN note text", "public", "users", usersFacts()) @@ -104,6 +105,22 @@ func TestCLIOutputExamplesMatchPipelineOutput(t *testing.T) { "public", "users", usersFacts()) copyAndSwapRouted := alterReport(t, "ALTER TABLE users ALTER COLUMN id TYPE text", "public", "users", usersFacts()) + // The failed verdict is captured from a run: a CHECK whose NOT VALID + // step commits and whose VALIDATE step then meets a violating row. Its + // detail is the migrate package's rendering of a mid-sequence failure. + failedSequence := verdict.Verdict{ + Outcome: verdict.OutcomeFailed, + Code: string(executor.CodeExecutionFailed), + FailedStep: 2, + FailedStepSQL: `ALTER TABLE "public"."users" VALIDATE CONSTRAINT "users_email_lower"`, + Statement: "ALTER TABLE public.users ADD CONSTRAINT users_email_lower CHECK (email = lower(email))", + Table: "public.users", + Detail: "sequence step 2 of 2 failed; the 1 committed steps' state remains — " + + "the planner sequence's partial-failure contract says how a retry resumes", + ExecutedSQL: []string{ + "ALTER TABLE public.users ADD CONSTRAINT users_email_lower CHECK (email = lower(email)) NOT VALID", + }, + } // The executed verdict is captured from a run; its detail is the // verdict's own rendering of the verification pass and the retained // source's fate, which the migrate package's tests pin. @@ -168,7 +185,7 @@ func TestCLIOutputExamplesMatchPipelineOutput(t *testing.T) { } diff.Fingerprint = plan.Fingerprint(diff.Statements) - want := []any{metadataOnly, saferIdiom, executed, acceptedBlocking, rewriteRequired, + want := []any{metadataOnly, saferIdiom, executed, acceptedBlocking, failedSequence, rewriteRequired, copyAndSwapRouted, executedViaCopyAndSwap, backendUnavailable, partitioned, destructiveRefused, destructive, lintReport, diff} for i, w := range want {