• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

supabase / cli / 21441313842

28 Jan 2026 02:04PM UTC coverage: 51.348% (-4.8%) from 56.176%
21441313842

Pull #4770

github

web-flow
Merge e775f7c37 into 3fe548f00
Pull Request #4770: feat: add `supabase dev` command

8 of 1155 new or added lines in 10 files covered. (0.69%)

7 existing lines in 2 files now uncovered.

6857 of 13354 relevant lines covered (51.35%)

5.78 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/internal/dev/differ.go
1
package dev
2

3
import (
4
        "bytes"
5
        "context"
6
        "fmt"
7
        "io"
8
        "regexp"
9
        "strings"
10
        "time"
11

12
        "github.com/docker/docker/api/types/container"
13
        "github.com/docker/docker/api/types/network"
14
        "github.com/go-errors/errors"
15
        "github.com/jackc/pgconn"
16
        "github.com/spf13/afero"
17
        "github.com/supabase/cli/internal/utils"
18
        "github.com/supabase/cli/pkg/migration"
19
        "github.com/supabase/cli/pkg/parser"
20
)
21

22
// DiffResult contains the outcome of a schema diff
23
type DiffResult struct {
24
        SQL      string
25
        HasDrops bool
26
        Drops    []string
27
}
28

29
// https://github.com/djrobstep/migra/blob/master/migra/statements.py#L6
30
var dropStatementPattern = regexp.MustCompile(`(?i)drop\s+`)
31

32
// shadowState holds the persistent shadow database state
33
var shadowState = &ShadowState{}
34

35
// DiffAndApply computes the diff between declared schemas and local DB, then applies changes.
36
// Returns true if any changes were applied (marking the session as dirty).
NEW
37
func DiffAndApply(ctx context.Context, fsys afero.Fs, w io.Writer) (bool, error) {
×
NEW
38
        totalStart := time.Now()
×
NEW
39

×
NEW
40
        // Step 1: Ensure shadow database is ready (uses template for fast reset)
×
NEW
41
        fmt.Fprintln(w, "Preparing shadow database...")
×
NEW
42
        stepStart := time.Now()
×
NEW
43
        shadowConfig, err := shadowState.EnsureShadowReady(ctx, fsys)
×
NEW
44
        if err != nil {
×
NEW
45
                return false, errors.Errorf("failed to prepare shadow database: %w", err)
×
NEW
46
        }
×
NEW
47
        timingLog.Printf("Shadow DB ready: %dms", time.Since(stepStart).Milliseconds())
×
NEW
48

×
NEW
49
        // Step 2: Apply declared schemas to shadow
×
NEW
50
        declared, err := loadSchemaFiles(fsys)
×
NEW
51
        if err != nil {
×
NEW
52
                return false, err
×
NEW
53
        }
×
54

NEW
55
        if len(declared) > 0 {
×
NEW
56
                fmt.Fprintln(w, "Applying declared schemas to shadow...")
×
NEW
57
                stepStart = time.Now()
×
NEW
58
                if err := shadowState.ApplyDeclaredSchemas(ctx, declared, fsys); err != nil {
×
NEW
59
                        return false, err
×
NEW
60
                }
×
NEW
61
                timingLog.Printf("Schemas applied to shadow: %dms", time.Since(stepStart).Milliseconds())
×
62
        }
63

64
        // Step 3: Diff local DB (current state) vs shadow (desired state) using pg-delta
NEW
65
        localConfig := pgconn.Config{
×
NEW
66
                Host:     utils.Config.Hostname,
×
NEW
67
                Port:     utils.Config.Db.Port,
×
NEW
68
                User:     "postgres",
×
NEW
69
                Password: utils.Config.Db.Password,
×
NEW
70
                Database: "postgres",
×
NEW
71
        }
×
NEW
72

×
NEW
73
        fmt.Fprintln(w, "Computing diff with pg-delta...")
×
NEW
74
        stepStart = time.Now()
×
NEW
75
        // source = local DB (current state), target = shadow DB (desired state)
×
NEW
76
        result, err := computeDiffPgDelta(ctx, localConfig, shadowConfig)
×
NEW
77
        if err != nil {
×
NEW
78
                return false, errors.Errorf("failed to compute diff: %w", err)
×
NEW
79
        }
×
NEW
80
        timingLog.Printf("pg-delta diff: %dms", time.Since(stepStart).Milliseconds())
×
NEW
81

×
NEW
82
        // Log the computed diff SQL in debug mode
×
NEW
83
        if result.SQL != "" {
×
NEW
84
                sqlLog.Printf("pg-delta computed diff:\n%s", result.SQL)
×
NEW
85
        }
×
86

NEW
87
        if result.SQL == "" {
×
NEW
88
                fmt.Fprintf(w, "%s No schema changes detected\n", utils.Green("✓"))
×
NEW
89
                timingLog.Printf("Total: %dms", time.Since(totalStart).Milliseconds())
×
NEW
90
                return false, nil
×
NEW
91
        }
×
92

93
        // Step 4: Show warnings for DROP statements
NEW
94
        if result.HasDrops {
×
NEW
95
                fmt.Fprintf(w, "%s Found DROP statements:\n", utils.Yellow("Warning:"))
×
NEW
96
                for _, drop := range result.Drops {
×
NEW
97
                        fmt.Fprintf(w, "    %s\n", utils.Yellow(drop))
×
NEW
98
                }
×
99
        }
100

101
        // Step 5: Apply changes to local database
NEW
102
        fmt.Fprintln(w, "Applying changes to local database...")
×
NEW
103
        stepStart = time.Now()
×
NEW
104
        if err := applyDiff(ctx, localConfig, result.SQL); err != nil {
×
NEW
105
                return false, errors.Errorf("failed to apply changes: %w", err)
×
NEW
106
        }
×
NEW
107
        timingLog.Printf("Applied to local DB: %dms", time.Since(stepStart).Milliseconds())
×
NEW
108

×
NEW
109
        fmt.Fprintf(w, "%s Schema changes applied successfully\n", utils.Green("✓"))
×
NEW
110
        showAppliedStatements(w, result.SQL)
×
NEW
111

×
NEW
112
        timingLog.Printf("Total: %dms", time.Since(totalStart).Milliseconds())
×
NEW
113
        return true, nil
×
114
}
115

116
// CleanupShadow removes the persistent shadow container
NEW
117
func CleanupShadow(ctx context.Context) {
×
NEW
118
        shadowState.Cleanup(ctx)
×
NEW
119
}
×
120

121
// InvalidateShadowTemplate marks the shadow template as needing rebuild
122
// Call this when migrations change so the shadow rebuilds with new migrations
NEW
123
func InvalidateShadowTemplate() {
×
NEW
124
        shadowState.TemplateReady = false
×
NEW
125
        shadowState.MigrationsHash = ""
×
NEW
126
        timingLog.Printf("Shadow template invalidated - will rebuild on next diff")
×
NEW
127
}
×
128

129
const (
130
        // Bun image for running pg-delta CLI
131
        bunImage = "oven/bun:canary-alpine"
132
        // Volume name for caching Bun packages
133
        bunCacheVolume = "supabase_bun_cache"
134
        // pg-delta package version
135
        pgDeltaPackage = "@supabase/pg-delta@1.0.0-alpha.2"
136
)
137

138
// computeDiffPgDelta uses pg-delta (via Bun container) to compute the difference
139
// source = current state (local DB), target = desired state (shadow DB)
140
//
141
// pg-delta exit codes:
142
//   - 0: No changes detected (stdout: "No changes detected.")
143
//   - 2: Changes detected (stdout: SQL statements)
144
//   - other: Error
NEW
145
func computeDiffPgDelta(ctx context.Context, source, target pgconn.Config) (*DiffResult, error) {
×
NEW
146
        sourceURL := utils.ToPostgresURL(source)
×
NEW
147
        targetURL := utils.ToPostgresURL(target)
×
NEW
148

×
NEW
149
        // Build the pg-delta CLI command
×
NEW
150
        cmd := []string{
×
NEW
151
                "x", pgDeltaPackage, "plan",
×
NEW
152
                "--source", sourceURL,
×
NEW
153
                "--target", targetURL,
×
NEW
154
                "--integration", "supabase",
×
NEW
155
                "--format", "sql",
×
NEW
156
                "--role", "postgres",
×
NEW
157
        }
×
NEW
158

×
NEW
159
        var stdout, stderr bytes.Buffer
×
NEW
160
        err := utils.DockerRunOnceWithConfig(
×
NEW
161
                ctx,
×
NEW
162
                container.Config{
×
NEW
163
                        Image: bunImage,
×
NEW
164
                        Cmd:   cmd,
×
NEW
165
                        Env:   []string{"BUN_INSTALL_CACHE_DIR=/bun-cache"},
×
NEW
166
                },
×
NEW
167
                container.HostConfig{
×
NEW
168
                        Binds:       []string{bunCacheVolume + ":/bun-cache:rw"},
×
NEW
169
                        NetworkMode: network.NetworkHost,
×
NEW
170
                },
×
NEW
171
                network.NetworkingConfig{},
×
NEW
172
                "",
×
NEW
173
                &stdout,
×
NEW
174
                &stderr,
×
NEW
175
        )
×
NEW
176

×
NEW
177
        // Trim whitespace from output
×
NEW
178
        output := strings.TrimSpace(stdout.String())
×
NEW
179

×
NEW
180
        // Handle pg-delta exit codes:
×
NEW
181
        // - Exit 0: No changes (output may be "No changes detected." or similar)
×
NEW
182
        // - Exit 2: Changes detected (output contains SQL)
×
NEW
183
        // - Other exits: Real errors
×
NEW
184
        if err != nil {
×
NEW
185
                // Check if it's exit code 2 (changes detected) - this is expected
×
NEW
186
                if strings.Contains(err.Error(), "exit 2") {
×
NEW
187
                        // Exit 2 means changes were detected, stdout has the SQL
×
NEW
188
                        drops := findDropStatements(output)
×
NEW
189
                        return &DiffResult{
×
NEW
190
                                SQL:      output,
×
NEW
191
                                HasDrops: len(drops) > 0,
×
NEW
192
                                Drops:    drops,
×
NEW
193
                        }, nil
×
NEW
194
                }
×
195
                // Any other error is a real failure
NEW
196
                return nil, errors.Errorf("pg-delta failed: %w\n%s", err, stderr.String())
×
197
        }
198

199
        // Exit 0: No changes detected
200
        // Check for "No changes" message or empty output
NEW
201
        if output == "" || strings.Contains(strings.ToLower(output), "no changes") {
×
NEW
202
                return &DiffResult{
×
NEW
203
                        SQL:      "",
×
NEW
204
                        HasDrops: false,
×
NEW
205
                        Drops:    nil,
×
NEW
206
                }, nil
×
NEW
207
        }
×
208

209
        // Exit 0 but has SQL output - treat as changes (shouldn't normally happen)
NEW
210
        drops := findDropStatements(output)
×
NEW
211
        return &DiffResult{
×
NEW
212
                SQL:      output,
×
NEW
213
                HasDrops: len(drops) > 0,
×
NEW
214
                Drops:    drops,
×
NEW
215
        }, nil
×
216
}
217

218
// findDropStatements extracts DROP statements from SQL
NEW
219
func findDropStatements(sql string) []string {
×
NEW
220
        lines, err := parser.SplitAndTrim(strings.NewReader(sql))
×
NEW
221
        if err != nil {
×
NEW
222
                return nil
×
NEW
223
        }
×
NEW
224
        var drops []string
×
NEW
225
        for _, line := range lines {
×
NEW
226
                if dropStatementPattern.MatchString(line) {
×
NEW
227
                        drops = append(drops, line)
×
NEW
228
                }
×
229
        }
NEW
230
        return drops
×
231
}
232

233
// applyDiff executes the diff SQL on the target database without recording in migration history
NEW
234
func applyDiff(ctx context.Context, config pgconn.Config, sql string) error {
×
NEW
235
        conn, err := utils.ConnectLocalPostgres(ctx, config)
×
NEW
236
        if err != nil {
×
NEW
237
                return err
×
NEW
238
        }
×
NEW
239
        defer conn.Close(context.Background())
×
NEW
240

×
NEW
241
        // Parse the SQL into statements
×
NEW
242
        m, err := migration.NewMigrationFromReader(strings.NewReader(sql))
×
NEW
243
        if err != nil {
×
NEW
244
                return errors.Errorf("failed to parse diff SQL: %w", err)
×
NEW
245
        }
×
246

247
        // Skip inserting to migration history (no version = no history entry)
NEW
248
        m.Version = ""
×
NEW
249

×
NEW
250
        // Execute the statements
×
NEW
251
        return m.ExecBatch(ctx, conn)
×
252
}
253

254
// showAppliedStatements prints the applied SQL statements
NEW
255
func showAppliedStatements(w io.Writer, sql string) {
×
NEW
256
        lines, err := parser.SplitAndTrim(strings.NewReader(sql))
×
NEW
257
        if err != nil {
×
NEW
258
                return
×
NEW
259
        }
×
260

NEW
261
        fmt.Fprintln(w, "Applied:")
×
NEW
262
        for _, line := range lines {
×
NEW
263
                // Skip empty lines and comments
×
NEW
264
                trimmed := strings.TrimSpace(line)
×
NEW
265
                if trimmed == "" || strings.HasPrefix(trimmed, "--") {
×
NEW
266
                        continue
×
267
                }
NEW
268
                fmt.Fprintf(w, "    %s\n", line)
×
269
        }
270
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc