Skip to content

Commit 5be6e90

Browse files
authored
fix(pg-delta): declarative apply error results (#5082)
* fix(pg-delta): declarative apply error results Improve readability report for decalrative appy errors wrapping * chore: upgrade pg-delta to alpha 13
1 parent 3be2887 commit 5be6e90

7 files changed

Lines changed: 239 additions & 29 deletions

File tree

internal/db/diff/templates/pgdelta.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import {
22
createPlan,
33
deserializeCatalog,
44
formatSqlStatements,
5-
} from "npm:@supabase/pg-delta@1.0.0-alpha.11";
6-
import { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.11/integrations/supabase";
5+
} from "npm:@supabase/pg-delta@1.0.0-alpha.13";
6+
import { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.13/integrations/supabase";
77

88
async function resolveInput(ref: string | undefined) {
99
if (!ref) {

internal/db/diff/templates/pgdelta_catalog_export.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
extractCatalog,
66
serializeCatalog,
77
stringifyCatalogSnapshot,
8-
} from "npm:@supabase/pg-delta@1.0.0-alpha.11";
8+
} from "npm:@supabase/pg-delta@1.0.0-alpha.13";
99

1010
const target = Deno.env.get("TARGET");
1111
const role = Deno.env.get("ROLE") ?? undefined;

internal/db/diff/templates/pgdelta_declarative_export.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import {
55
createPlan,
66
deserializeCatalog,
77
exportDeclarativeSchema,
8-
} from "npm:@supabase/pg-delta@1.0.0-alpha.11";
9-
import { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.11/integrations/supabase";
8+
} from "npm:@supabase/pg-delta@1.0.0-alpha.13";
9+
import { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.13/integrations/supabase";
1010

1111
async function resolveInput(ref: string | undefined) {
1212
if (!ref) {
@@ -21,14 +21,6 @@ async function resolveInput(ref: string | undefined) {
2121

2222
const source = Deno.env.get("SOURCE");
2323
const target = Deno.env.get("TARGET");
24-
supabase.filter = {
25-
// Also allow dropped extensions from migrations to be captured in the declarative schema export
26-
// TODO: fix upstream bug into pgdelta supabase integration
27-
or: [
28-
...supabase.filter!.or!,
29-
{ objectType: "extension", operation: "drop", scope: "object" },
30-
],
31-
};
3224

3325
const includedSchemas = Deno.env.get("INCLUDED_SCHEMAS");
3426
if (includedSchemas) {
@@ -46,7 +38,6 @@ let formatOptions = undefined;
4638
if (formatOptionsRaw) {
4739
formatOptions = JSON.parse(formatOptionsRaw);
4840
}
49-
5041
try {
5142
const result = await createPlan(
5243
await resolveInput(source),
@@ -66,6 +57,7 @@ try {
6657
);
6758
} else {
6859
const output = exportDeclarativeSchema(result, {
60+
integration: supabase,
6961
formatOptions,
7062
});
7163
console.log(

internal/db/pgcache/cache.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import {
3434
extractCatalog,
3535
serializeCatalog,
3636
stringifyCatalogSnapshot,
37-
} from "npm:@supabase/pg-delta@1.0.0-alpha.11";
37+
} from "npm:@supabase/pg-delta@1.0.0-alpha.13";
3838
const target = Deno.env.get("TARGET");
3939
const role = Deno.env.get("ROLE") ?? undefined;
4040
if (!target) {

internal/pgdelta/apply.go

Lines changed: 136 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ import (
88
"fmt"
99
"os"
1010
"path/filepath"
11+
"strings"
1112

1213
"github.com/go-errors/errors"
1314
"github.com/jackc/pgconn"
1415
"github.com/spf13/afero"
16+
"github.com/spf13/viper"
1517
"github.com/supabase/cli/internal/utils"
1618
)
1719

@@ -22,13 +24,129 @@ var pgDeltaDeclarativeApplyScript string
2224
//
2325
// The fields are surfaced to provide concise CLI feedback after apply runs.
2426
type ApplyResult struct {
25-
Status string `json:"status"`
26-
TotalStatements int `json:"totalStatements"`
27-
TotalRounds int `json:"totalRounds"`
28-
TotalApplied int `json:"totalApplied"`
29-
TotalSkipped int `json:"totalSkipped"`
30-
Errors []string `json:"errors"`
31-
StuckStatements []string `json:"stuckStatements"`
27+
Status string `json:"status"`
28+
TotalStatements int `json:"totalStatements"`
29+
TotalRounds int `json:"totalRounds"`
30+
TotalApplied int `json:"totalApplied"`
31+
TotalSkipped int `json:"totalSkipped"`
32+
Errors []ApplyIssue `json:"errors"`
33+
StuckStatements []ApplyIssue `json:"stuckStatements"`
34+
}
35+
36+
// ApplyIssue models a pg-delta apply error or stuck statement.
37+
//
38+
// pg-delta may emit either a plain string or a structured object, so unmarshal
39+
// needs to gracefully handle both forms.
40+
type ApplyIssue struct {
41+
Statement *ApplyStatement `json:"statement,omitempty"`
42+
Code string `json:"code,omitempty"`
43+
Message string `json:"message,omitempty"`
44+
IsDependencyError bool `json:"isDependencyError,omitempty"`
45+
}
46+
47+
type ApplyStatement struct {
48+
ID string `json:"id"`
49+
SQL string `json:"sql"`
50+
StatementClass string `json:"statementClass"`
51+
}
52+
53+
func (i *ApplyIssue) UnmarshalJSON(data []byte) error {
54+
trimmed := bytes.TrimSpace(data)
55+
if bytes.Equal(trimmed, []byte("null")) {
56+
*i = ApplyIssue{}
57+
return nil
58+
}
59+
var message string
60+
if err := json.Unmarshal(trimmed, &message); err == nil {
61+
*i = ApplyIssue{Message: message}
62+
return nil
63+
}
64+
type alias ApplyIssue
65+
var parsed alias
66+
if err := json.Unmarshal(trimmed, &parsed); err != nil {
67+
return err
68+
}
69+
*i = ApplyIssue(parsed)
70+
return nil
71+
}
72+
73+
func formatApplyFailure(result ApplyResult) string {
74+
totalStatements := result.TotalStatements
75+
if totalStatements == 0 {
76+
totalStatements = result.TotalApplied + result.TotalSkipped + len(result.StuckStatements)
77+
}
78+
lines := []string{
79+
fmt.Sprintf("pg-delta apply returned status %q.", result.Status),
80+
fmt.Sprintf("%d/%d statements applied in %d round(s); %d skipped.", result.TotalApplied, totalStatements, result.TotalRounds, result.TotalSkipped),
81+
}
82+
if len(result.Errors) > 0 {
83+
lines = append(lines, "Errors:")
84+
for _, issue := range result.Errors {
85+
lines = append(lines, formatApplyIssue(issue))
86+
}
87+
}
88+
if len(result.StuckStatements) > 0 {
89+
lines = append(lines, "Stuck statements:")
90+
for _, issue := range result.StuckStatements {
91+
lines = append(lines, formatApplyIssue(issue))
92+
}
93+
}
94+
return strings.Join(lines, "\n")
95+
}
96+
97+
func formatApplyIssue(issue ApplyIssue) string {
98+
if issue.Statement == nil {
99+
return "- " + formatApplyIssueMessage(issue)
100+
}
101+
title := "- " + issue.Statement.ID
102+
if issue.Statement.StatementClass != "" {
103+
title += " [" + issue.Statement.StatementClass + "]"
104+
}
105+
lines := []string{title}
106+
lines = append(lines, " "+formatApplyIssueMessage(issue))
107+
if sql := formatStatementSQL(issue.Statement.SQL); sql != "" {
108+
lines = append(lines, " SQL: "+sql)
109+
}
110+
return strings.Join(lines, "\n")
111+
}
112+
113+
func formatApplyIssueMessage(issue ApplyIssue) string {
114+
message := strings.TrimSpace(issue.Message)
115+
if message == "" {
116+
message = "unknown pg-delta issue"
117+
}
118+
var metadata []string
119+
if issue.Code != "" {
120+
metadata = append(metadata, "SQLSTATE "+issue.Code)
121+
}
122+
if issue.IsDependencyError {
123+
metadata = append(metadata, "dependency error")
124+
}
125+
if len(metadata) == 0 {
126+
return message
127+
}
128+
return fmt.Sprintf("%s (%s)", message, strings.Join(metadata, ", "))
129+
}
130+
131+
func formatStatementSQL(sql string) string {
132+
normalized := strings.Join(strings.Fields(sql), " ")
133+
const maxLen = 120
134+
if len(normalized) <= maxLen {
135+
return normalized
136+
}
137+
return normalized[:maxLen-3] + "..."
138+
}
139+
140+
func formatDebugJSON(raw []byte) string {
141+
trimmed := bytes.TrimSpace(raw)
142+
if len(trimmed) == 0 {
143+
return ""
144+
}
145+
var indented bytes.Buffer
146+
if err := json.Indent(&indented, trimmed, "", " "); err == nil {
147+
return indented.String()
148+
}
149+
return string(trimmed)
32150
}
33151

34152
// ApplyDeclarative applies files from supabase/declarative to the target
@@ -64,14 +182,19 @@ func ApplyDeclarative(ctx context.Context, config pgconn.Config, fsys afero.Fs)
64182

65183
var result ApplyResult
66184
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
67-
return errors.Errorf("failed to parse pg-delta apply output: %w\nstdout: %s", err, stdout.String())
185+
if viper.GetBool("DEBUG") {
186+
return errors.Errorf("failed to parse pg-delta apply output: %w\nstdout: %s", err, stdout.String())
187+
}
188+
return errors.Errorf("failed to parse pg-delta apply output: %w", err)
68189
}
69190
if result.Status != "success" {
70-
if len(result.Errors) > 0 {
71-
fmt.Fprintf(os.Stderr, "Errors: %v\n", result.Errors)
72-
}
73-
if len(result.StuckStatements) > 0 {
74-
fmt.Fprintf(os.Stderr, "Stuck statements: %v\n", result.StuckStatements)
191+
if viper.GetBool("DEBUG") {
192+
if debugJSON := formatDebugJSON(stdout.Bytes()); len(debugJSON) > 0 {
193+
fmt.Fprintln(os.Stderr, "pg-delta apply result:")
194+
fmt.Fprintln(os.Stderr, debugJSON)
195+
}
196+
} else {
197+
fmt.Fprintln(os.Stderr, formatApplyFailure(result))
75198
}
76199
return errors.Errorf("pg-delta declarative apply failed with status: %s", result.Status)
77200
}

internal/pgdelta/apply_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package pgdelta
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestApplyResultUnmarshalStructuredStuckStatements(t *testing.T) {
10+
raw := []byte(`{
11+
"status": "stuck",
12+
"totalStatements": 34,
13+
"totalRounds": 2,
14+
"totalApplied": 29,
15+
"totalSkipped": 0,
16+
"errors": [],
17+
"stuckStatements": [
18+
{
19+
"statement": {
20+
"id": "cluster/extensions/pgmq.sql:0",
21+
"sql": "CREATE EXTENSION pgmq WITH SCHEMA pgmq;",
22+
"statementClass": "CREATE_EXTENSION"
23+
},
24+
"code": "3F000",
25+
"message": "schema \"pgmq\" does not exist",
26+
"isDependencyError": true
27+
}
28+
]
29+
}`)
30+
31+
var result ApplyResult
32+
if err := json.Unmarshal(raw, &result); err != nil {
33+
t.Fatalf("json.Unmarshal() error = %v", err)
34+
}
35+
36+
if got, want := len(result.StuckStatements), 1; got != want {
37+
t.Fatalf("len(StuckStatements) = %d, want %d", got, want)
38+
}
39+
40+
stuck := result.StuckStatements[0]
41+
if stuck.Statement == nil {
42+
t.Fatal("expected structured statement details")
43+
}
44+
if got, want := stuck.Statement.ID, "cluster/extensions/pgmq.sql:0"; got != want {
45+
t.Fatalf("Statement.ID = %q, want %q", got, want)
46+
}
47+
if got, want := stuck.Statement.StatementClass, "CREATE_EXTENSION"; got != want {
48+
t.Fatalf("Statement.StatementClass = %q, want %q", got, want)
49+
}
50+
if got, want := stuck.Code, "3F000"; got != want {
51+
t.Fatalf("Code = %q, want %q", got, want)
52+
}
53+
if got, want := stuck.Message, `schema "pgmq" does not exist`; got != want {
54+
t.Fatalf("Message = %q, want %q", got, want)
55+
}
56+
if !stuck.IsDependencyError {
57+
t.Fatal("expected dependency error to be preserved")
58+
}
59+
}
60+
61+
func TestFormatApplyFailure(t *testing.T) {
62+
result := ApplyResult{
63+
Status: "stuck",
64+
TotalStatements: 34,
65+
TotalRounds: 2,
66+
TotalApplied: 29,
67+
TotalSkipped: 0,
68+
StuckStatements: []ApplyIssue{
69+
{
70+
Statement: &ApplyStatement{
71+
ID: "cluster/extensions/pgmq.sql:0",
72+
SQL: "CREATE EXTENSION pgmq WITH SCHEMA pgmq;",
73+
StatementClass: "CREATE_EXTENSION",
74+
},
75+
Code: "3F000",
76+
Message: `schema "pgmq" does not exist`,
77+
IsDependencyError: true,
78+
},
79+
},
80+
}
81+
82+
formatted := formatApplyFailure(result)
83+
assertContains(t, formatted, `pg-delta apply returned status "stuck"`)
84+
assertContains(t, formatted, `29/34 statements applied in 2 round(s)`)
85+
assertContains(t, formatted, `cluster/extensions/pgmq.sql:0 [CREATE_EXTENSION]`)
86+
assertContains(t, formatted, `schema "pgmq" does not exist (SQLSTATE 3F000, dependency error)`)
87+
assertContains(t, formatted, `SQL: CREATE EXTENSION pgmq WITH SCHEMA pgmq;`)
88+
}
89+
90+
func assertContains(t *testing.T, text, want string) {
91+
t.Helper()
92+
if !strings.Contains(text, want) {
93+
t.Fatalf("expected %q to contain %q", text, want)
94+
}
95+
}

internal/pgdelta/templates/pgdelta_declarative_apply.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import {
44
applyDeclarativeSchema,
55
loadDeclarativeSchema,
6-
} from "npm:@supabase/pg-delta@1.0.0-alpha.11/declarative";
6+
} from "npm:@supabase/pg-delta@1.0.0-alpha.13/declarative";
77

88
const schemaPath = Deno.env.get("SCHEMA_PATH");
99
const target = Deno.env.get("TARGET");

0 commit comments

Comments
 (0)