From faaa7f38de49840b4bf577618f021c0386522287 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 25 Jun 2026 11:16:34 +0200 Subject: [PATCH] feat(db): add reset sql path override --- apps/cli-go/cmd/db.go | 76 ++++++++- apps/cli-go/cmd/db_test.go | 100 ++++++++++++ apps/cli-go/docs/supabase/db/reset.md | 4 + apps/cli/docs/go-cli-porting-status.md | 2 +- .../legacy/commands/db/reset/SIDE_EFFECTS.md | 13 +- .../legacy/commands/db/reset/reset.command.ts | 9 ++ .../legacy/commands/db/reset/reset.handler.ts | 1 + .../db/reset/reset.integration.test.ts | 147 ++++++++++++++++++ 8 files changed, 342 insertions(+), 10 deletions(-) create mode 100644 apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index 3f8d3d82a8..df04364e44 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -1,8 +1,10 @@ package cmd import ( + "errors" "fmt" "os" + "path" "path/filepath" "github.com/spf13/afero" @@ -24,6 +26,7 @@ import ( "github.com/supabase/cli/legacy/branch/delete" "github.com/supabase/cli/legacy/branch/list" "github.com/supabase/cli/legacy/branch/switch_" + "github.com/supabase/cli/pkg/config" "github.com/supabase/cli/pkg/migration" ) @@ -300,15 +303,23 @@ var ( }, } - noSeed bool - lastVersion uint + noSeed bool + lastVersion uint + seedSqlPaths []string dbResetCmd = &cobra.Command{ Use: "reset", Short: "Resets the local database to current migrations", + PreRunE: func(cmd *cobra.Command, args []string) error { + if err := validateDbResetSeedFlags(noSeed, seedSqlPaths); err != nil { + return err + } + warnRemoteResetSeedOverride(cmd, seedSqlPaths) + return nil + }, RunE: func(cmd *cobra.Command, args []string) error { - if noSeed { - utils.Config.Db.Seed.Enabled = false + if err := applyDbResetSeedFlags(noSeed, seedSqlPaths); err != nil { + return err } return reset.Run(cmd.Context(), migrationVersion, lastVersion, flags.DbConfig, afero.NewOsFs()) }, @@ -470,6 +481,62 @@ func resolvePullDiffEngine(engineFlagChanged bool, engine string, pgDeltaDefault return pgDeltaDefault } +func validateDbResetSeedFlags(noSeed bool, patterns []string) error { + if noSeed && len(patterns) > 0 { + utils.CmdSuggestion = fmt.Sprintf("Use either %s to skip seeding or %s to override seed files, not both.", utils.Aqua("--no-seed"), utils.Aqua("--sql-paths")) + return errors.New("--no-seed cannot be used with --sql-paths") + } + for _, pattern := range patterns { + if len(pattern) == 0 { + utils.CmdSuggestion = fmt.Sprintf("Pass a non-empty file path or glob pattern to %s.", utils.Aqua("--sql-paths")) + return errors.New("--sql-paths requires a non-empty path or glob pattern") + } + } + return nil +} + +func warnRemoteResetSeedOverride(cmd *cobra.Command, patterns []string) { + if len(patterns) == 0 { + return + } + if cmd.Flags().Changed("linked") || cmd.Flags().Changed("db-url") { + fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "--sql-paths overrides [db.seed].sql_paths and seeds the remote database selected by --linked or --db-url.") + } +} + +func applyDbResetSeedFlags(noSeed bool, patterns []string) error { + if noSeed { + utils.Config.Db.Seed.Enabled = false + return nil + } + if len(patterns) == 0 { + return nil + } + resolved, err := resolveSeedSqlPaths(patterns) + if err != nil { + return err + } + utils.Config.Db.Seed.Enabled = true + utils.Config.Db.Seed.SqlPaths = resolved + return nil +} + +func resolveSeedSqlPaths(patterns []string) ([]string, error) { + resolved := make([]string, len(patterns)) + base := config.NewPathBuilder("").SupabaseDirPath + for i, pattern := range patterns { + if len(pattern) == 0 { + return nil, errors.New("--sql-paths requires a non-empty path or glob pattern") + } + if !filepath.IsAbs(pattern) { + resolved[i] = path.Join(base, pattern) + } else { + resolved[i] = pattern + } + } + return resolved, nil +} + func init() { // Build branch command dbBranchCmd.AddCommand(dbBranchCreateCmd) @@ -570,6 +637,7 @@ func init() { resetFlags.Bool("linked", false, "Resets the linked project with local migrations.") resetFlags.Bool("local", true, "Resets the local database with local migrations.") resetFlags.BoolVar(&noSeed, "no-seed", false, "Skip running the seed script after reset.") + resetFlags.StringArrayVar(&seedSqlPaths, "sql-paths", nil, "Override [db.seed].sql_paths for this reset. May be repeated; each value accepts a SQL file path or glob pattern relative to the supabase directory and force-enables seeding.") dbResetCmd.MarkFlagsMutuallyExclusive("db-url", "linked", "local") resetFlags.StringVar(&migrationVersion, "version", "", "Reset up to the specified version.") resetFlags.UintVar(&lastVersion, "last", 0, "Reset up to the last n migration versions.") diff --git a/apps/cli-go/cmd/db_test.go b/apps/cli-go/cmd/db_test.go index 654278d059..d12bb1cf8f 100644 --- a/apps/cli-go/cmd/db_test.go +++ b/apps/cli-go/cmd/db_test.go @@ -1,9 +1,12 @@ package cmd import ( + "path/filepath" "testing" + "github.com/spf13/pflag" "github.com/stretchr/testify/assert" + "github.com/supabase/cli/internal/utils" ) func TestResolvePullDiffEngine(t *testing.T) { @@ -45,3 +48,100 @@ func TestResolveDiffEngine(t *testing.T) { assert.False(t, resolveDiffEngine(false, true, false, true)) }) } + +func TestResolveSeedSqlPaths(t *testing.T) { + t.Run("resolves relative paths against the supabase directory", func(t *testing.T) { + absoluteSeedPath := filepath.Join(t.TempDir(), "seed.sql") + got, err := resolveSeedSqlPaths([]string{ + "./seeds/minimal.sql", + "./seeds/demo/*.sql", + "./seeds/tenant,one.sql", + absoluteSeedPath, + }) + + assert.NoError(t, err) + assert.Equal(t, []string{ + filepath.Join(utils.SupabaseDirPath, "seeds", "minimal.sql"), + filepath.Join(utils.SupabaseDirPath, "seeds", "demo", "*.sql"), + filepath.Join(utils.SupabaseDirPath, "seeds", "tenant,one.sql"), + absoluteSeedPath, + }, got) + }) + + t.Run("rejects empty paths", func(t *testing.T) { + got, err := resolveSeedSqlPaths([]string{""}) + assert.Nil(t, got) + assert.EqualError(t, err, "--sql-paths requires a non-empty path or glob pattern") + }) +} + +func TestValidateDbResetSeedFlags(t *testing.T) { + t.Run("rejects no seed with sql paths", func(t *testing.T) { + utils.CmdSuggestion = "" + t.Cleanup(func() { utils.CmdSuggestion = "" }) + + err := validateDbResetSeedFlags(true, []string{"./seed.sql"}) + + assert.EqualError(t, err, "--no-seed cannot be used with --sql-paths") + assert.Contains(t, utils.CmdSuggestion, "Use either") + assert.Contains(t, utils.CmdSuggestion, "--no-seed") + assert.Contains(t, utils.CmdSuggestion, "--sql-paths") + }) + + t.Run("rejects empty sql paths", func(t *testing.T) { + utils.CmdSuggestion = "" + t.Cleanup(func() { utils.CmdSuggestion = "" }) + + err := validateDbResetSeedFlags(false, []string{""}) + + assert.EqualError(t, err, "--sql-paths requires a non-empty path or glob pattern") + assert.Contains(t, utils.CmdSuggestion, "non-empty") + assert.Contains(t, utils.CmdSuggestion, "--sql-paths") + }) +} + +func TestApplyDbResetSeedFlags(t *testing.T) { + oldSeed := utils.Config.Db.Seed + t.Cleanup(func() { utils.Config.Db.Seed = oldSeed }) + + t.Run("leaves config unchanged without seed flags", func(t *testing.T) { + utils.Config.Db.Seed.Enabled = false + utils.Config.Db.Seed.SqlPaths = []string{"supabase/original.sql"} + + assert.NoError(t, applyDbResetSeedFlags(false, nil)) + assert.False(t, utils.Config.Db.Seed.Enabled) + assert.Equal(t, []string{"supabase/original.sql"}, []string(utils.Config.Db.Seed.SqlPaths)) + }) + + t.Run("disables seed when no seed is set", func(t *testing.T) { + utils.Config.Db.Seed.Enabled = true + utils.Config.Db.Seed.SqlPaths = []string{"supabase/original.sql"} + + assert.NoError(t, applyDbResetSeedFlags(true, nil)) + assert.False(t, utils.Config.Db.Seed.Enabled) + assert.Equal(t, []string{"supabase/original.sql"}, []string(utils.Config.Db.Seed.SqlPaths)) + }) + + t.Run("force enables seed and overrides sql paths", func(t *testing.T) { + utils.Config.Db.Seed.Enabled = false + utils.Config.Db.Seed.SqlPaths = []string{"supabase/original.sql"} + + assert.NoError(t, applyDbResetSeedFlags(false, []string{"./seeds/base.sql"})) + assert.True(t, utils.Config.Db.Seed.Enabled) + assert.Equal(t, []string{filepath.Join(utils.SupabaseDirPath, "seeds", "base.sql")}, []string(utils.Config.Db.Seed.SqlPaths)) + }) +} + +func TestSeedSqlPathsFlagPreservesCommas(t *testing.T) { + var values []string + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + flags.StringArrayVar(&values, "sql-paths", nil, "") + + assert.NoError(t, flags.Parse([]string{ + "--sql-paths", + "./seeds/tenant,one.sql", + "--sql-paths", + "./seeds/two.sql", + })) + assert.Equal(t, []string{"./seeds/tenant,one.sql", "./seeds/two.sql"}, values) +} diff --git a/apps/cli-go/docs/supabase/db/reset.md b/apps/cli-go/docs/supabase/db/reset.md index acb9b9832b..9a60a67711 100644 --- a/apps/cli-go/docs/supabase/db/reset.md +++ b/apps/cli-go/docs/supabase/db/reset.md @@ -6,4 +6,8 @@ Requires the local development stack to be started by running `supabase start`. Recreates the local Postgres container and applies all local migrations found in `supabase/migrations` directory. If test data is defined in `supabase/seed.sql`, it will be seeded after the migrations are run. Any other data or schema changes made during local development will be discarded. +Use the `--no-seed` flag to skip seeding entirely. To override `[db.seed].sql_paths` for a single reset, pass one or more `--sql-paths` flags. Each value accepts the same file path or glob pattern syntax as `sql_paths`, relative to the `supabase` directory. Passing `--sql-paths` force-enables seeding for that reset even when `[db.seed].enabled = false`. + When running db reset with `--linked` or `--db-url` flag, a SQL script is executed to identify and drop all user created entities in the remote database. Since Postgres roles are cluster level entities, any custom roles created through the dashboard or `supabase/roles.sql` will not be deleted by remote reset. + +If you combine `--sql-paths` with `--linked` or `--db-url`, the override seed files are applied to the selected remote database after migrations. Use this only when you intend to seed that remote target. diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 9cebb1a5f5..9b22759d95 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -302,7 +302,7 @@ Legend: | `db dump` | `ported` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | | `db push` | `wrapped` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | | `db pull` | `ported` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — native pg-delta / migra; `--declarative` (deprecated alias `--use-pg-delta`) + `--diff-engine` (migra\|pg-delta); `--experimental` / initial `pg_dump` delegate to Go | -| `db reset` | `wrapped` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) | +| `db reset` | `wrapped` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) — includes Go-parity `--sql-paths` override for `[db.seed].sql_paths` | | `db lint` | `ported` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | | `db start` | `wrapped` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) | | `db query` | `ported` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | diff --git a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md index a2c24f24d2..d1291e27a4 100644 --- a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md @@ -2,11 +2,11 @@ ## Files Read -| Path | Format | When | -| -------------------------------- | ---------- | ------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | -| `/supabase/migrations/` | directory | always, to load migration files | -| seed files from config | SQL | unless `--no-seed` is set | +| Path | Format | When | +| --------------------------------------- | ---------- | ------------------------------------------------- | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | +| `/supabase/migrations/` | directory | always, to load migration files | +| seed files from config or `--sql-paths` | SQL | unless `--no-seed` is set | ## Files Written @@ -52,6 +52,9 @@ Not applicable. ## Notes - `--no-seed` skips running the seed script after reset. +- `--sql-paths` overrides `[db.seed].sql_paths` for one reset; repeat it to seed multiple files or glob patterns. +- `--sql-paths` force-enables seeding for that reset even when `[db.seed].enabled = false`. +- With `--linked` or `--db-url`, `--sql-paths` seeds the selected remote database after migrations. - `--version` resets up to the specified migration version. - `--last` resets up to the last n migration versions; mutually exclusive with `--version`. - `--db-url`, `--linked`, and `--local` (default true) are mutually exclusive. diff --git a/apps/cli/src/legacy/commands/db/reset/reset.command.ts b/apps/cli/src/legacy/commands/db/reset/reset.command.ts index 11764e9db7..15a61c8d88 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.command.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.command.ts @@ -2,6 +2,8 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { legacyDbReset } from "./reset.handler.ts"; +const noSqlPaths: ReadonlyArray = []; + const config = { dbUrl: Flag.string("db-url").pipe( Flag.withDescription( @@ -18,6 +20,13 @@ const config = { noSeed: Flag.boolean("no-seed").pipe( Flag.withDescription("Skip running the seed script after reset."), ), + sqlPaths: Flag.string("sql-paths").pipe( + Flag.atLeast(0), + Flag.withDescription( + "Override [db.seed].sql_paths for this reset. May be repeated; each value accepts a SQL file path or glob pattern relative to the supabase directory and force-enables seeding.", + ), + Flag.withDefault(noSqlPaths), + ), version: Flag.string("version").pipe( Flag.withDescription("Reset up to the specified version."), Flag.optional, diff --git a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts index 406485fe53..de35923ac2 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts @@ -9,6 +9,7 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega if (flags.linked) args.push("--linked"); if (flags.local) args.push("--local"); if (flags.noSeed) args.push("--no-seed"); + for (const path of flags.sqlPaths) args.push("--sql-paths", path); if (Option.isSome(flags.version)) args.push("--version", flags.version.value); if (Option.isSome(flags.last)) args.push("--last", String(flags.last.value)); yield* proxy.exec(args); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts new file mode 100644 index 0000000000..bd533d2adc --- /dev/null +++ b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; +import { legacyDbResetCommand } from "./reset.command.ts"; +import { legacyDbReset } from "./reset.handler.ts"; +import type { LegacyDbResetFlags } from "./reset.command.ts"; + +function setupLegacyDbReset() { + const calls: Array> = []; + const layer = Layer.succeed(LegacyGoProxy, { + exec: (args) => + Effect.sync(() => { + calls.push(args); + }), + execCapture: () => Effect.succeed(""), + }); + return { layer, calls }; +} + +const baseFlags: LegacyDbResetFlags = { + dbUrl: Option.none(), + linked: false, + local: false, + noSeed: false, + sqlPaths: [], + version: Option.none(), + last: Option.none(), +}; + +describe("legacy db reset", () => { + it.live("forwards the empty-array baseline without seed override flags", () => { + const { layer, calls } = setupLegacyDbReset(); + return Effect.gen(function* () { + yield* legacyDbReset(baseFlags); + expect(calls).toEqual([["db", "reset"]]); + }).pipe(Effect.provide(layer)); + }); + + it.live("forwards --no-seed alone", () => { + const { layer, calls } = setupLegacyDbReset(); + return Effect.gen(function* () { + yield* legacyDbReset({ ...baseFlags, noSeed: true }); + expect(calls).toEqual([["db", "reset", "--no-seed"]]); + }).pipe(Effect.provide(layer)); + }); + + it.live("forwards a single --sql-paths flag", () => { + const { layer, calls } = setupLegacyDbReset(); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...baseFlags, + sqlPaths: ["./seeds/base.sql"], + }); + expect(calls).toEqual([["db", "reset", "--sql-paths", "./seeds/base.sql"]]); + }).pipe(Effect.provide(layer)); + }); + + it.live("forwards repeated --sql-paths flags in order", () => { + const { layer, calls } = setupLegacyDbReset(); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...baseFlags, + sqlPaths: ["./seeds/base.sql", "./seeds/demo/*.sql"], + }); + expect(calls).toEqual([ + ["db", "reset", "--sql-paths", "./seeds/base.sql", "--sql-paths", "./seeds/demo/*.sql"], + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("forwards --no-seed with --sql-paths so Go owns the diagnostic", () => { + const { layer, calls } = setupLegacyDbReset(); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...baseFlags, + noSeed: true, + sqlPaths: ["./seeds/base.sql"], + }); + expect(calls).toEqual([["db", "reset", "--no-seed", "--sql-paths", "./seeds/base.sql"]]); + }).pipe(Effect.provide(layer)); + }); + + it.live("forwards an empty --sql-paths value so Go owns the diagnostic", () => { + const { layer, calls } = setupLegacyDbReset(); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...baseFlags, + sqlPaths: [""], + }); + expect(calls).toEqual([["db", "reset", "--sql-paths", ""]]); + }).pipe(Effect.provide(layer)); + }); + + it("parses repeated --sql-paths flags from the command surface", async () => { + const { layer, calls } = setupLegacyDbReset(); + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* Command.runWith(legacyDbResetCommand, { version: "0.0.0-test" })([ + "--sql-paths", + "./seeds/base.sql", + "--sql-paths", + "./seeds/demo/*.sql", + ]); + expect(calls).toEqual([ + ["db", "reset", "--sql-paths", "./seeds/base.sql", "--sql-paths", "./seeds/demo/*.sql"], + ]); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + layer, + mockOutput({ format: "text" }).layer, + CliOutput.layer(textCliOutputFormatter()), + ), + ), + ) as Effect.Effect, + ); + }); + + it("forwards mutually exclusive seed flags from the command surface", async () => { + const { layer, calls } = setupLegacyDbReset(); + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* Command.runWith(legacyDbResetCommand, { version: "0.0.0-test" })([ + "--no-seed", + "--sql-paths", + "./seeds/base.sql", + ]); + expect(calls).toEqual([["db", "reset", "--no-seed", "--sql-paths", "./seeds/base.sql"]]); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + layer, + mockOutput({ format: "text" }).layer, + CliOutput.layer(textCliOutputFormatter()), + ), + ), + ) as Effect.Effect, + ); + }); +});