Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/pg-sprite/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,8 @@ func main() {
if errors.Is(err, verdict.ErrRefused) {
os.Exit(verdict.ExitCodeRefused)
}
if errors.Is(err, verdict.ErrAcceptedBlocking) {
os.Exit(verdict.ExitCodeAcceptedBlocking)
}
k.FatalIfErrorf(err)
}
8 changes: 4 additions & 4 deletions demo/tour.sh
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ dry_run() {
else
assert_eq "dry-run refusal exit of [$sql]" 2 "$status"
fi
assert_eq "plan format_version of [$sql]" 4 "$(jq -r '.format_version' <<<"$out")"
assert_eq "plan format_version of [$sql]" 5 "$(jq -r '.format_version' <<<"$out")"
assert_eq "disposition of [$sql]" "$disposition" "$(jq -r '.disposition' <<<"$out")"
assert_eq "route of [$sql]" "$route" "$(jq -r '.statements[0].route' <<<"$out")"
assert_eq "reason of [$sql]" "$reason" "$(jq -r '.statements[0].decisions[0].reason' <<<"$out")"
Expand Down Expand Up @@ -136,7 +136,7 @@ diff_plan() {
if [ "$CHECK" = 1 ]; then
out=$("$PGS" diff --url "$PG_DSN" --desired "$desired" --schema public --json) || status=$?
assert_eq "diff exit of [$desired]" 0 "$status"
assert_eq "diff format_version of [$desired]" 4 "$(jq -r '.format_version' <<<"$out")"
assert_eq "diff format_version of [$desired]" 5 "$(jq -r '.format_version' <<<"$out")"
assert_eq "statement count of [$desired]" "$count" "$(jq -r '.statements | length' <<<"$out")"
case "$(jq -r '.statements[0].sql' <<<"$out")" in
*"$fragment"*) ;;
Expand Down Expand Up @@ -164,7 +164,7 @@ diff_refused() {
if [ "$CHECK" = 1 ]; then
out=$("$PGS" diff --url "$PG_DSN" --desired "$desired" --schema public --json) || status=$?
assert_eq "diff refusal exit of [$desired]" 2 "$status"
assert_eq "diff format_version of [$desired]" 4 "$(jq -r '.format_version' <<<"$out")"
assert_eq "diff format_version of [$desired]" 5 "$(jq -r '.format_version' <<<"$out")"
assert_eq "diff disposition of [$desired]" refuse "$(jq -r '.disposition' <<<"$out")"
assert_eq "diff reason of [$desired]" unsupported-statement "$(jq -r '.statements[0].reason' <<<"$out")"
assert_eq "diff class of [$desired]" "$class" "$(jq -r '.statements[0].class' <<<"$out")"
Expand Down Expand Up @@ -199,7 +199,7 @@ run_pull() {
out=$("$PGS" diff --url "$PG_DSN" --schema public --desired "$desired" --json) || status=$?
assert_eq "diff exit of [$desired]" 0 "$status"
# Plan report contract version (plan.FormatVersion), same pin as diff_plan.
assert_eq "diff format_version of [$desired]" 4 "$(jq -r '.format_version' <<<"$out")"
assert_eq "diff format_version of [$desired]" 5 "$(jq -r '.format_version' <<<"$out")"
assert_eq "diff disposition of [$desired]" execute "$(jq -r '.disposition' <<<"$out")"
assert_eq "zero diff of [$desired]" 0 "$(jq -r '.statements | length' <<<"$out")"
else
Expand Down
45 changes: 37 additions & 8 deletions docs/cli-output-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ in `detail`. The set is closed and pinned by test (`verdict.Reasons()`).
| `not-native-safe-table-too-large` | The size guard skipped the optimistic attempt: the table exceeds the configured bound and the change is not provably metadata-only. |
| `insufficient-privileges` | The connected role lacks the access the change needs; `detail` names the exact missing GRANT (see [engine-role.md](engine-role.md)). |
| `unsupported-partitioned-parent` | The routed plan builds an index on a partitioned parent, where PostgreSQL cannot `CREATE INDEX CONCURRENTLY`. |
| `not-native-safe-budget-exceeded` | The optimistic attempt exceeded its lock or statement budget and was cancelled; the verdict's `cause` narrows which budget fired (a budget vocabulary, unrelated to the plan statement's create-shape `cause`). |
| `not-native-safe-budget-exceeded` | The optimistic attempt exceeded its lock or statement budget and was cancelled; the verdict's `cause` narrows which budget fired. The same `cause` field carries the partitioned-parent shape under `unsupported-partitioned-parent`; both are refusal causes, unrelated to the plan statement's create-shape `cause`. |
| `not-native-safe-rewrite-required` | The submitted form blocks and must run as a safer native sequence, but none could be constructed. |
| `backend-unavailable` | The change routes to an execution strategy this build does not implement (copy-and-swap). |
| `destructive-change` | The desired-state plan discards live structure — a dropped column, constraint, index, or `NOT NULL` — and desired-state execution runs no destructive statement; run the drop deliberately instead ([execution model](execution-model.md)). |
Expand All @@ -109,7 +109,7 @@ itself.
```console
$ pg-sprite migrate --alter 'ALTER TABLE users ADD COLUMN note text' --dry-run --json
{
"format_version": 4,
"format_version": 5,
"source": "alter",
"schema": "public",
"table": "users",
Expand Down Expand Up @@ -150,7 +150,7 @@ plans the safer online sequence instead: the decision carries it in
```console
$ pg-sprite migrate --alter 'ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email)' --dry-run --json
{
"format_version": 4,
"format_version": 5,
"source": "alter",
"schema": "public",
"table": "users",
Expand Down Expand Up @@ -208,6 +208,34 @@ $ pg-sprite migrate --alter 'ALTER TABLE users ADD CONSTRAINT users_email_key UN
}
```

An operator-accepted blocking refusal has a distinct marked outcome and exit
3; exit 0 remains exclusive to online-safe execution. Nothing produces this
outcome until `--accept-blocking` lands in a later change:

```text
executed without online safety (accepted blocking refusal)
table: public.users
refusal: by-design / index-statement
statement: DROP INDEX public.users_email_idx
safer: DROP INDEX CONCURRENTLY
budgets: lock 3s, statement 10m
```

```console
$ pg-sprite migrate --alter 'DROP INDEX public.users_email_idx' --accept-blocking public.users --lock-timeout 3s --statement-timeout 10m --json
{
"outcome": "executed-without-online-safety",
"reason": "index-statement",
"class": "by-design",
"statement": "DROP INDEX public.users_email_idx",
"table": "public.users",
"safer_idiom": "DROP INDEX CONCURRENTLY",
"blocking_passthrough": true,
"lock_timeout": "3s",
"statement_timeout": "10m"
}
```

### Refused: no online rewrite exists (`rewrite-required`) — exit 2

The column and its constraint arrive in one statement, so no online
Expand All @@ -221,7 +249,7 @@ column first, then build the constraint as a separate, named
```console
$ pg-sprite migrate --alter 'ALTER TABLE users ADD COLUMN nickname text UNIQUE' --dry-run --json
{
"format_version": 4,
"format_version": 5,
"source": "alter",
"schema": "public",
"table": "users",
Expand Down Expand Up @@ -258,7 +286,7 @@ implemented yet.
```console
$ pg-sprite migrate --alter 'ALTER TABLE users ALTER COLUMN id TYPE text' --dry-run --json
{
"format_version": 4,
"format_version": 5,
"source": "alter",
"schema": "public",
"table": "users",
Expand Down Expand Up @@ -295,7 +323,7 @@ The refusal cause is the report-level `reason`.
```console
$ pg-sprite migrate --alter 'CREATE INDEX events_created_idx ON events (created)' --dry-run --json
{
"format_version": 4,
"format_version": 5,
"source": "alter",
"schema": "public",
"table": "events",
Expand All @@ -313,6 +341,7 @@ $ pg-sprite migrate --alter 'CREATE INDEX events_created_idx ON events (created)
"disposition": "refuse",
"reason": "unsupported-partitioned-parent",
"class": "capability-boundary",
"blocking_passthrough_eligible": false,
"decisions": [
{
"operation": "CREATE INDEX events_created_idx",
Expand All @@ -334,7 +363,7 @@ the reviewer or orchestrator to gate on; `migrate` itself does not block it.
```console
$ pg-sprite migrate --alter 'ALTER TABLE users DROP COLUMN email' --dry-run --json
{
"format_version": 4,
"format_version": 5,
"source": "alter",
"schema": "public",
"table": "users",
Expand Down Expand Up @@ -461,7 +490,7 @@ CREATE TABLE users (
```console
$ pg-sprite diff --desired /tmp/users.sql --json
{
"format_version": 4,
"format_version": 5,
"source": "diff",
"schema": "public",
"table": "users",
Expand Down
2 changes: 1 addition & 1 deletion docs/lock-budgeted-passthrough.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,8 @@ while the contract is that the engine cannot vouch for online safety. Text begin
```text
executed without online safety (accepted blocking refusal)
table: app.orders
statement: DROP INDEX app.orders_created_at_idx
refusal: by-design / index-statement
statement: DROP INDEX app.orders_created_at_idx
safer: DROP INDEX CONCURRENTLY
budgets: lock 3s, statement 10m
```
Expand Down
11 changes: 7 additions & 4 deletions docs/plan-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ kinds, guidance, causes, classes, owners) and the fingerprint serialization are
changing the fingerprint definition is a contract change and bumps `format_version`, even if
no field is added or renamed.

The current version is **4**: version 4 added the `class` and `owner` fields on the report
The current version is **5**: version 5 added `blocking_passthrough_eligible` to every refused
statement; version 4 added the `class` and `owner` fields on the report
and on refused statements, each drawn from a closed vocabulary (see Classes and Owners);
version 3 added the statement-level `cause` field on greenfield statements the create path
refuses by shape; version 2 added the statement-level `guidance` field on `rewrite-required`
Expand Down Expand Up @@ -71,6 +72,7 @@ consumer rendering either into a shared surface must clamp and escape them.
| `cause` | string | greenfield create-shape refusals only | The create path's typed shape refusal (see Causes): why a table born in the run cannot carry this statement. Present exactly when the create path refused the statement — on a `diff`-source report with `table_exists: false`, that is every statement whose `disposition` is `refuse` and `reason` is `unsupported-statement`. Absent for every other refusal, including an `alter`-source refusal against a table that does not exist. Explanatory: excluded from the fingerprint. |
| `class` | string | refusals only | This statement's refusal class (see Classes): how a consumer routes it — wait for a capability, hand to an owner, use a safer idiom, fix the environment, or report a bug. Present exactly when `disposition` is `refuse`. Explanatory: excluded from the fingerprint. |
| `owner` | string | `no-online-safety-problem` refusals only | Who owns the work (see Owners). Present exactly when `class` is `no-online-safety-problem`. Explanatory: excluded from the fingerprint. |
| `blocking_passthrough_eligible` | bool | refusals only | Whether this typed refusal is in the closed accepted-blocking registry. Independent of flags and catalog lookups; explanatory and excluded from the fingerprint. |
| `decisions` | array | always | The planner's per-operation classifications (below). |
| `exec_sql` | array | native route | The ordered SQL the native backend would run — the safer sequence when the planner constructed one, or the statement as written for a table that does not exist yet (the greenfield create path runs plain builds; see Fingerprint). Absent for non-native routes. |
| `execution` | string | with `exec_sql` | The typed execution contract for `exec_sql` (see Execution contracts). A consumer that runs the statements itself branches on this — it is what says the steps must not be wrapped in a transaction block. Present exactly when `exec_sql` is. |
Expand Down Expand Up @@ -290,7 +292,7 @@ in `pkg/plan` — if the code drifts from this page, CI fails.

```json
{
"format_version": 4,
"format_version": 5,
"source": "alter",
"schema": "app",
"table": "orders",
Expand Down Expand Up @@ -333,7 +335,7 @@ A desired state that drops an index and adds a column with a constant default:

```json
{
"format_version": 4,
"format_version": 5,
"source": "diff",
"schema": "app",
"table": "orders",
Expand Down Expand Up @@ -400,7 +402,7 @@ A desired state for a table that does not exist yet, whose `CREATE TABLE` carrie

```json
{
"format_version": 4,
"format_version": 5,
"source": "diff",
"schema": "app",
"table": "gadgets",
Expand All @@ -419,6 +421,7 @@ A desired state for a table that does not exist yet, whose `CREATE TABLE` carrie
"disposition": "refuse",
"reason": "unsupported-statement",
"class": "by-design",
"blocking_passthrough_eligible": false,
"cause": "if-not-exists",
"decisions": [
{
Expand Down
33 changes: 18 additions & 15 deletions internal/cli/color_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,21 +164,24 @@ func TestSuggestTextColorWrapsLabelsOnly(t *testing.T) {
func fullVerdict(t *testing.T) verdict.Verdict {
t.Helper()
v := verdict.Verdict{
Outcome: verdict.OutcomeFailed,
Reason: verdict.ReasonIndexStatement,
Class: verdict.ClassNoOnlineSafetyProblem,
Owner: verdict.OwnerDirectOperator,
Cause: verdict.CauseLockBudget,
Code: "lock-budget-exceeded",
FailedStep: 2,
FailedStepSQL: `ALTER TABLE "t" VALIDATE CONSTRAINT "c"`,
Attempts: 3,
Statement: "ALTER TABLE t ADD COLUMN c int",
Table: "public.t",
Detail: "every field populated for the renderer parity lock",
SaferIdiom: "DROP INDEX CONCURRENTLY",
ExecutedSQL: []string{`ALTER TABLE "t" ADD CONSTRAINT "c" CHECK (x > 0) NOT VALID`},
Forced: true,
Outcome: verdict.OutcomeFailed,
Reason: verdict.ReasonIndexStatement,
Class: verdict.ClassNoOnlineSafetyProblem,
Owner: verdict.OwnerDirectOperator,
Cause: verdict.CauseLockBudget,
Code: "lock-budget-exceeded",
FailedStep: 2,
FailedStepSQL: `ALTER TABLE "t" VALIDATE CONSTRAINT "c"`,
Attempts: 3,
Statement: "ALTER TABLE t ADD COLUMN c int",
Table: "public.t",
Detail: "every field populated for the renderer parity lock",
SaferIdiom: "DROP INDEX CONCURRENTLY",
ExecutedSQL: []string{`ALTER TABLE "t" ADD CONSTRAINT "c" CHECK (x > 0) NOT VALID`},
Forced: true,
BlockingPassthrough: true,
LockTimeout: "3s",
StatementTimeout: "10m",
}
rv := reflect.ValueOf(v)
for i := range rv.NumField() {
Expand Down
13 changes: 11 additions & 2 deletions internal/cli/docs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"regexp"
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -74,7 +75,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, 9, "the doc publishes nine captured --json outputs")
require.Len(t, blocks, 10, "the doc publishes ten captured --json outputs")

metadataOnly := alterReport(t, "ALTER TABLE users ADD COLUMN note text",
"public", "users", usersFacts())
Expand All @@ -90,6 +91,14 @@ func TestCLIOutputExamplesMatchPipelineOutput(t *testing.T) {
`ALTER TABLE "public"."users" ADD CONSTRAINT "users_email_key" UNIQUE USING INDEX "users_email_key"`,
},
}
acceptedBlocking, err := (verdict.Verdict{
Statement: "DROP INDEX public.users_email_idx",
Table: "public.users",
SaferIdiom: "DROP INDEX CONCURRENTLY",
}).WithAcceptedBlocking(
verdict.ByDesign(verdict.ReasonIndexStatement).WithSite(verdict.RefusalSiteIndexSingleRelation),
3*time.Second, 10*time.Minute)
require.NoError(t, err)
rewriteRequired := alterReport(t, "ALTER TABLE users ADD COLUMN nickname text UNIQUE",
"public", "users", usersFacts())
backendUnavailable := alterReport(t, "ALTER TABLE users ALTER COLUMN id TYPE text",
Expand Down Expand Up @@ -131,7 +140,7 @@ func TestCLIOutputExamplesMatchPipelineOutput(t *testing.T) {
}
diff.Fingerprint = plan.Fingerprint(diff.Statements)

want := []any{metadataOnly, saferIdiom, executed, rewriteRequired,
want := []any{metadataOnly, saferIdiom, executed, acceptedBlocking, rewriteRequired,
backendUnavailable, partitioned, destructive, lintReport, diff}
for i, w := range want {
marshaled, err := json.Marshal(w)
Expand Down
16 changes: 12 additions & 4 deletions internal/cli/verdict_text.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,18 @@ import (
func writeVerdictText(out io.Writer, pal palette, v verdict.Verdict) error {
var b strings.Builder
b.WriteString(outcomeHeadline(pal, v))
if v.Outcome == verdict.OutcomeRefused {
if v.Table != "" {
fmt.Fprintf(&b, "\n %s %s", pal.bold("table:"), v.Table)
}
switch v.Outcome {
case verdict.OutcomeExecutedWithoutOnlineSafety:
fmt.Fprintf(&b, "\n %s %s / %s", pal.bold("refusal:"), v.Class, v.Reason)
case verdict.OutcomeRefused:
fmt.Fprintf(&b, "\n %s %s", pal.bold("class:"), v.Class)
if v.Owner != "" {
fmt.Fprintf(&b, "\n %s %s", pal.bold("owner:"), v.Owner)
}
}
if v.Table != "" {
fmt.Fprintf(&b, "\n %s %s", pal.bold("table:"), v.Table)
}
fmt.Fprintf(&b, "\n %s %s", pal.bold("statement:"), v.Statement)
if v.Attempts > 0 {
fmt.Fprintf(&b, "\n %s %d", pal.bold("attempts:"), v.Attempts)
Expand All @@ -36,6 +39,9 @@ func writeVerdictText(out io.Writer, pal palette, v verdict.Verdict) error {
if v.SaferIdiom != "" {
fmt.Fprintf(&b, "\n %s %s", pal.bold("safer:"), v.SaferIdiom)
}
if v.Outcome == verdict.OutcomeExecutedWithoutOnlineSafety {
fmt.Fprintf(&b, "\n %s lock %s, statement %s", pal.bold("budgets:"), v.LockTimeout, v.StatementTimeout)
}
if v.Forced {
fmt.Fprintf(&b, "\n %s the submitted form ran as-is (force acknowledged)", pal.bold("forced:"))
}
Expand Down Expand Up @@ -66,6 +72,8 @@ func outcomeHeadline(pal palette, v verdict.Verdict) string {
switch v.Outcome {
case verdict.OutcomeExecuted:
return pal.severity("help", "executed natively")
case verdict.OutcomeExecutedWithoutOnlineSafety:
return pal.severity("warning", "executed without online safety (accepted blocking refusal)")
case verdict.OutcomeRefused:
return pal.severity("error", fmt.Sprintf("refused (%s)", v.Reason))
case verdict.OutcomeFailed:
Expand Down
42 changes: 0 additions & 42 deletions pkg/migrate/refusal_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,48 +205,6 @@ func partitionRefusal(cause preflight.PartitionRefusalCause) (verdict.Refusal, b
return plan.PartitionRefusal(cause)
}

// AcceptedBlockingEligible reports whether a typed refusal is in the closed
// accepted-blocking registry. Unknown combinations fail closed.
func AcceptedBlockingEligible(r verdict.Refusal) bool {
eligible, decided := acceptedBlockingDecision(r)
return decided && eligible
}

// acceptedBlockingDecision is total over the registered reason and
// cause/site vocabulary. The bool pair is eligibility and whether the key
// has an explicit decision; completeness tests reject undecided additions.
func acceptedBlockingDecision(r verdict.Refusal) (bool, bool) {
switch r.Reason() {
case verdict.ReasonIndexStatement:
switch r.Site() {
case verdict.RefusalSiteIndexSingleRelation:
return r.Class() == verdict.ClassByDesign, true
case verdict.RefusalSiteIndexOther, "":
return false, true
default:
return false, false
}
case verdict.ReasonUnsupportedPartitionedParent:
switch r.Cause() {
case verdict.CauseParentBlockingIndexBuild:
return r.Class() == verdict.ClassCapabilityBoundary, true
case verdict.CauseParentConcurrentIndexBuild, verdict.CauseParentIndexAdoption,
verdict.CauseParentNotValidForeignKey:
return false, true
default:
return false, false
}
case verdict.ReasonUnsupportedStatement, verdict.ReasonTableTooLarge,
verdict.ReasonInsufficientPrivileges, verdict.ReasonBudgetExceeded,
verdict.ReasonRewriteRequired, verdict.ReasonBackendUnavailable,
verdict.ReasonDestructiveChange, verdict.ReasonPlanFingerprintMismatch,
verdict.ReasonCreateCollision:
return false, true
default:
return false, false
}
}

// isInSentinelSet reports whether err matches any sentinel in set.
func isInSentinelSet(err error, set []error) bool {
for _, sentinel := range set {
Expand Down
Loading
Loading