Repository navigation
Expand file tree
/
Copy pathdb_schema_declarative.go
More file actions
475 lines (430 loc) · 16.6 KB
/
Copy pathdb_schema_declarative.go
File metadata and controls
475 lines (430 loc) · 16.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
package cmd
import (
"context"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/jackc/pgconn"
"github.com/jackc/pgx/v4"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/supabase/cli/internal/db/declarative"
"github.com/supabase/cli/internal/db/reset"
"github.com/supabase/cli/internal/db/start"
"github.com/supabase/cli/internal/migration/new"
"github.com/supabase/cli/internal/utils"
"github.com/supabase/cli/internal/utils/flags"
"github.com/supabase/cli/pkg/config"
"github.com/supabase/cli/pkg/migration"
"golang.org/x/term"
)
const defaultDeclarativeSyncName = "declarative_sync"
var (
declarativeNoCache bool
declarativeOverwrite bool
declarativeLocal bool
declarativeReset bool
declarativeApply bool
declarativeFile string
declarativeName string
// dbSchemaCmd groups schema-related subcommands under `supabase db schema`.
dbSchemaCmd = &cobra.Command{
Use: "schema",
Short: "Manage database schema",
}
// dbDeclarativeCmd introduces a dedicated command group for declarative workflows.
dbDeclarativeCmd = &cobra.Command{
Use: "declarative",
Short: "Manage declarative database schemas",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if err := flags.LoadConfig(afero.NewOsFs()); err != nil {
return err
}
// If the user has passed the --experimental flag and pg-delta is not enabled, enable it
// so in the rest of the code we can know that we're running pg-delta logic.
if viper.GetBool("EXPERIMENTAL") && !utils.IsPgDeltaEnabled() {
utils.Config.Experimental.PgDelta = &config.PgDeltaConfig{Enabled: true}
}
if !utils.IsPgDeltaEnabled() {
utils.CmdSuggestion = fmt.Sprintf("Either pass %s or add %s with %s to %s",
utils.Aqua("--experimental"),
utils.Aqua("[experimental.pgdelta]"),
utils.Aqua("enabled = true"),
utils.Bold(utils.ConfigPath))
return errors.New("declarative commands require --experimental flag or pg-delta enabled in config")
}
// If the config.toml has [experimental.pgdelta] enabled = true, set the EXPERIMENTAL flag to true
// so the follow-up PersistentPreRunE can run the pg-delta logic.
if utils.Config.Experimental.PgDelta.Enabled {
viper.Set("EXPERIMENTAL", true)
}
return cmd.Root().PersistentPreRunE(cmd, args)
},
}
// dbDeclarativeSyncCmd generates a new migration from declarative schema.
dbDeclarativeSyncCmd = &cobra.Command{
Use: "sync",
Short: "Generate a new migration from declarative schema",
RunE: runDeclarativeSync,
}
// dbDeclarativeGenerateCmd generates declarative files directly from a live
// database target. This is the entrypoint for bootstrapping declarative mode.
dbDeclarativeGenerateCmd = &cobra.Command{
Use: "generate",
Short: "Generate declarative schema from a database",
RunE: runDeclarativeGenerate,
PostRun: func(cmd *cobra.Command, args []string) {
fmt.Println("Finished " + utils.Aqua("supabase db schema declarative generate") + ".")
},
}
)
func resolveDeclarativeMigrationName(name, file string) string {
if len(name) > 0 {
return name
}
return file
}
func ensureLocalDatabaseStarted(ctx context.Context, local bool, isRunning func() error, startDatabase func(context.Context) error) error {
if !local {
return nil
}
if err := isRunning(); err != nil {
if errors.Is(err, utils.ErrNotRunning) {
return startDatabase(ctx)
}
return err
}
return nil
}
// hasExplicitTargetFlag returns true if the user explicitly set --local, --linked, or --db-url.
func hasExplicitTargetFlag(cmd *cobra.Command) bool {
return cmd.Flags().Changed("local") || cmd.Flags().Changed("linked") || cmd.Flags().Changed("db-url")
}
// isTTY returns true if stdin is a terminal.
func isTTY() bool {
return term.IsTerminal(int(os.Stdin.Fd())) //nolint:gosec // G115: stdin fd always fits in int
}
// hasDeclarativeFiles checks if the declarative schema directory exists and contains files.
func hasDeclarativeFiles(fsys afero.Fs) bool {
declarativeDir := utils.GetDeclarativeDir()
exists, err := afero.DirExists(fsys, declarativeDir)
if err != nil || !exists {
return false
}
files, err := afero.ReadDir(fsys, declarativeDir)
if err != nil {
return false
}
return len(files) > 0
}
// hasMigrationFiles checks if the migrations directory contains migration files.
func hasMigrationFiles(fsys afero.Fs) bool {
migrations, err := migration.ListLocalMigrations(utils.MigrationsDir, afero.NewIOFS(fsys))
if err != nil {
return false
}
return len(migrations) > 0
}
// configureLocalDbConfig sets flags.DbConfig for local database connection.
func configureLocalDbConfig() {
flags.DbConfig = pgconn.Config{
Host: utils.Config.Hostname,
Port: utils.Config.Db.Port,
User: "postgres",
Password: utils.Config.Db.Password,
Database: "postgres",
}
}
// runDeclarativeGenerate implements the smart interactive generate flow.
func runDeclarativeGenerate(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
fsys := afero.NewOsFs()
// When an explicit target flag is provided, use the direct path.
if hasExplicitTargetFlag(cmd) {
if err := ensureLocalDatabaseStarted(ctx, declarativeLocal, utils.AssertSupabaseDbIsRunning, func(ctx context.Context) error {
return start.Run(ctx, "", fsys)
}); err != nil {
return err
}
return declarative.Generate(ctx, schema, flags.DbConfig, declarativeOverwrite, declarativeNoCache, fsys)
}
// Smart mode: no explicit target flag
if !isTTY() && !viper.GetBool("YES") {
return errors.New("in non-interactive mode, specify a target: --local, --linked, or --db-url")
}
console := utils.NewConsole()
// Check if declarative dir already has files
if hasDeclarativeFiles(fsys) && !declarativeOverwrite {
msg := fmt.Sprintf("Declarative schema already exists at %s. Regenerate from database? This will overwrite existing files.", utils.Bold(utils.GetDeclarativeDir()))
ok, err := console.PromptYesNo(ctx, msg, false)
if err != nil {
return err
}
if !ok {
fmt.Fprintln(os.Stderr, "Skipped generating declarative schema.")
return nil
}
}
// Check for migrations and offer choices
if hasMigrationFiles(fsys) {
// Try to resolve linked project ref for the prompt
var linkedRef string
if err := flags.LoadProjectRef(fsys); err == nil {
linkedRef = flags.ProjectRef
}
choices := []utils.PromptItem{
{Summary: "Local database", Details: "generate from local Postgres", Index: 0},
}
if len(linkedRef) > 0 {
choices = append(choices, utils.PromptItem{
Summary: "Linked project",
Details: fmt.Sprintf("generate from remote linked project (%s)", linkedRef),
Index: 1,
})
}
choices = append(choices, utils.PromptItem{
Summary: "Custom database URL",
Details: "enter a connection string",
Index: 2,
})
choice, err := utils.PromptChoice(ctx, "Generate declarative schema from:", choices)
if err != nil {
return err
}
switch choice.Index {
case 0: // Local database
if err := ensureLocalDatabaseStarted(ctx, true, utils.AssertSupabaseDbIsRunning, func(ctx context.Context) error {
return start.Run(ctx, "", fsys)
}); err != nil {
return err
}
// Prompt to reset local DB first
shouldReset := declarativeReset
if !shouldReset {
shouldReset, err = console.PromptYesNo(ctx, "Reset local database to match migrations first? (local data will be lost)", false)
if err != nil {
return err
}
}
if shouldReset {
configureLocalDbConfig()
if err := reset.Run(ctx, "", 0, flags.DbConfig, fsys); err != nil {
return err
}
}
configureLocalDbConfig()
case 1: // Linked project
var err error
flags.DbConfig, err = flags.NewDbConfigWithPassword(ctx, flags.ProjectRef)
if err != nil {
return err
}
case 2: // Custom database URL
dbURL, err := console.PromptText(ctx, "Enter database URL: ")
if err != nil {
return err
}
if len(strings.TrimSpace(dbURL)) == 0 {
return errors.New("database URL cannot be empty")
}
config, err := pgconn.ParseConfig(dbURL)
if err != nil {
return fmt.Errorf("failed to parse connection string: %w", err)
}
flags.DbConfig = *config
}
} else {
// No migrations — generate from local DB
if err := ensureLocalDatabaseStarted(ctx, true, utils.AssertSupabaseDbIsRunning, func(ctx context.Context) error {
return start.Run(ctx, "", fsys)
}); err != nil {
return err
}
configureLocalDbConfig()
}
return declarative.Generate(ctx, schema, flags.DbConfig, true, declarativeNoCache, fsys)
}
// runDeclarativeSync implements the smart interactive sync flow.
func runDeclarativeSync(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
fsys := afero.NewOsFs()
console := utils.NewConsole()
// Step 1: Check if declarative dir has files
if !hasDeclarativeFiles(fsys) {
if !isTTY() && !viper.GetBool("YES") {
return fmt.Errorf("no declarative schema found. Run %s first", utils.Aqua("supabase db schema declarative generate"))
}
ok, err := console.PromptYesNo(ctx, "No declarative schema found. Generate a new one ?", true)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("no declarative schema found. Run %s first", utils.Aqua("supabase db schema declarative generate"))
}
// Run smart generate flow
if err := runDeclarativeGenerate(cmd, args); err != nil {
return err
}
// Verify declarative files were actually generated
if !hasDeclarativeFiles(fsys) {
return errors.New("declarative schema generation did not produce any files")
}
}
// Step 2: Generate migration diff
result, err := declarative.DiffDeclarativeToMigrations(ctx, schema, declarativeNoCache, fsys)
if err != nil {
// Save debug bundle on error
bundle := declarative.DebugBundle{
Error: err,
Migrations: declarative.CollectMigrationsList(fsys),
}
if debugDir, saveErr := declarative.SaveDebugBundle(bundle, fsys); saveErr == nil {
declarative.PrintDebugBundleMessage(debugDir)
}
return err
}
// Step 3: Check for empty diff
if len(strings.TrimSpace(result.DiffSQL)) < 2 {
fmt.Fprintln(os.Stderr, "No schema changes found")
return nil
}
fmt.Fprintln(os.Stderr, "Generated migration SQL:")
// Don't wrap with utils.Bold: lipgloss renders multi-line input as a block
// and pads every line with trailing spaces to match the widest line, which
// produces a wall of whitespace for long CREATE FUNCTION bodies.
fmt.Fprintln(os.Stderr, result.DiffSQL)
// Step 4: Resolve migration name
migrationName := resolveDeclarativeMigrationName(declarativeName, declarativeFile)
// Prompt for name if not set via flags and TTY is available
if len(declarativeName) == 0 && isTTY() && !viper.GetBool("YES") {
input, err := console.PromptText(ctx, fmt.Sprintf("Enter a name for this migration (press Enter to keep '%s'): ", migrationName))
if err != nil {
return err
}
if len(strings.TrimSpace(input)) > 0 {
migrationName = strings.TrimSpace(input)
}
}
// Step 5: Save migration file
timestamp := utils.GetCurrentTimestamp()
path := new.GetMigrationPath(timestamp, migrationName)
if err := utils.WriteFile(path, []byte(result.DiffSQL), fsys); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "Created new migration at "+utils.Bold(path))
// Show drop warnings
if len(result.DropWarnings) > 0 {
fmt.Fprintln(os.Stderr, utils.Yellow("Found drop statements in schema diff. Please double check if these are expected:"))
fmt.Fprintln(os.Stderr, utils.Yellow(strings.Join(result.DropWarnings, "\n")))
}
// Step 6: Prompt to apply migration to local DB
shouldApply := declarativeApply
if !shouldApply && isTTY() && !viper.GetBool("YES") {
shouldApply, err = console.PromptYesNo(ctx, "Apply this migration to local database?", true)
if err != nil {
return err
}
} else if viper.GetBool("YES") {
shouldApply = true
}
if shouldApply {
if applyErr := applyMigrationToLocal(ctx, path, fsys); applyErr != nil {
fmt.Fprintln(os.Stderr, utils.Red("Migration failed to apply: "+applyErr.Error()))
// Save debug bundle with apply error context
ts := time.Now().UTC().Format("20060102-150405")
debugDir := saveApplyDebugBundle(ts+"-apply-error", result, applyErr, fsys)
// In interactive mode, offer to reset and reapply
if isTTY() && !viper.GetBool("YES") {
shouldReset, promptErr := console.PromptYesNo(ctx, "Would you like to reset the local database and reapply all migrations? (local data will be lost)", false)
if promptErr != nil {
return promptErr
}
if shouldReset {
configureLocalDbConfig()
if resetErr := reset.Run(ctx, "", 0, flags.DbConfig, fsys); resetErr != nil {
fmt.Fprintln(os.Stderr, utils.Red("Database reset also failed: "+resetErr.Error()))
resetDebugDir := saveApplyDebugBundle(ts+"-after-reset", result, resetErr, fsys)
if len(debugDir) > 0 {
fmt.Fprintln(os.Stderr, "\nDebug information saved to "+utils.Bold(debugDir))
}
if len(resetDebugDir) > 0 {
fmt.Fprintln(os.Stderr, "Debug information saved to "+utils.Bold(resetDebugDir))
}
declarative.PrintDebugBundleMessage("")
return resetErr
}
fmt.Fprintln(os.Stderr, "Database reset and all migrations applied successfully.")
return nil
}
}
// Non-interactive or user declined reset
if len(debugDir) > 0 {
declarative.PrintDebugBundleMessage(debugDir)
}
return applyErr
}
fmt.Fprintln(os.Stderr, "Migration applied successfully.")
}
return nil
}
// saveApplyDebugBundle saves a debug bundle for apply errors and returns the debug directory path.
func saveApplyDebugBundle(id string, result *declarative.SyncResult, applyErr error, fsys afero.Fs) string {
bundle := declarative.DebugBundle{
ID: id,
SourceRef: result.SourceRef,
TargetRef: result.TargetRef,
MigrationSQL: result.DiffSQL,
Error: applyErr,
Migrations: declarative.CollectMigrationsList(fsys),
}
debugDir, saveErr := declarative.SaveDebugBundle(bundle, fsys)
if saveErr != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to save debug artifacts: %v\n", saveErr)
return ""
}
return debugDir
}
// applyMigrationToLocal connects to the local database and applies a single migration.
func applyMigrationToLocal(ctx context.Context, migrationPath string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error {
config := pgconn.Config{
Host: utils.Config.Hostname,
Port: utils.Config.Db.Port,
User: "postgres",
Password: utils.Config.Db.Password,
Database: "postgres",
}
conn, err := utils.ConnectByConfig(ctx, config, options...)
if err != nil {
return err
}
defer conn.Close(context.Background())
return migration.ApplyMigrations(ctx, []string{migrationPath}, conn, afero.NewIOFS(fsys))
}
func init() {
// no-cache allows bypassing catalog snapshots when users need a fresh view of
// database state, even if cached artifacts are available.
declarativeFlags := dbDeclarativeCmd.PersistentFlags()
declarativeFlags.BoolVar(&declarativeNoCache, "no-cache", false, "Disable catalog cache and force fresh shadow database setup.")
syncFlags := dbDeclarativeSyncCmd.Flags()
syncFlags.StringSliceVarP(&schema, "schema", "s", []string{}, "Comma separated list of schema to include.")
syncFlags.StringVarP(&declarativeFile, "file", "f", defaultDeclarativeSyncName, "Saves schema diff to a new migration file.")
syncFlags.StringVar(&declarativeName, "name", "", "Name for the generated migration file.")
syncFlags.BoolVar(&declarativeApply, "apply", false, "Apply the generated migration to the local database without prompting.")
generateFlags := dbDeclarativeGenerateCmd.Flags()
generateFlags.BoolVar(&declarativeOverwrite, "overwrite", false, "Overwrite declarative schema files without confirmation.")
generateFlags.BoolVar(&declarativeReset, "reset", false, "Reset local database before generating (local data will be lost).")
generateFlags.StringSliceVarP(&schema, "schema", "s", []string{}, "Comma separated list of schema to include.")
generateFlags.String("db-url", "", "Generates declarative schema from the database specified by the connection string (must be percent-encoded).")
generateFlags.Bool("linked", false, "Generates declarative schema from the linked project.")
generateFlags.BoolVar(&declarativeLocal, "local", false, "Generates declarative schema from the local database.")
dbDeclarativeGenerateCmd.MarkFlagsMutuallyExclusive("db-url", "linked", "local")
generateFlags.StringVarP(&dbPassword, "password", "p", "", "Password to your remote Postgres database.")
cobra.CheckErr(viper.BindPFlag("DB_PASSWORD", generateFlags.Lookup("password")))
dbDeclarativeCmd.AddCommand(dbDeclarativeSyncCmd)
dbDeclarativeCmd.AddCommand(dbDeclarativeGenerateCmd)
dbSchemaCmd.AddCommand(dbDeclarativeCmd)
dbCmd.AddCommand(dbSchemaCmd)
}