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
18 changes: 9 additions & 9 deletions apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ primitives run behind the hidden Go `db __db-bootstrap` seam. Only the niche

## Files Read

| Path | Format | When |
| ------------------------------------------------------ | ---------- | ------------------------------------------------------------------------- |
| `<workdir>/supabase/migrations/` | directory | to validate `--version` / resolve `--last`, and to load migrations |
| `<workdir>/supabase/config.toml` | TOML | remote path + local bucket seeding (embedded defaults when absent) |
| `<workdir>/.git/HEAD` (walked upward) | plain text | local path, for the `Finished … on branch <branch>.` line |
| `~/.supabase/<hash>/project-ref` | plain text | `--linked`, to resolve the ref |
| `~/.supabase/access-token` | plain text | `--linked`, when `SUPABASE_ACCESS_TOKEN` unset and a temp role is minted |
| seed files from `--sql-paths` or `[db.seed].sql_paths` | SQL | when seeding is enabled (not `--no-seed`); `--sql-paths` overrides config |
| `<workdir>/supabase/buckets/` | files | local path, when storage is up and `[storage.buckets]` configure objects |
| Path | Format | When |
| ------------------------------------------------------ | ---------- | --------------------------------------------------------------------------------------------------------------------- |
| `<workdir>/supabase/migrations/` | directory | to validate `--version` / resolve `--last`, and to load migrations |
| `<workdir>/supabase/config.toml` | TOML | always, parsed up front before any destructive work (embedded defaults when absent); re-read for local bucket seeding |
| `<workdir>/.git/HEAD` (walked upward) | plain text | local path, for the `Finished … on branch <branch>.` line |
| `~/.supabase/<hash>/project-ref` | plain text | `--linked`, to resolve the ref |
| `~/.supabase/access-token` | plain text | `--linked`, when `SUPABASE_ACCESS_TOKEN` unset and a temp role is minted |
| seed files from `--sql-paths` or `[db.seed].sql_paths` | SQL | when seeding is enabled (not `--no-seed`); `--sql-paths` overrides config |
| `<workdir>/supabase/buckets/` | files | local path, when storage is up and `[storage.buckets]` configure objects |

## Files Written

Expand Down
37 changes: 30 additions & 7 deletions apps/cli/src/legacy/commands/db/reset/reset.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,20 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega
// (running check, messages, bucket seeding, git-branch line, output shaping).
// Mirrors `internal/db/reset/reset.go:57-77`.
if (cfg.isLocal) {
// Go's `flags.LoadConfig` (root `PersistentPreRunE` → the local target's
// per-connType `LoadConfig`, `internal/utils/flags/db_url.go:77-80`) runs full
// config validation before `reset.Run` ever reaches `AssertSupabaseDbIsRunning`
// / the destructive `resetDatabase` (`internal/db/reset/reset.go:57-61`). The
// resolver's own local read (above, line 239) already performs the identical
// validation and would already reject a broken config before this point is
// reached — so today this re-validates for its own sake. Repeat it here anyway,
// as an explicit, independent gate (the same pattern `db start` and `db push`
// use), so the "malformed config aborts before the local database is recreated"
// guarantee is enforced by this handler directly and stays covered by a
// handler-level test even if the resolver's own internal read is ever mocked,
// relaxed, or refactored to stop validating.
yield* legacyCheckDbToml(fs, path, workdir);

// AssertSupabaseDbIsRunning — error if the local db container is down.
const running = yield* seam.isDbRunning();
if (!running) {
Expand All @@ -268,13 +282,22 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega
// Go's `buckets.Run(ctx, "", false, fsys)` — non-interactive: overwrite/prune
// confirmations take their defaults instead of blocking on input.
//
// Bucket seeding re-loads config.toml through the strict `@supabase/config`
// loader, which (unlike the Go-parity reader used elsewhere in reset) rejects some
// Go-valid configs — e.g. `[db.seed] enabled = "env(SEED_ENABLED)"`. The seam's Go
// `recreate` has already run Go's full `LoadConfig`+`Validate` on this same config,
// so a parse failure HERE is that loader-strictness gap, not a genuinely invalid
// config. Recreate already dropped/rebuilt the DB, so aborting now would leave the
// reset half-done; warn and skip buckets so `db reset` finishes like Go instead.
// `legacyCheckDbToml` above resolves `env(VAR)` via `legacyLoadProjectEnv`,
// which mirrors Go's full nested-env walk (`.env.<SUPABASE_ENV>.local`,
// `.env.local`, `.env.<SUPABASE_ENV>`, `.env`, across both `supabase/` and the
// project root — `pkg/config/config.go:1220-1257`). This reload instead goes
// through `@supabase/config`'s `loadProjectConfig` → `loadProjectEnvironment`,
// which only ever reads `supabase/.env`/`.env.local` plus ambient env
// (`packages/config/src/project.ts:209-245`) — regardless of `goViperCompat`,
// which only widens `env(VAR)` matching, not the file set consulted. So a
// config whose `env(VAR)` reference is backed by e.g. `supabase/.env.development`
// is genuinely Go-valid (Go's `godotenv.Load` calls `os.Setenv`, so the value is
// real ambient env by the time Go resolves it — `config.go:1260-1261`) and
// already passed `legacyCheckDbToml` and the real recreate above, but this
// narrower reload can still reject it. A `LegacySeedConfigLoadError` here is
// that env-file-set gap, not a genuinely invalid config — and recreate already
// dropped/rebuilt the DB, so aborting now would leave the reset half-done; warn
// and skip buckets so `db reset` finishes like Go instead.
yield* legacySeedBucketsRun({
Comment thread
Coly010 marked this conversation as resolved.
projectRef: "",
emitSummary: false,
Expand Down
169 changes: 157 additions & 12 deletions apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,117 @@ describe("legacy db reset", () => {
});
});

it.live("proceeds with a local reset when no config file is present", () => {
// Go's `Config.Load` tolerates a missing `config.toml`: `Eject` defaults an empty
// `project_id` to the cwd basename (`pkg/config/config.go:563-570`), so `Validate`
// never sees an empty required field and the CLI proceeds — exactly the mechanism
// the cli-e2e parity suite relies on when it runs `db reset --local` from a project
// with no config.toml. A missing config must not become a hard failure here.
const { layer, seam } = setup(tmp.current, {
args: ["db", "reset"],
isLocal: true,
running: true,
});
return Effect.gen(function* () {
yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer));
expect(seam.recreateCalls).toHaveLength(1);
});
});

it.live("fails a local reset before the destructive recreate on a malformed config.toml", () => {
// Go's `flags.LoadConfig` (the local target's `LoadConfig`, `db_url.go:77-80`) runs
// full config validation before `reset.Run` reaches `AssertSupabaseDbIsRunning` /
// `resetDatabase` (`internal/db/reset/reset.go:57-61`). A broken config.toml must
// abort before the local database is ever recreated.
const { layer, seam } = setup(tmp.current, {
toml: 'project_id = "unterminated\n',
args: ["db", "reset"],
isLocal: true,
running: true,
});
return Effect.gen(function* () {
const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
expect(JSON.stringify(exit.cause)).toContain("failed to load config");
}
expect(seam.recreateCalls).toHaveLength(0);
});
});

it.live(
"fails a local reset on a malformed config.toml even when the database is not running",
() => {
// Pins Go's exact ordering: `flags.LoadConfig` runs in the root `PersistentPreRunE`,
// strictly before `reset.Run` ever calls `AssertSupabaseDbIsRunning`
// (`internal/db/reset/reset.go:57`). So a broken config must surface as a config
// error even when the local database is ALSO not running — the config check must
// win the race, not the "is not running" check.
const { layer, seam } = setup(tmp.current, {
toml: 'project_id = "unterminated\n',
args: ["db", "reset"],
isLocal: true,
running: false,
});
return Effect.gen(function* () {
const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const cause = JSON.stringify(exit.cause);
expect(cause).toContain("failed to load config");
expect(cause).not.toContain("is not running.");
}
expect(seam.recreateCalls).toHaveLength(0);
});
},
);

it.live("fails a local reset before the destructive recreate on an undecryptable secret", () => {
// Regression: Go's `flags.LoadConfig` decrypts every `encrypted:` secret before
// `reset.Run` recreates the local database, so an undecryptable secret must abort
// before the destructive recreate, not surface later (or never) during bucket
// seeding.
const { layer, seam } = setup(tmp.current, {
toml: 'project_id = "test"\n\n[db.vault]\nmy_secret = "encrypted:anything"\n',
args: ["db", "reset"],
isLocal: true,
running: true,
});
return Effect.gen(function* () {
const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit);
expect(Exit.isFailure(exit)).toBe(true);
// Assert on the stable "failed to parse config:" prefix rather than the exact
// decrypt-failure tail, which depends on whether an ambient `DOTENV_PRIVATE_KEY*`
// is set (missing key vs. a base64/decrypt failure) — either way, the config
// load must fail before the destructive recreate.
if (Exit.isFailure(exit)) {
expect(JSON.stringify(exit.cause)).toContain("failed to parse config:");
}
expect(seam.recreateCalls).toHaveLength(0);
});
});

it.live("fails a local reset before the destructive recreate on an empty project_id", () => {
// Go's `config.Validate` rejects an explicit `project_id = ""` (a present override
// that resolved to empty, unlike an absent field) before the local recreate.
const { layer, seam } = setup(tmp.current, {
toml: 'project_id = ""\n',
args: ["db", "reset"],
isLocal: true,
running: true,
});
return Effect.gen(function* () {
const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
expect(JSON.stringify(exit.cause)).toContain(
"Missing required field in config: project_id",
);
}
expect(seam.recreateCalls).toHaveLength(0);
});
});

it.live("seeds buckets after a local reset when storage is ready", () => {
const { layer, seam } = setup(tmp.current, {
toml: 'project_id = "test"\n',
Expand All @@ -346,27 +457,29 @@ describe("legacy db reset", () => {
});
});

it.live("finishes a local reset when bucket seeding hits a strict-loader-rejected config", () => {
// The bucket-seeding core re-loads config via the strict `@supabase/config` loader.
// `SEED_ENABLED=maybe` is invalid for both Go's `strconv.ParseBool` and the TS
// loader's coercion, so the reload fails during decode (unlike e.g. `1`/`true`,
// which both now accept). The seam's Go recreate already validated + rebuilt the
// DB, so aborting here would leave the reset half-done — warn and skip buckets so
// reset finishes like Go.
it.live("fails a local reset before the destructive recreate on an unparseable boolean", () => {
// `SEED_ENABLED=maybe` cannot be resolved by Go's `strconv.ParseBool`, so
// `flags.LoadConfig` aborts on this config before `reset.Run` ever reaches
// `AssertSupabaseDbIsRunning`/`resetDatabase`. Previously this surfaced only much
// later (if at all) via the bucket-seeding core's own reload, AFTER the local
// database had already been recreated — this must now abort up front instead,
// via the pre-recreate `legacyCheckDbToml` gate.
const previous = process.env["SEED_ENABLED"];
process.env["SEED_ENABLED"] = "maybe";
const { layer, out, seam } = setup(tmp.current, {
const { layer, seam } = setup(tmp.current, {
toml: 'project_id = "test"\n\n[db.seed]\nenabled = "env(SEED_ENABLED)"\n',
args: ["db", "reset"],
isLocal: true,
running: true,
storageReady: true,
});
return Effect.gen(function* () {
yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer));
expect(out.stderrText).toContain("skipped seeding storage buckets");
expect(out.stderrText).toContain("Finished ");
expect(seam.recreateCalls).toHaveLength(1);
const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
expect(JSON.stringify(exit.cause)).toContain("invalid db.seed.enabled");
}
expect(seam.recreateCalls).toHaveLength(0);
}).pipe(
Effect.ensuring(
Effect.sync(() => {
Expand All @@ -377,6 +490,38 @@ describe("legacy db reset", () => {
);
});

it.live(
"finishes a local reset when bucket seeding can't see an env(VAR) value the pre-recreate gate saw",
() => {
// `legacyCheckDbToml` (the pre-recreate gate) resolves `env(VAR)` via
// `legacyLoadProjectEnv`, which mirrors Go's full nested-env walk and sees
// `supabase/.env.development` — a real, Go-valid env source
// (`pkg/config/config.go:1220-1257`; `godotenv.Load` calls `os.Setenv`, so this
// is genuinely ambient env by the time Go itself resolves `env(VAR)`,
// `config.go:1260-1261`). The post-recreate bucket-seed reload instead goes
// through `@supabase/config`'s `loadProjectEnvironment`, which only ever reads
// `supabase/.env`/`.env.local` + ambient env (`packages/config/src/project.ts:
// 209-245) — it can't see `.env.development` at all. So this Go-valid config
// passes the gate and the real recreate, then can't be re-resolved by the
// reload; the reset must still finish (warn-and-skip), not hard-fail after the
// local database has already been dropped and rebuilt.
const { layer, out, seam } = setup(tmp.current, {
toml: 'project_id = "test"\n\n[db.seed]\nenabled = "env(SEED_ENABLED)"\n',
files: { "supabase/.env.development": "SEED_ENABLED=true\n" },
args: ["db", "reset"],
isLocal: true,
running: true,
storageReady: true,
});
return Effect.gen(function* () {
yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer));
expect(seam.recreateCalls).toHaveLength(1);
expect(out.stderrText).toContain("skipped seeding storage buckets");
expect(out.stderrText).toContain("Finished ");
});
},
);

it.live("uses the detected git branch in the Finished line", () => {
const { layer, out } = setup(tmp.current, {
toml: 'project_id = "test"\n',
Expand Down
Loading