diff --git a/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts new file mode 100644 index 0000000000..9c2ec6f8e7 --- /dev/null +++ b/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts @@ -0,0 +1,81 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect } from "vitest"; +import { TARGET } from "../env.ts"; +import { testLive } from "./live-context.ts"; + +// Real-backend live coverage for the native `db start` / `db reset` ports. +// +// `db start` / `db reset` live only in the `go` reference and the `ts-legacy` +// port (the `next` shell has no `db` group), so skip the `ts-next` target. +// +// The live suite runs serially (`fileParallelism: false`, `maxWorkers: 1`), so the +// destructive remote reset below is safe against the throwaway per-run project. + +// --- Local leg: db start + db reset --local against the real Docker socket ----- +// Exercises the hidden `db __db-bootstrap` Go seam end-to-end — the boundary the +// in-process integration suites mock. The start → already-running → reset cycle +// runs in one test so it shares a single booted stack, and `finally` stops it +// (legacy proxies `stop` to Go) so the run never leaves containers behind. +describe.skipIf(TARGET === "ts-next")("db start / db reset --local (live, local Docker)", () => { + testLive( + "db start boots, is idempotent, and db reset --local recreates", + { timeout: 600_000 }, + async ({ run }) => { + try { + const start = await run(["db", "start"]); + expect(start.exitCode, start.stderr).toBe(0); + // Go tees bootstrap progress to stderr (mode-independent). + expect(`${start.stdout}${start.stderr}`).toMatch(/Starting database|Initialising schema/i); + + // Second start is a no-op: the db is already running, exit 0. + const again = await run(["db", "start"]); + expect(again.exitCode, again.stderr).toBe(0); + expect(`${again.stdout}${again.stderr}`).toMatch(/already[\s-]running/i); + + // Local reset recreates the container and prints the git-branch line. + const reset = await run(["db", "reset", "--local"]); + expect(reset.exitCode, reset.stderr).toBe(0); + expect(reset.stderr).toContain("on branch "); + } finally { + await run(["stop", "--no-backup"]).catch(() => undefined); + } + }, + ); +}); + +// --- Remote leg: db reset against the staging project over the session pooler --- +// Exercises the native remote reset path (drop user schemas → apply local +// migrations → seed) against a real Postgres, no Docker. `--yes` auto-accepts the +// confirmation prompt (the non-interactive default is decline). Mutates the +// throwaway project's schema — deleted on teardown. The IPv4 session pooler +// `dbUrl` is used because the direct host is IPv6-only and unreachable from +// IPv4-only CI runners. +describe.skipIf(TARGET === "ts-next")("db reset (live, remote session pooler)", () => { + testLive( + "resets the remote schema and re-applies a local migration", + { timeout: 600_000 }, + async ({ run, dbUrl, workspace }) => { + const migrations = join(workspace.path, "supabase", "migrations"); + mkdirSync(migrations, { recursive: true }); + writeFileSync( + join(migrations, "20240101000000_e2e_reset.sql"), + "create table if not exists e2e_reset (id int);\n", + ); + + const reset = await run(["db", "reset", "--db-url", dbUrl, "--yes"]); + expect(reset.exitCode, reset.stderr).toBe(0); + expect(reset.stderr).toContain("Resetting remote database"); + // A real connection failure must never be mistaken for a benign outcome. + expect(`${reset.stdout}${reset.stderr}`, "db reset hit a connection error").not.toMatch( + /dial|no route|connection refused|could not connect|server closed the connection|i\/o timeout/i, + ); + + // The migration history shows the re-applied version → proves the drop + + // migrate ran against the remote database. + const listed = await run(["migration", "list", "--db-url", dbUrl]); + expect(listed.exitCode, listed.stderr).toBe(0); + expect(listed.stdout).toContain("20240101000000"); + }, + ); +}); diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index df04364e44..66df4311cc 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "errors" "fmt" "os" @@ -270,6 +271,71 @@ var ( }, } + bootstrapMode string + bootstrapSqlPaths []string + bootstrapFromBackup string + bootstrapVersion string + bootstrapNoSeed bool + + // dbBootstrapCmd is a hidden seam used by the native-TypeScript `db start` and + // `db reset --local` commands to drive the container-bootstrap primitives that + // are not yet ported to TypeScript: creating/recreating the local Postgres + // container, applying the initial schema, and the storage health gate. The TS + // caller orchestrates everything else (the "already running?" check and its + // message, version/last resolution, bucket seeding, the git-branch "Finished…" + // line, telemetry, and --output-format shaping); the seam stays in Go only for + // the Docker lifecycle. It mirrors the existing db __shadow seam: it carries no + // db-url/local/linked target flags, so it loads supabase/config.toml explicitly + // (the root PersistentPreRunE only loads it when a target flag is set). Progress + // goes to stderr; the only stdout output is a single machine-parseable marker + // for --mode await-storage ("ready" or "absent"). + dbBootstrapCmd = &cobra.Command{ + Use: "__db-bootstrap", + Hidden: true, + Short: "Internal: container bootstrap for the native db start / db reset commands", + RunE: func(cmd *cobra.Command, args []string) error { + fsys := afero.NewOsFs() + if err := flags.LoadConfig(fsys); err != nil { + return err + } + switch bootstrapMode { + case "start": + // Mirror start.Run minus the "already running?" check, which the TS + // caller performs (and prints "Postgres database is already running."). + if err := start.StartDatabase(cmd.Context(), bootstrapFromBackup, fsys, os.Stderr); err != nil { + if rmErr := utils.DockerRemoveAll(context.Background(), os.Stderr, utils.Config.ProjectId); rmErr != nil { + fmt.Fprintln(os.Stderr, rmErr) + } + return err + } + return nil + case "recreate": + // The PG14/PG15 container-recreate half of local db reset. The TS + // caller has already printed "Resetting local database…" and validated + // the flags. Apply the same seed handling as `db reset` (dbResetCmd): + // `--no-seed` disables the seed, `--sql-paths` overrides the seed paths, + // before MigrateAndSeed runs inside the recreate. + if err := applyDbResetSeedFlags(bootstrapNoSeed, bootstrapSqlPaths); err != nil { + return err + } + return reset.RecreateLocalDatabase(cmd.Context(), bootstrapVersion, fsys) + case "await-storage": + ready, err := reset.AwaitStorageReady(cmd.Context()) + if err != nil { + return err + } + if ready { + fmt.Println("ready") + } else { + fmt.Println("absent") + } + return nil + default: + return fmt.Errorf("unknown bootstrap mode: %s", bootstrapMode) + } + }, + } + dbRemoteCmd = &cobra.Command{ Hidden: true, Use: "remote", @@ -620,6 +686,14 @@ func init() { shadowFlags.StringSliceVarP(&shadowSchema, "schema", "s", []string{}, "Comma separated list of schema to include.") shadowFlags.StringVar(&shadowProjectRef, "project-ref", "", "Linked project ref, so the shadow merges the matching [remotes.] config override.") dbCmd.AddCommand(dbShadowCmd) + // Build hidden container-bootstrap seam command (native db start / db reset) + bootstrapFlags := dbBootstrapCmd.Flags() + bootstrapFlags.StringVar(&bootstrapMode, "mode", "start", "Bootstrap mode: start, recreate, or await-storage.") + bootstrapFlags.StringVar(&bootstrapFromBackup, "from-backup", "", "Path to a logical backup file (start mode).") + bootstrapFlags.StringVar(&bootstrapVersion, "version", "", "Reset up to the specified version (recreate mode).") + bootstrapFlags.BoolVar(&bootstrapNoSeed, "no-seed", false, "Skip the seed script after recreate (recreate mode).") + bootstrapFlags.StringArrayVar(&bootstrapSqlPaths, "sql-paths", nil, "Override [db.seed].sql_paths for the recreate (recreate mode).") + dbCmd.AddCommand(dbBootstrapCmd) // Build remote command remoteFlags := dbRemoteCmd.PersistentFlags() remoteFlags.StringSliceVarP(&schema, "schema", "s", []string{}, "Comma separated list of schema to include.") diff --git a/apps/cli-go/internal/db/reset/reset.go b/apps/cli-go/internal/db/reset/reset.go index 7d841f4ba3..765153d6d7 100644 --- a/apps/cli-go/internal/db/reset/reset.go +++ b/apps/cli-go/internal/db/reset/reset.go @@ -92,6 +92,38 @@ func toLogMessage(version string) string { return "..." } +// RecreateLocalDatabase is the container-lifecycle half of a local `db reset`, +// exposed for the native-TypeScript `db reset --local` seam (cmd db __db-bootstrap). +// It performs the PG14/PG15 branch — recreate the db container/volume, init schema, +// migrate + seed, and restart the satellite containers — WITHOUT the leading +// "Resetting local database…" line, which the TS caller prints itself. Mirrors +// resetDatabase (above) minus that message. +func RecreateLocalDatabase(ctx context.Context, version string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { + if utils.Config.Db.MajorVersion <= 14 { + return resetDatabase14(ctx, version, fsys, options...) + } + return resetDatabase15(ctx, version, fsys, options...) +} + +// AwaitStorageReady mirrors the storage-health gate that local `db reset` runs +// before seeding buckets (Run, above): if the storage container exists but is not +// healthy, wait up to 30s for it. It reports whether the storage container exists +// so the native-TypeScript caller knows whether to run the (already-ported) bucket +// seeding. Any inspect error is treated as "storage not running" → false, matching +// Go's `err == nil` gate, which silently skips buckets on any inspect failure. +func AwaitStorageReady(ctx context.Context) (bool, error) { + resp, err := utils.Docker.ContainerInspect(ctx, utils.StorageId) + if err != nil { + return false, nil + } + if resp.State.Health == nil || resp.State.Health.Status != types.Healthy { + if err := start.WaitForHealthyService(ctx, 30*time.Second, utils.StorageId); err != nil { + return false, err + } + } + return true, nil +} + func resetDatabase14(ctx context.Context, version string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { if err := recreateDatabase(ctx, options...); err != nil { return err diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index f5969bd199..0b549321e6 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -80,51 +80,51 @@ These commands exist in the TS CLI today but have no direct top-level equivalent ## Database -| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | -| --------------------------------- | --------- | -------------------------------------------------- | -------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `db diff` | `ported` | `legacy/commands/db/diff/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra diff via edge-runtime against a Go-seam-provisioned live shadow (`db __shadow`); `--use-pgadmin` / `--use-pg-schema` delegate to the Go binary. | -| `db dump` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `db lint` | `ported` | `legacy/commands/db/lint/` | `n/a` | `n/a` | Native TS port. Runs `plpgsql_check` in a rolled-back transaction via LegacyDbConnection; emits Go-parity pretty JSON. | -| `db pull` | `ported` | `legacy/commands/db/pull/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra migration + `--declarative` pg-delta export; reconciles `schema_migrations`. `--experimental` dump + initial-pull `pg_dump` (migra) delegate to the Go binary. | -| `db push` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `db reset` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `db start` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | -| `inspect db db-stats` | `ported` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db replication-slots` | `ported` | `legacy/commands/inspect/db/replication-slots/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db locks` | `ported` | `legacy/commands/inspect/db/locks/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db blocking` | `ported` | `legacy/commands/inspect/db/blocking/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db outliers` | `ported` | `legacy/commands/inspect/db/outliers/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db calls` | `ported` | `legacy/commands/inspect/db/calls/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db index-stats` | `ported` | `legacy/commands/inspect/db/index-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db long-running-queries` | `ported` | `legacy/commands/inspect/db/long-running-queries/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db bloat` | `ported` | `legacy/commands/inspect/db/bloat/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db role-stats` | `ported` | `legacy/commands/inspect/db/role-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db vacuum-stats` | `ported` | `legacy/commands/inspect/db/vacuum-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db table-stats` | `ported` | `legacy/commands/inspect/db/table-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db traffic-profile` | `ported` | `legacy/commands/inspect/db/traffic-profile/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db cache-hit` | `ported` | `legacy/commands/inspect/db/cache-hit/` | `n/a` | `n/a` | Native TS port. Deprecated (use db-stats); routes to the active query. | -| `inspect db index-usage` | `ported` | `legacy/commands/inspect/db/index-usage/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db total-index-size` | `ported` | `legacy/commands/inspect/db/total-index-size/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db index-sizes` | `ported` | `legacy/commands/inspect/db/index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db table-sizes` | `ported` | `legacy/commands/inspect/db/table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db table-index-sizes` | `ported` | `legacy/commands/inspect/db/table-index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db total-table-sizes` | `ported` | `legacy/commands/inspect/db/total-table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db unused-indexes` | `ported` | `legacy/commands/inspect/db/unused-indexes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db table-record-counts` | `ported` | `legacy/commands/inspect/db/table-record-counts/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db seq-scans` | `ported` | `legacy/commands/inspect/db/seq-scans/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db role-configs` | `ported` | `legacy/commands/inspect/db/role-configs/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | -| `inspect db role-connections` | `ported` | `legacy/commands/inspect/db/role-connections/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | -| `migration down` | `ported` | `legacy/commands/migration/down/` | `n/a` | `n/a` | Native TS port. Revert prompt → drop user schemas → vault upsert → migrate&seed to the target version; defaults to `--local`. Skips Go's pgcache catalog write. | -| `migration fetch` | `ported` | `legacy/commands/migration/fetch/` | `n/a` | `n/a` | Native TS port. Reads `schema_migrations` and writes `supabase/migrations/_.sql`; overwrite prompt for a non-empty dir. | -| `migration list` | `ported` | `legacy/commands/migration/list/` | `n/a` | `n/a` | Native TS port. Merges remote `schema_migrations` with local files into a Glamour ASCII table (Local / Remote / Time-UTC columns); defaults to `--linked`. | -| `migration new` | `ported` | `legacy/commands/migration/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/migrations/_.sql` (mode 0644) from piped stdin; no DB/API. | -| `migration repair` | `ported` | `legacy/commands/migration/repair/` | `n/a` | `n/a` | Native TS port. Transactional create-table + TRUNCATE/UPSERT/DELETE; applied mode reads local files; repair-all prompt; defaults to `--linked`. | -| `migration squash` | `missing` | `missing` | `n/a` | `n/a` | Deliberate Go-proxy delegate (parity-preserving). A native port would emit pg-delta diff format instead of Go's `pg_dump` bytes (an accepted divergence, CLI-1597) and needs a bare-baseline shadow the seam does not yet expose; kept on the proxy for byte parity until CLI-1597's squash rewrite lands. | -| `migration up` | `ported` | `legacy/commands/migration/up/` | `n/a` | `n/a` | Native TS port. Computes pending migrations, upserts `[db.vault]`, applies each transactionally; `--include-all` for out-of-order; defaults to `--local`. Does not seed (matches Go). | -| `seed buckets` | `ported` | `legacy/commands/seed/buckets/` | `n/a` | `n/a` | Native TS port. Local-only (Go's `seed` defines no `--project-ref`, so the ref is always empty): seeds `[storage.buckets]` + `[storage.vector]` against the local Storage service gateway; remote/analytics paths are unreachable and omitted. `--linked`/`--local` accepted for surface parity (both seed local). Vector graceful-skip WARNINGs ported. | -| `test db` | `ported` | `legacy/commands/test/db/` | `n/a` | `n/a` | Native TS port. `--db-url`/`--local`/`--linked` + variadic paths; runs `supabase/pg_prove:3.36` via `docker run`; pgTAP enable/disable via `@effect/sql-pg`. `--network-id` override and `[images]` config override not modeled (documented divergences). | -| `test new` | `ported` | `legacy/commands/test/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/tests/_test.sql` from the embedded pgtap template; `--template` (pgtap). | +| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | +| --------------------------------- | --------- | -------------------------------------------------- | -------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `db diff` | `ported` | `legacy/commands/db/diff/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra diff via edge-runtime against a Go-seam-provisioned live shadow (`db __shadow`); `--use-pgadmin` / `--use-pg-schema` delegate to the Go binary. | +| `db dump` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `db lint` | `ported` | `legacy/commands/db/lint/` | `n/a` | `n/a` | Native TS port. Runs `plpgsql_check` in a rolled-back transaction via LegacyDbConnection; emits Go-parity pretty JSON. | +| `db pull` | `ported` | `legacy/commands/db/pull/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra migration + `--declarative` pg-delta export; reconciles `schema_migrations`. `--experimental` dump + initial-pull `pg_dump` (migra) delegate to the Go binary. | +| `db push` | `ported` | `legacy/commands/db/push/` | `n/a` | `n/a` | Native TS port. Connects local/linked/`--db-url`; pushes pending migrations, `--include-seed` seeds (`seed_files` hash tracking), `--include-roles`, `[db.vault]` secrets; `--dry-run`. `encrypted:` vault secrets + best-effort pg-delta catalog cache not ported (no output impact). | +| `db reset` | `ported` | `legacy/commands/db/reset/` | `n/a` | `n/a` | Remote path native (drop user schemas, vault upsert, MigrateAndSeed, `--version`/`--last`, `--sql-paths` seed override). Local path native: running check, recreate + migrate + seed via the hidden Go `db __db-bootstrap` seam, storage-gated bucket seeding (reuses `seed buckets`), git-branch `Finished…` line. Only the niche `--experimental` remote schema-files path still delegates to the Go binary (telemetry-disabled). | +| `db start` | `ported` | `legacy/commands/db/start/` | `n/a` | `n/a` | Native TS port. Validates config, checks "already running" (prints Go's line), else delegates the container bootstrap (create + health + initial schema/roles/migrations/seed + `_current_branch`) to the hidden Go `db __db-bootstrap --mode start` seam. No status table / `cli_stack_started` (those are `supabase start`). `--from-backup` supported. | +| `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | +| `inspect db db-stats` | `ported` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db replication-slots` | `ported` | `legacy/commands/inspect/db/replication-slots/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db locks` | `ported` | `legacy/commands/inspect/db/locks/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db blocking` | `ported` | `legacy/commands/inspect/db/blocking/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db outliers` | `ported` | `legacy/commands/inspect/db/outliers/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db calls` | `ported` | `legacy/commands/inspect/db/calls/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db index-stats` | `ported` | `legacy/commands/inspect/db/index-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db long-running-queries` | `ported` | `legacy/commands/inspect/db/long-running-queries/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db bloat` | `ported` | `legacy/commands/inspect/db/bloat/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db role-stats` | `ported` | `legacy/commands/inspect/db/role-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db vacuum-stats` | `ported` | `legacy/commands/inspect/db/vacuum-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db table-stats` | `ported` | `legacy/commands/inspect/db/table-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db traffic-profile` | `ported` | `legacy/commands/inspect/db/traffic-profile/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db cache-hit` | `ported` | `legacy/commands/inspect/db/cache-hit/` | `n/a` | `n/a` | Native TS port. Deprecated (use db-stats); routes to the active query. | +| `inspect db index-usage` | `ported` | `legacy/commands/inspect/db/index-usage/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db total-index-size` | `ported` | `legacy/commands/inspect/db/total-index-size/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db index-sizes` | `ported` | `legacy/commands/inspect/db/index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db table-sizes` | `ported` | `legacy/commands/inspect/db/table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db table-index-sizes` | `ported` | `legacy/commands/inspect/db/table-index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db total-table-sizes` | `ported` | `legacy/commands/inspect/db/total-table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db unused-indexes` | `ported` | `legacy/commands/inspect/db/unused-indexes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db table-record-counts` | `ported` | `legacy/commands/inspect/db/table-record-counts/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db seq-scans` | `ported` | `legacy/commands/inspect/db/seq-scans/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db role-configs` | `ported` | `legacy/commands/inspect/db/role-configs/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | +| `inspect db role-connections` | `ported` | `legacy/commands/inspect/db/role-connections/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | +| `migration down` | `ported` | `legacy/commands/migration/down/` | `n/a` | `n/a` | Native TS port. Revert prompt → drop user schemas → vault upsert → migrate&seed to the target version; defaults to `--local`. Skips Go's pgcache catalog write. | +| `migration fetch` | `ported` | `legacy/commands/migration/fetch/` | `n/a` | `n/a` | Native TS port. Reads `schema_migrations` and writes `supabase/migrations/_.sql`; overwrite prompt for a non-empty dir. | +| `migration list` | `ported` | `legacy/commands/migration/list/` | `n/a` | `n/a` | Native TS port. Merges remote `schema_migrations` with local files into a Glamour ASCII table (Local / Remote / Time-UTC columns); defaults to `--linked`. | +| `migration new` | `ported` | `legacy/commands/migration/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/migrations/_.sql` (mode 0644) from piped stdin; no DB/API. | +| `migration repair` | `ported` | `legacy/commands/migration/repair/` | `n/a` | `n/a` | Native TS port. Transactional create-table + TRUNCATE/UPSERT/DELETE; applied mode reads local files; repair-all prompt; defaults to `--linked`. | +| `migration squash` | `missing` | `missing` | `n/a` | `n/a` | Deliberate Go-proxy delegate (parity-preserving). A native port would emit pg-delta diff format instead of Go's `pg_dump` bytes (an accepted divergence, CLI-1597) and needs a bare-baseline shadow the seam does not yet expose; kept on the proxy for byte parity until CLI-1597's squash rewrite lands. | +| `migration up` | `ported` | `legacy/commands/migration/up/` | `n/a` | `n/a` | Native TS port. Computes pending migrations, upserts `[db.vault]`, applies each transactionally; `--include-all` for out-of-order; defaults to `--local`. Does not seed (matches Go). | +| `seed buckets` | `ported` | `legacy/commands/seed/buckets/` | `n/a` | `n/a` | Native TS port. Local-only (Go's `seed` defines no `--project-ref`, so the ref is always empty): seeds `[storage.buckets]` + `[storage.vector]` against the local Storage service gateway; remote/analytics paths are unreachable and omitted. `--linked`/`--local` accepted for surface parity (both seed local). Vector graceful-skip WARNINGs ported. | +| `test db` | `ported` | `legacy/commands/test/db/` | `n/a` | `n/a` | Native TS port. `--db-url`/`--local`/`--linked` + variadic paths; runs `supabase/pg_prove:3.36` via `docker run`; pgTAP enable/disable via `@effect/sql-pg`. `--network-id` override and `[images]` config override not modeled (documented divergences). | +| `test new` | `ported` | `legacy/commands/test/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/tests/_test.sql` from the embedded pgtap template; `--template` (pgtap). | ## Code Generation @@ -300,11 +300,11 @@ Legend: | `seed buckets` | `ported` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | | `db diff` | `ported` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) — native pg-delta / migra; `--use-pgadmin` / `--use-pg-schema` delegate to Go | | `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 push` | `ported` | [`../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) — includes Go-parity `--sql-paths` override for `[db.seed].sql_paths` | +| `db reset` | `ported` | [`../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 start` | `ported` | [`../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) | | `db advisors` | `ported` | [`../src/legacy/commands/db/advisors/advisors.command.ts`](../src/legacy/commands/db/advisors/advisors.command.ts) | | `db test` | `wrapped` | [`../src/legacy/commands/db/test/test.command.ts`](../src/legacy/commands/db/test/test.command.ts) | diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts index df39fcba27..8e69052093 100644 --- a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts @@ -37,6 +37,7 @@ function mockCliConfig(opts: { apiUrl: opts.apiUrl ?? "https://api.supabase.com", projectHost: opts.projectHost ?? "supabase.co", poolerHost: "supabase.com", + dashboardUrl: "https://supabase.com/dashboard", accessToken: opts.accessToken === undefined ? Option.none() : Option.some(Redacted.make(opts.accessToken)), projectId: Option.none(), diff --git a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index 11337a8955..5216459f76 100644 --- a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md @@ -1,59 +1,104 @@ # `supabase db push` +Native TypeScript port of `apps/cli-go/internal/db/push/push.go`. Applies pending +local migrations (and optionally seed data and custom roles) to the local or +linked/remote Postgres database. + ## Files Read -| Path | Format | When | -| -------------------------------- | ---------- | ------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | -| `/supabase/migrations/` | directory | always, to list migration files to push | -| `/supabase/roles.sql` | SQL | when `--include-roles` is set | -| seed files from config | SQL | when `--include-seed` is set | +| Path | Format | When | +| ------------------------------------- | ---------- | ----------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (embedded defaults used when absent) | +| `~/.supabase//project-ref` | plain text | on the `--linked` path (and the default target), to resolve the ref | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and a linked temp-role is minted | +| `/supabase/migrations/` | directory | when `[db.migrations].enabled` (default true), to list local files | +| `/supabase/migrations/*.sql` | SQL | for each pending migration, when applied (and not `--dry-run`) | +| seed files from `[db.seed].sql_paths` | SQL | when `--include-seed` and `[db.seed].enabled` (paths under `supabase/`) | +| `/supabase/roles.sql` | SQL | when `--include-roles` (existence check + apply) | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------------------ | ------ | ------------------------------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | on the `--linked` path (post-run cache, Go's `ensureProjectGroupsCached`) | +| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | + +No project files are written. All other effects are database mutations (below). + +## Database Mutations + +| Statement | When | +| ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `RESET ALL` + `BEGIN` … migration statements … `INSERT INTO supabase_migrations.schema_migrations(version, name, statements)` … `COMMIT` | per pending migration (after confirmation) | +| `CREATE SCHEMA/TABLE … supabase_migrations.schema_migrations`, `ALTER TABLE … ADD COLUMN …` | once before applying migrations (idempotent) | +| `RESET ALL` + `BEGIN` … roles.sql statements … `COMMIT` (no history row) | per `--include-roles` globals file (after confirmation) | +| `SELECT id, name FROM vault.secrets …`, `SELECT vault.update_secret(...)`, `SELECT vault.create_secret(...)` | when `[db.vault]` has syncable secrets and migrations are applied | +| `CREATE TABLE … supabase_migrations.seed_files`, seed statements, `INSERT … seed_files(path, hash) … ON CONFLICT …` | per pending seed file with `--include-seed` (after confirmation); a dirty seed only refreshes the hash | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---- | ---- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| — | — | — | — | The native handler connects to Postgres directly. On the `--linked` path the db-config resolver may call the Management API to mint a temporary login role (inherited from the shared resolver). | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `DB_PASSWORD` | password for direct database connection | no | +| Variable | Purpose | Required? | +| ----------------------- | ------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no (`--password`/`-p` takes precedence) | +| `SUPABASE_YES` | auto-confirm prompts (Go's `viper YES`) | no (also `--yes`) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | migration apply error | +| Code | Condition | +| ---- | ------------------------------------------------------------------------- | +| `0` | success (including "up to date") | +| `1` | mutually exclusive target flags (`[db-url linked local]`) | +| `1` | `ErrMissingLocal` — remote versions absent locally (suggests repair/pull) | +| `1` | `ErrMissingRemote` without `--include-all` (suggests `--include-all`) | +| `1` | user declined a confirmation prompt (`context canceled`) | +| `1` | `config.toml` parse failure | +| `1` | database connection / migration / seed / roles / vault apply failure | ## Output -### `--output-format text` (Go CLI compatible) +Diagnostics ("Connecting to…", "Applying migration…", "Seeding…", "Updating vault +secrets…", skip/up-to-date notices, dry-run plan, prompts) go to **stderr**. The +two summary lines Go prints to **stdout** — ` is up to date.` and +`Finished supabase db push.` (the command name in Aqua) — go to stdout in text +mode; in machine modes they are suppressed and a structured result is emitted. -Prints applied migration versions to stderr. With `--dry-run`, prints the migrations that would be applied. +### `--output-format text` (Go CLI compatible) -### `--output-format json` +Byte-matches Go: connection status, per-item progress, prompts, and the stdout +summary line, including ANSI color (Aqua command name, Bold file paths). -Not applicable. +### `--output-format json` / `stream-json` -### `--output-format stream-json` +stdout is payload-only. A single `result` object is emitted: -Not applicable. +```json +{ + "upToDate": false, + "dryRun": false, + "migrations": [".sql"], + "seeds": ["supabase/seed.sql"], + "roles": ["supabase/roles.sql"] +} +``` ## Notes -- `--dry-run` prints the migrations that would be applied without applying them. -- `--include-all` includes all migrations not found in remote history table. -- `--include-roles` includes custom roles from the roles file. -- `--include-seed` includes seed data from config. -- `--db-url`, `--linked` (default true), and `--local` are mutually exclusive. +- **Targets**: `--db-url`, `--linked` (default), and `--local` are mutually + exclusive; with no flag the target defaults to linked, matching Go. +- **Prompt order**: custom roles → migrations → seeds; each defaults to "yes" and + declining returns `context canceled`. +- **`--dry-run`** prints the plan (roles / migrations / seeds) and applies nothing. +- **`[db.migrations].enabled = false`** / **`[db.seed].enabled = false`** print a + skip notice naming the project ref (empty for local/db-url). +- **Vault**: only non-empty, non-`env()` `[db.vault]` literals are synced (Go syncs + secrets with a non-empty SHA256). **Known gap vs Go**: `encrypted:`-prefixed + vault secrets are currently skipped — dotenvx/ECIES decryption is not yet ported. +- **Migrations catalog cache** (Go's best-effort `pgcache.TryCacheMigrationsCatalog`, + warning-only) is not ported; it produces no output, so parity is preserved. diff --git a/apps/cli/src/legacy/commands/db/push/push.command.ts b/apps/cli/src/legacy/commands/db/push/push.command.ts index 2d6d8d17e9..8c936315ca 100644 --- a/apps/cli/src/legacy/commands/db/push/push.command.ts +++ b/apps/cli/src/legacy/commands/db/push/push.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyDbPush } from "./push.handler.ts"; +import { legacyDbPushRuntimeLayer } from "./push.layers.ts"; const config = { includeAll: Flag.boolean("include-all").pipe( @@ -37,5 +41,24 @@ export type LegacyDbPushFlags = CliCommand.Command.Config.Infer; export const legacyDbPushCommand = Command.make("push", config).pipe( Command.withDescription("Push new migrations to the remote database."), Command.withShortDescription("Push new migrations to the remote database"), - Command.withHandler((flags) => legacyDbPush(flags)), + Command.withHandler((flags) => + legacyDbPush(flags).pipe( + withLegacyCommandInstrumentation({ + flags: { + "include-all": flags.includeAll, + "include-roles": flags.includeRoles, + "include-seed": flags.includeSeed, + "dry-run": flags.dryRun, + "db-url": flags.dbUrl, + linked: flags.linked, + local: flags.local, + // `password` is a credential — always reaches telemetry as ``. + password: flags.password, + }, + aliases: { p: "password" }, + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyDbPushRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/db/push/push.errors.ts b/apps/cli/src/legacy/commands/db/push/push.errors.ts new file mode 100644 index 0000000000..ff808b969c --- /dev/null +++ b/apps/cli/src/legacy/commands/db/push/push.errors.ts @@ -0,0 +1,61 @@ +import { Data } from "effect"; + +/** + * Conflicting database-target flags. Reproduces cobra's + * `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` error byte-for-byte + * (`apps/cli-go/cmd/db.go:526`). + */ +export class LegacyDbPushTargetFlagsError extends Data.TaggedError("LegacyDbPushTargetFlagsError")<{ + readonly message: string; +}> {} + +/** + * Remote migration versions are missing from the local directory. Byte-matches + * Go's `migration.ErrMissingLocal` (`pkg/migration/apply.go:16`); the + * `migration repair` / `db pull` suggestion is attached (Go's `CmdSuggestion`). + */ +export class LegacyDbPushMissingLocalError extends Data.TaggedError( + "LegacyDbPushMissingLocalError", +)<{ + readonly message: string; + readonly suggestion: string; +}> {} + +/** + * Local migration files are ordered before the remote head and `--include-all` + * was not passed. Byte-matches Go's `migration.ErrMissingRemote` + * (`pkg/migration/apply.go:15`); the `--include-all` suggestion is attached. + */ +export class LegacyDbPushMissingRemoteError extends Data.TaggedError( + "LegacyDbPushMissingRemoteError", +)<{ + readonly message: string; + readonly suggestion: string; +}> {} + +/** + * The user declined a confirmation prompt. Go returns `errors.New(context.Canceled)` + * (`internal/db/push/push.go:80,91,110`), rendered as `context canceled`. + */ +export class LegacyDbPushCancelledError extends Data.TaggedError("LegacyDbPushCancelledError")<{ + readonly message: string; +}> {} + +/** `supabase/config.toml` failed to parse. */ +export class LegacyDbPushConfigLoadError extends Data.TaggedError("LegacyDbPushConfigLoadError")<{ + readonly message: string; +}> {} + +/** Locating `supabase/roles.sql` failed (Go's `failed to find custom roles: %w`). */ +export class LegacyDbPushRolesError extends Data.TaggedError("LegacyDbPushRolesError")<{ + readonly message: string; +}> {} + +/** + * A migration / seed / globals / vault statement failed while applying. Carries + * the underlying Postgres error (with Go's `At statement: ` context for + * migrations) so stderr matches Go's propagated error. + */ +export class LegacyDbPushApplyError extends Data.TaggedError("LegacyDbPushApplyError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/db/push/push.handler.ts b/apps/cli/src/legacy/commands/db/push/push.handler.ts index fddb270174..2cce52d1eb 100644 --- a/apps/cli/src/legacy/commands/db/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/db/push/push.handler.ts @@ -1,17 +1,350 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { + loadProjectConfig, + type LoadProjectConfigOptions, + ProjectConfigSchema, +} from "@supabase/config"; +import { Effect, FileSystem, Option, Path, Schema } from "effect"; + +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; +import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { legacyAqua, legacyBold } from "../../../shared/legacy-colors.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { + legacyApplyMigrations, + legacySeedGlobals, +} from "../../../shared/legacy-migration-apply.ts"; +import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; +import { resolveLegacyDbTargetFlags } from "../../../shared/legacy-db-target-flags.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyListLocalMigrations } from "../shared/legacy-pgdelta.cache.ts"; +import { + LEGACY_ERR_MISSING_LOCAL, + LEGACY_ERR_MISSING_REMOTE, + legacyFindPendingMigrations, + legacyIncludeAllPending, + legacySuggestIgnoreFlag, + legacySuggestRevertHistory, +} from "../shared/legacy-migration-pending.ts"; +import { + legacyMigrationsEnabled, + legacySeedEnabled, +} from "../shared/legacy-config-env-override.ts"; +import { + type LegacySeedFile, + legacyGetPendingSeeds, + legacySeedData, +} from "../shared/legacy-seed-ops.ts"; +import { legacyReadVaultDocument, legacyUpsertVaultSecrets } from "../shared/legacy-vault.ts"; +// Listing the remote `schema_migrations` history (with the 42P01 → empty rule) +// lives in the shared migration-history module (Go's `migration.ListRemoteMigrations`). +import { legacyListRemoteMigrations } from "../../../shared/legacy-migration-history.ts"; import type { LegacyDbPushFlags } from "./push.command.ts"; +import { + LegacyDbPushApplyError, + LegacyDbPushCancelledError, + LegacyDbPushConfigLoadError, + LegacyDbPushMissingLocalError, + LegacyDbPushMissingRemoteError, + LegacyDbPushRolesError, + LegacyDbPushTargetFlagsError, +} from "./push.errors.ts"; + +const CUSTOM_ROLES_PATH = "supabase/roles.sql"; + +const decodeDefaultConfig = Schema.decodeUnknownSync(ProjectConfigSchema); + +const toSlash = (p: string): string => p.replaceAll("\\", "/"); + +/** Go's `confirmPushAll` (`internal/db/push/push.go:123-129`) — bold filenames. */ +const confirmPushAll = (filenames: ReadonlyArray): string => + filenames.map((name) => ` • ${legacyBold(name)}\n`).join(""); + +/** Go's `confirmSeedAll` (`internal/db/push/push.go:131-140`) — bold paths, hash notice. */ +const confirmSeedAll = (seeds: ReadonlyArray): string => + seeds + .map((seed) => ` • ${legacyBold(seed.dirty ? `${seed.path} (hash update)` : seed.path)}\n`) + .join(""); +const applyError = (message: string) => new LegacyDbPushApplyError({ message }); + +/** + * `supabase db push` — apply pending local migrations (and optionally seed data + * and custom roles) to the local or linked/remote database. + * + * Strict 1:1 port of `apps/cli-go/internal/db/push/push.go`. + */ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: LegacyDbPushFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["db", "push"]; - if (flags.includeAll) args.push("--include-all"); - if (flags.includeRoles) args.push("--include-roles"); - if (flags.includeSeed) args.push("--include-seed"); - if (flags.dryRun) args.push("--dry-run"); - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - if (Option.isSome(flags.password)) args.push("--password", flags.password.value); - yield* proxy.exec(args); + const output = yield* Output; + const resolver = yield* LegacyDbConfigResolver; + const dbConn = yield* LegacyDbConnection; + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cliArgs = yield* CliArgs; + const dnsResolver = yield* LegacyDnsResolverFlag; + const yes = yield* legacyResolveYes; + + const workdir = cliConfig.workdir; + let linkedRefForCache: string | undefined; + + const body = Effect.gen(function* () { + const target = resolveLegacyDbTargetFlags(cliArgs.args); + // cobra MarkFlagsMutuallyExclusive("db-url", "linked", "local"), keyed off the + // explicitly-set flags (cobra's `Changed`), not the `--linked` default value. + if (target.setFlags.length > 1) { + return yield* Effect.fail( + new LegacyDbPushTargetFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, + }), + ); + } + // Go's push defaults `--linked` to true, so no target flag → linked. + const connType = target.connType ?? "linked"; + + // The linked path resolves the project ref before loading config so a matching + // `[remotes.]` block merges (Go's ParseDatabaseConfig → LoadConfig). For + // `--local` / `--db-url`, Go leaves `flags.ProjectRef` empty. + let projectRef = ""; + if (connType === "linked") { + const refResolver = yield* LegacyProjectRefResolver; + projectRef = yield* refResolver.loadProjectRef(Option.none()); + linkedRefForCache = projectRef; + } + + const loadOptions: LoadProjectConfigOptions | undefined = + projectRef !== "" ? { projectRef } : undefined; + const loaded = yield* loadProjectConfig(workdir, loadOptions).pipe( + Effect.catchTag( + "ProjectConfigParseError", + (cause) => + new LegacyDbPushConfigLoadError({ + message: `failed to parse supabase/config.toml: ${String(cause.cause)}`, + }), + ), + ); + const config = loaded === null ? decodeDefaultConfig({}) : loaded.config; + const document = loaded === null ? undefined : loaded.document; + if (loaded !== null && loaded.appliedRemote !== undefined) { + yield* output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr"); + } + + if (flags.dryRun) { + yield* output.raw("DRY RUN: migrations will *not* be pushed to the database.\n", "stderr"); + } + + const cfg = yield* resolver.resolve({ + dbUrl: flags.dbUrl, + connType, + dnsResolver, + password: flags.password, + }); + const databaseName = cfg.isLocal ? "local database" : "remote database"; + const statusTarget = cfg.isLocal ? "Local database" : "Remote database"; + + yield* Effect.scoped( + Effect.gen(function* () { + yield* output.raw( + `Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, + "stderr", + ); + const session = yield* dbConn.connect(cfg.conn, { isLocal: cfg.isLocal, dnsResolver }); + + // --- Collect pending migrations --- + let pending: ReadonlyArray = []; + if (!legacyMigrationsEnabled(config.db.migrations.enabled)) { + yield* output.raw( + `Skipping migrations because it is disabled in config.toml for project: ${projectRef}\n`, + "stderr", + ); + } else { + const migrationsDir = path.join(workdir, "supabase", "migrations"); + const remote = yield* legacyListRemoteMigrations(session); + const local = yield* legacyListLocalMigrations(fs, path, migrationsDir); + const result = legacyFindPendingMigrations(local, remote); + if (result.kind === "missing-local") { + return yield* Effect.fail( + new LegacyDbPushMissingLocalError({ + message: LEGACY_ERR_MISSING_LOCAL, + suggestion: legacySuggestRevertHistory(result.versions), + }), + ); + } + if (result.kind === "missing-remote") { + if (!flags.includeAll) { + // Go's suggestIgnoreFlag lists the workdir-relative paths. + const relPaths = result.paths.map((p) => toSlash(path.relative(workdir, p))); + return yield* Effect.fail( + new LegacyDbPushMissingRemoteError({ + message: LEGACY_ERR_MISSING_REMOTE, + suggestion: legacySuggestIgnoreFlag(relPaths), + }), + ); + } + pending = legacyIncludeAllPending(local, remote.length, result.paths); + } else { + pending = result.pending; + } + } + + // --- Collect pending seeds --- + let seeds: ReadonlyArray = []; + if (flags.includeSeed) { + if (!legacySeedEnabled(config.db.seed.enabled)) { + yield* output.raw( + `Skipping seed because it is disabled in config.toml for project: ${projectRef}\n`, + "stderr", + ); + } else { + seeds = yield* legacyGetPendingSeeds( + session, + fs, + path, + config.db.seed.sql_paths, + workdir, + ); + } + } + + // --- Collect custom roles --- + const globals: Array = []; + if (flags.includeRoles) { + const exists = yield* fs.exists(path.join(workdir, CUSTOM_ROLES_PATH)).pipe( + Effect.mapError( + (cause) => + new LegacyDbPushRolesError({ + message: `failed to find custom roles: ${cause.message}`, + }), + ), + ); + if (exists) globals.push(CUSTOM_ROLES_PATH); + } + + // --- Nothing to push --- + if (pending.length === 0 && seeds.length === 0 && globals.length === 0) { + if (output.format === "text") { + yield* output.raw(`${statusTarget} is up to date.\n`); + } else { + yield* output.success(`${statusTarget} is up to date.`, { + upToDate: true, + dryRun: flags.dryRun, + migrations: [], + seeds: [], + roles: [], + }); + } + return; + } + + if (flags.dryRun) { + if (globals.length > 0) { + yield* output.raw( + `Would create custom roles ${legacyBold(globals[0]!)}...\n`, + "stderr", + ); + } + if (pending.length > 0) { + yield* output.raw("Would push these migrations:\n", "stderr"); + yield* output.raw(confirmPushAll(pending.map((p) => path.basename(p))), "stderr"); + } + if (seeds.length > 0) { + yield* output.raw("Would seed these files:\n", "stderr"); + yield* output.raw(confirmSeedAll(seeds), "stderr"); + } + } else { + // --- Custom roles --- + if (globals.length > 0) { + const ok = yield* legacyPromptYesNo( + output, + yes, + "Do you want to create custom roles in the database cluster?", + true, + ); + if (!ok) { + return yield* Effect.fail( + new LegacyDbPushCancelledError({ message: "context canceled" }), + ); + } + yield* legacySeedGlobals( + session, + fs, + path, + globals.map((g) => path.join(workdir, g)), + applyError, + ); + } + + // --- Migrations --- + if (pending.length > 0) { + const ok = yield* legacyPromptYesNo( + output, + yes, + `Do you want to push these migrations to the ${databaseName}?\n${confirmPushAll(pending.map((p) => path.basename(p)))}`, + true, + ); + if (!ok) { + return yield* Effect.fail( + new LegacyDbPushCancelledError({ message: "context canceled" }), + ); + } + yield* legacyUpsertVaultSecrets(session, legacyReadVaultDocument(document), applyError); + yield* legacyApplyMigrations(session, fs, path, pending, applyError); + // Go best-effort caches the migrations catalog for pg-delta; a failure + // only warns (`push.go:99-101`). The catalog cache is not yet ported, so + // there is nothing to warn about — parity is preserved (no extra output). + } else { + yield* output.raw("Schema migrations are up to date.\n", "stderr"); + } + + // --- Seeds --- + if (seeds.length > 0) { + const ok = yield* legacyPromptYesNo( + output, + yes, + `Do you want to seed the ${databaseName} with these files?\n${confirmSeedAll(seeds)}`, + true, + ); + if (!ok) { + return yield* Effect.fail( + new LegacyDbPushCancelledError({ message: "context canceled" }), + ); + } + yield* legacySeedData(session, fs, workdir, path, seeds, applyError); + } else if (flags.includeSeed) { + yield* output.raw("Seed files are up to date.\n", "stderr"); + } + } + + if (output.format === "text") { + yield* output.raw(`Finished ${legacyAqua("supabase db push")}.\n`); + } else { + yield* output.success("Finished supabase db push.", { + upToDate: false, + dryRun: flags.dryRun, + migrations: pending.map((p) => path.basename(p)), + seeds: seeds.map((s) => s.path), + roles: globals, + }); + } + }), + ); + }); + + yield* body.pipe( + Effect.ensuring( + Effect.suspend(() => + linkedRefForCache !== undefined && linkedRefForCache !== "" + ? linkedProjectCache.cache(linkedRefForCache) + : Effect.void, + ), + ), + Effect.ensuring(telemetryState.flush), + ); }); diff --git a/apps/cli/src/legacy/commands/db/push/push.integration.test.ts b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts new file mode 100644 index 0000000000..f43fa47d02 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts @@ -0,0 +1,652 @@ +import { createHash } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + LEGACY_VALID_REF, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { LegacyDnsResolverFlag, LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import type { OutputFormat } from "../../../../shared/output/types.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import type { + LegacyDbConfigFlags, + LegacyResolvedDbConfig, +} from "../../../shared/legacy-db-config.types.ts"; +import { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import { + LegacyDbConnection, + type LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import { legacyDbPush } from "./push.handler.ts"; +import type { LegacyDbPushFlags } from "./push.command.ts"; + +const LIST_MIGRATIONS = + "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; +const SELECT_SEEDS = "SELECT path, hash FROM supabase_migrations.seed_files"; +const READ_VAULT = "SELECT id, name FROM vault.secrets WHERE name = ANY($1)"; + +const LOCAL_CONN: LegacyPgConnInput = { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "postgres", + database: "postgres", +}; + +const DEFAULT_FLAGS: LegacyDbPushFlags = { + includeAll: false, + includeRoles: false, + includeSeed: false, + dryRun: false, + dbUrl: Option.none(), + linked: false, + local: true, + password: Option.none(), +}; + +function mockResolver(opts: { isLocal?: boolean } = {}) { + return Layer.succeed(LegacyDbConfigResolver, { + resolve: (_flags: LegacyDbConfigFlags) => + Effect.succeed({ + conn: LOCAL_CONN, + isLocal: opts.isLocal ?? true, + } satisfies LegacyResolvedDbConfig), + resolvePoolerFallback: () => Effect.succeed(Option.none()), + }); +} + +function mockConnection(opts: { + remoteMigrations?: ReadonlyArray; + remoteSeeds?: Readonly>; + vaultRows?: ReadonlyArray<{ id: string; name: string }>; + noSeedTable?: boolean; + failExec?: string; +}) { + const execs: Array = []; + const queries: Array<{ sql: string; params?: ReadonlyArray }> = []; + const layer = Layer.succeed(LegacyDbConnection, { + connect: () => + Effect.succeed({ + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + exec: (sql: string): Effect.Effect => + Effect.suspend((): Effect.Effect => { + execs.push(sql); + if (opts.failExec !== undefined && sql === opts.failExec) { + return Effect.fail( + new LegacyDbExecError({ message: "ERROR: boom (SQLSTATE 42601)" }), + ); + } + return Effect.void; + }), + query: ( + sql: string, + params?: ReadonlyArray, + ): Effect.Effect>, LegacyDbExecError> => + Effect.suspend( + (): Effect.Effect>, LegacyDbExecError> => { + queries.push({ sql, params }); + if (sql === LIST_MIGRATIONS) { + return Effect.succeed( + (opts.remoteMigrations ?? []).map((version) => ({ version })), + ); + } + if (sql === SELECT_SEEDS) { + if (opts.noSeedTable === true) { + return Effect.fail( + new LegacyDbExecError({ + message: 'relation "supabase_migrations.seed_files" does not exist', + code: "42P01", + }), + ); + } + return Effect.succeed( + Object.entries(opts.remoteSeeds ?? {}).map(([path, hash]) => ({ path, hash })), + ); + } + if (sql === READ_VAULT) { + return Effect.succeed(opts.vaultRows ?? []); + } + return Effect.succeed([]); + }, + ), + }), + }); + return { + layer, + get execs() { + return execs; + }, + get queries() { + return queries; + }, + }; +} + +function setup( + workdir: string, + opts: { + toml?: string; + files?: Readonly>; + format?: OutputFormat; + confirm?: ReadonlyArray; + args?: ReadonlyArray; + yes?: boolean; + isLocal?: boolean; + projectRef?: string; + linkedFails?: boolean; + remoteMigrations?: ReadonlyArray; + remoteSeeds?: Readonly>; + vaultRows?: ReadonlyArray<{ id: string; name: string }>; + noSeedTable?: boolean; + failExec?: string; + }, +) { + if (opts.toml !== undefined) { + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), opts.toml); + } + for (const [rel, content] of Object.entries(opts.files ?? {})) { + const abs = join(workdir, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); + } + + const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm }); + const conn = mockConnection(opts); + const telemetry = mockLegacyTelemetryStateTracked(); + const linkedCache = mockLegacyLinkedProjectCacheTracked(); + const projectRefLayer = Layer.succeed(LegacyProjectRefResolver, { + resolve: () => Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), + resolveForLink: () => Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), + resolveOptional: () => Effect.succeed(Option.some(opts.projectRef ?? LEGACY_VALID_REF)), + loadProjectRef: () => + opts.linkedFails === true + ? Effect.fail( + new LegacyProjectNotLinkedError({ + message: "Cannot find project ref. Have you run supabase link?", + }), + ) + : Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), + promptProjectRef: () => Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), + }); + + const layer = Layer.mergeAll( + out.layer, + conn.layer, + mockResolver({ isLocal: opts.isLocal ?? true }), + mockLegacyCliConfig({ workdir }), + BunServices.layer, + Layer.succeed(CliArgs, { args: opts.args ?? ["db", "push", "--local"] }), + Layer.succeed(LegacyYesFlag, opts.yes ?? false), + Layer.succeed(LegacyDnsResolverFlag, "native"), + projectRefLayer, + telemetry.layer, + linkedCache.layer, + ); + return { layer, out, conn, telemetry, linkedCache }; +} + +const MIGRATION_DIR = "supabase/migrations"; +const migrationFile = (version: string, body = "create table t ();") => ({ + [`${MIGRATION_DIR}/${version}_test.sql`]: body, +}); + +describe("legacy db push", () => { + const tmp = useLegacyTempWorkdir("supabase-db-push-"); + + it.live("reports up to date when nothing is pending (text)", () => { + const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stdoutText).toBe("Local database is up to date.\n"); + // No migration was applied. + expect(conn.execs).not.toContain("BEGIN"); + }); + }); + + it.live("emits a json result for an up-to-date run", () => { + const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', format: "json" }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["upToDate"]).toBe(true); + expect(success?.data?.["migrations"]).toEqual([]); + }); + }); + + it.live("rejects mutually exclusive target flags", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "push", "--local", "--linked"], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }); + }); + + it.live("applies a pending migration after confirmation", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + // "supabase db push" is wrapped in Aqua (cyan) on stdout, matching Go. + expect(out.stdoutText).toContain("Finished"); + expect(out.stdoutText).toContain("supabase db push"); + // The migration body + history insert ran inside a transaction. + expect(conn.execs).toContain("BEGIN"); + expect(conn.execs).toContain("COMMIT"); + expect(conn.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations"))).toBe( + true, + ); + }); + }); + + it.live("returns context canceled when the migration prompt is declined", () => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + confirm: [false], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("context canceled"); + } + expect(conn.execs).not.toContain("BEGIN"); + }); + }); + + it.live("prints the plan without applying in dry-run mode", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, dryRun: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("DRY RUN: migrations will *not* be pushed to the database."); + expect(out.stderrText).toContain("Would push these migrations:"); + expect(out.stderrText).toContain("20240101000000_test.sql"); + expect(conn.execs).not.toContain("BEGIN"); + }); + }); + + it.live("fails with a repair suggestion when remote has versions missing locally", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + remoteMigrations: ["20240101000000"], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "Remote migration versions not found in local migrations directory.", + ); + expect(JSON.stringify(exit.cause)).toContain("migration repair --status reverted"); + } + expect(out).toBeDefined(); + }); + }); + + it.live("fails with an --include-all suggestion for out-of-order local migrations", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + // 0101 is local-only and ordered before the already-applied remote 0202. + files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, + remoteMigrations: ["20240202000000"], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("--include-all"); + } + }); + }); + + it.live("pushes out-of-order migrations with --include-all", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, + remoteMigrations: ["20240202000000"], + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeAll: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }); + }); + + it.live("skips migrations when disabled in config and reports up to date", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nenabled = false\n', + files: migrationFile("20240101000000"), + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain( + "Skipping migrations because it is disabled in config.toml for project:", + ); + expect(out.stdoutText).toBe("Local database is up to date.\n"); + }); + }); + + it.live("seeds a new file with --include-seed", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); + expect( + conn.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations.seed_files")), + ).toBe(true); + }); + }); + + it.live("reports seed files up to date when hash matches remote", () => { + // sha256 of the seed body must match the remote hash to be skipped. + const body = "insert into t values (1);"; + const hash = createHash("sha256").update(body).digest("hex"); + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": body }, + remoteSeeds: { "supabase/seed.sql": hash }, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stdoutText).toBe("Local database is up to date.\n"); + }); + }); + + it.live("skips seeding when disabled in config", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nenabled = false\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain( + "Skipping seed because it is disabled in config.toml for project:", + ); + }); + }); + + it.live("creates custom roles with --include-roles", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/roles.sql": "create role app;" }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeRoles: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Seeding globals from roles.sql..."); + }); + }); + + it.live("reports schema migrations up to date when only roles are pushed", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/roles.sql": "create role app;" }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeRoles: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Schema migrations are up to date."); + }); + }); + + it.live("returns context canceled when the roles prompt is declined", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/roles.sql": "create role app;" }, + confirm: [false], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush({ ...DEFAULT_FLAGS, includeRoles: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("context canceled"); + }); + }); + + it.live("returns context canceled when the seed prompt is declined", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + confirm: [false], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("context canceled"); + }); + }); + + it.live("re-hashes a dirty seed without re-running its statements", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + // Remote hash differs → dirty. + remoteSeeds: { "supabase/seed.sql": "stalehash" }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Updating seed hash to supabase/seed.sql..."); + // Dirty seed only upserts the hash; the body statement is not executed. + expect(conn.execs).not.toContain("insert into t values (1);"); + }); + }); + + it.live("treats every seed as pending when the seed_files table is absent", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + noSeedTable: true, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); + }); + }); + + it.live("warns and reports up to date when no seed files match", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nsql_paths = ["missing.sql"]\n', + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("WARN: no files matched pattern: supabase/missing.sql"); + expect(out.stdoutText).toBe("Local database is up to date.\n"); + }); + }); + + it.live("reports seed files up to date when migrations push but no seeds match", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nsql_paths = ["missing.sql"]\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Seed files are up to date."); + }); + }); + + it.live("upserts vault secrets (update existing, create new) before migrating", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.vault]\nexisting = "v1"\nfresh = "v2"\n', + files: migrationFile("20240101000000"), + // `existing` already present remotely → update; `fresh` → create. + vaultRows: [{ id: "id-1", name: "existing" }], + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Updating vault secrets..."); + const sqls = conn.queries.map((q) => q.sql); + expect(sqls).toContain("SELECT vault.update_secret($1, $2)"); + expect(sqls).toContain("SELECT vault.create_secret($1, $2)"); + }); + }); + + it.live("defaults to the linked target when no target flag is set", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "push"], + isLocal: false, + projectRef: LEGACY_VALID_REF, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, local: false }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Connecting to remote database..."); + expect(out.stdoutText).toBe("Remote database is up to date.\n"); + }); + }); + + it.live("surfaces an apply error with statement context", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000", "BOOM;"), + failExec: "BOOM", + confirm: [true], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("At statement: 0"); + } + }); + }); + + it.live("dry-run lists roles, migrations and seeds without applying", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + "supabase/roles.sql": "create role app;", + "supabase/seed.sql": "insert into t values (1);", + }, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ + ...DEFAULT_FLAGS, + dryRun: true, + includeRoles: true, + includeSeed: true, + }).pipe(Effect.provide(layer)); + // The roles path is wrapped in Bold (ANSI), matching Go's utils.Bold. + expect(out.stderrText).toContain("Would create custom roles"); + expect(out.stderrText).toContain("roles.sql"); + expect(out.stderrText).toContain("Would push these migrations:"); + expect(out.stderrText).toContain("Would seed these files:"); + expect(conn.execs).not.toContain("BEGIN"); + }); + }); + + it.live("dry-run with only custom roles lists them without a migration section", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/roles.sql": "create role app;" }, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, dryRun: true, includeRoles: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Would create custom roles"); + expect(out.stderrText).not.toContain("Would push these migrations:"); + }); + }); + + it.live("uses embedded defaults when no config file is present", () => { + const { layer, out } = setup(tmp.current, { + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + // No config.toml written → loadProjectConfig returns null → default config + // (migrations enabled), and the vault document is absent. + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }); + }); + + it.live("fails when config.toml cannot be parsed", () => { + const { layer } = setup(tmp.current, { toml: "this is = = not [[[ valid toml" }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(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 parse supabase/config.toml"); + } + }); + }); + + it.live("announces a matching [remotes.*] override on the linked path", () => { + const { layer, out } = setup(tmp.current, { + toml: `project_id = "base"\n\n[remotes.preview]\nproject_id = "${LEGACY_VALID_REF}"\n`, + args: ["db", "push", "--linked"], + isLocal: false, + projectRef: LEGACY_VALID_REF, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Loading config override: [remotes.preview]"); + }); + }); + + it.live("pushes to the linked project and caches the project ref (json)", () => { + const { layer, out, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + args: ["db", "push", "--linked"], + isLocal: false, + projectRef: LEGACY_VALID_REF, + format: "json", + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Connecting to remote database..."); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(LEGACY_VALID_REF); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["migrations"]).toEqual(["20240101000000_test.sql"]); + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/push/push.layers.ts b/apps/cli/src/legacy/commands/db/push/push.layers.ts new file mode 100644 index 0000000000..c3cf8d4cca --- /dev/null +++ b/apps/cli/src/legacy/commands/db/push/push.layers.ts @@ -0,0 +1,73 @@ +import { Layer } from "effect"; + +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { legacyCredentialsLayer } from "../../../auth/legacy-credentials.layer.ts"; +import { legacyHttpClientLayer } from "../../../auth/legacy-http-debug.layer.ts"; +import { legacyPlatformApiFactoryLayer } from "../../../auth/legacy-platform-api-factory.layer.ts"; +import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.ts"; +import { legacyDbConfigLayer } from "../../../shared/legacy-db-config.layer.ts"; +import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { legacyIdentityStitchLayer } from "../../../shared/legacy-identity-stitch.ts"; +import { legacyLinkedProjectCacheLayer } from "../../../telemetry/legacy-linked-project-cache.layer.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; + +/** + * Runtime layer for `supabase db push`. Same shape as `db lint`: it spans local + * (`--local` / `--db-url`) and linked DB access, so it composes the Postgres + * connection, the db-config resolver, project-ref resolution, and the + * linked-project cache (Go's PersistentPostRun `ensureProjectGroupsCached`). + * + * Like `db lint`, it deliberately uses the **lazy** `legacyPlatformApiFactoryLayer` + * (not the eager management-API runtime) so the auth-free `--local` path never + * resolves an access token at layer-build time. `legacyCliConfigLayer` is provided + * to each consumer that needs it (legacy CLAUDE.md item 5); the single + * `legacyIdentityStitchLayer` reference is shared so the factory, the cache, and + * the db-config resolver share one `stitchAttempted` guard. + */ +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const credentials = legacyCredentialsLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), +); + +const platformApiFactory = legacyPlatformApiFactoryLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), +); + +const projectRef = legacyProjectRefLayer.pipe( + Layer.provide(platformApiFactory), + Layer.provide(cliConfig), +); + +const linkedProjectCache = legacyLinkedProjectCacheLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(httpClient), + Layer.provide(legacyIdentityStitchLayer), +); + +const dbConfig = legacyDbConfigLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), +); + +export const legacyDbPushRuntimeLayer = Layer.mergeAll( + dbConfig, + legacyDbConnectionLayer, + cliConfig, + httpClient, + credentials, + projectRef, + linkedProjectCache, + legacyIdentityStitchLayer, + legacyTelemetryStateLayer, + commandRuntimeLayer(["db", "push"]), +); 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 d1291e27a4..6089303d32 100644 --- a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md @@ -1,60 +1,143 @@ # `supabase db reset` +Native TypeScript port of `apps/cli-go/internal/db/reset/reset.go`. Reinitialises a +database from local migrations (plus seed). The **remote** path (`--linked`, or a +remote `--db-url`) is native: drop all user schemas, upsert vault secrets, then +re-apply migrations and seed. The **local** path (`--local`/default, or a `--db-url` +pointing at the local stack) is also native: TS orchestrates the running check, +messages, bucket seeding, and git-branch line, while the container-recreate +primitives run behind the hidden Go `db __db-bootstrap` seam. Only the niche +**`--experimental`** remote schema-files path still delegates to the Go binary. + ## 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 or `--sql-paths` | SQL | unless `--no-seed` is set | +| Path | Format | When | +| ------------------------------------------------------ | ---------- | ------------------------------------------------------------------------- | +| `/supabase/migrations/` | directory | to validate `--version` / resolve `--last`, and to load migrations | +| `/supabase/config.toml` | TOML | remote path + local bucket seeding (embedded defaults when absent) | +| `/.git/HEAD` (walked upward) | plain text | local path, for the `Finished … on branch .` line | +| `~/.supabase//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 | +| `/supabase/buckets/` | files | local path, when storage is up and `[storage.buckets]` configure objects | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------------------ | ------ | --------------------------------- | +| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | + +On the local path the Go seam additionally recreates the `supabase_db_` +container/volume and applies the initial schema (`SetupLocalDatabase`); the +`--experimental` remote path produces whatever the delegated Go binary writes. + +## Subprocesses + +| Command | When | Purpose | +| --------------------------------------------------------------------------- | ----------------------------------- | ----------------------------------------------------------------------- | +| `docker container inspect supabase_db_` | local path | `AssertSupabaseDbIsRunning` probe (Podman fallback) | +| `supabase-go db __db-bootstrap --mode recreate [--version ] [--no-seed]` | local path | recreate container + init schema + migrate + seed + restart services | +| `supabase-go db __db-bootstrap --mode await-storage` | local path | storage health gate before bucket seeding (`ready` / `absent`) | +| `supabase-go db reset --linked\|--db-url … [--no-seed]` | `--experimental` remote, no version | the un-ported experimental schema-files apply path (telemetry disabled) | + +The seam subprocesses run with `SUPABASE_TELEMETRY_DISABLED=1`, stderr inherited; +`--network-id` / a flag-selected `--profile` are forwarded. + +## Database Mutations + +### Remote path (native, in TS) + +| Statement | When | +| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| `drop.sql` `DO` block (drops user schemas/extensions/public objects, truncates auth/migrations) | always, first | +| `SELECT vault.update_secret(...)` / `vault.create_secret(...)` | when `[db.vault]` has syncable secrets | +| migration statements + `schema_migrations` history insert (per file, transactional) | when `[db.migrations].enabled`, for migrations `≤ --version` | +| seed statements + `seed_files` hash upsert | when `[db.seed].enabled` and not `--no-seed` | + +### Local path (inside the Go seam) + +The recreate seam drops & recreates the `postgres`/`_supabase` databases (PG≤14) or +removes & recreates the db container/volume (PG15), applies the initial schema + +roles, then runs `MigrateAndSeed` (migrations `≤ --version`, seed unless `--no-seed`) +and restarts the storage/auth/realtime/pooler containers. Bucket objects are then +seeded over the Storage gateway (reusing the `seed buckets` local path). ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---- | ---- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| — | — | — | — | Connects to Postgres directly. The `--linked` resolver may call the Management API to mint a temporary login role; local bucket seeding calls the Storage gateway. | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `DB_PASSWORD` | password for direct database connection | no | +| Variable | Purpose | Required? | +| ----------------------- | ----------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no | +| `SUPABASE_YES` | auto-confirm the reset prompt | no (also `--yes`) | +| `SUPABASE_EXPERIMENTAL` | routes the experimental schema-files path to Go | no (also `--experimental`) | +| `SUPABASE_PROJECT_ID` | overrides the local container id (`utils.DbId`) | no | ## Exit Codes -| Code | Condition | -| ---- | --------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | migration apply error | +| Code | Condition | +| ---- | ---------------------------------------------------------------- | +| `0` | success | +| `1` | mutually exclusive target flags (`[db-url linked local]`) | +| `1` | `--version` + `--last` together (`[last version]`) | +| `1` | `--version` not an integer (`invalid version number`) | +| `1` | `--version` has no matching migration file | +| `1` | local: database not running (`supabase start is not running.`) | +| `1` | user declined the reset confirmation (`context canceled`) | +| `1` | `config.toml` parse failure | +| `1` | drop / migrate / seed / vault apply failure, or connection error | +| `1` | local: container recreate / storage health-gate failure (seam) | ## Output +The remote path prints `Resetting remote database…` to **stderr**, then the +drop/migrate/seed progress (`Applying migration …`, `Seeding data from …`). Go +connects with `io.Discard`, so there is **no** `Connecting to … database…` line and +**no** `Finished …` line on the remote path. + +The local path prints `Resetting local database…` to **stderr**, then the seam's +`Recreating database...` / `Restarting containers...` progress, and finally +`Finished supabase db reset on branch .` (`supabase db reset` and `` +in Aqua). + ### `--output-format text` (Go CLI compatible) -Prints progress to stderr as migrations are applied. +Byte-matches Go's stderr progress for both the remote and local paths. The +`--experimental` remote path passes the delegated Go binary's output through +unchanged. -### `--output-format json` +### `--output-format json` / `stream-json` -Not applicable. +stdout is payload-only; a `result` object is emitted: -### `--output-format stream-json` +```json +{ "target": "remote" | "local", "version": "" } +``` -Not applicable. +In machine modes the remote confirmation prompt is non-interactive and takes its +default (`false`), so a remote reset is declined unless `--yes` is set. The local +path has no confirmation prompt. ## 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`. +- **Target/local split** follows Go's `IsLocalDatabase(resolved config)`, not the + flag name: a `--db-url` pointing at the local stack is treated as a local reset. +- `--no-seed` forces seeding off (Go sets `Config.Db.Seed.Enabled = false`); on the + local path it is forwarded to the recreate seam so `MigrateAndSeed` skips the seed. +- `--sql-paths` overrides `[db.seed].sql_paths` for one reset and force-enables seeding + even when `[db.seed].enabled = false`; repeat it to seed multiple files or glob + patterns (supabase-relative). Mutually exclusive with `--no-seed`. On the local path + it is forwarded to the recreate seam; on the remote path it seeds the selected + database after migrations (Go warns when paired with `--linked` / `--db-url`). +- `--last n` reverts the most recent `n` migrations; if `n ≥ total`, the reset target + version becomes `-` (revert everything). Mutually exclusive with `--version`. - `--db-url`, `--linked`, and `--local` (default true) are mutually exclusive. +- **Known interim**: only `--experimental` remote resets run via the Go binary; the + best-effort pg-delta catalog cache (inside the seam) is not surfaced (no output + impact). `encrypted:` vault secrets are skipped on the remote path. 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 15a61c8d88..664d740ffd 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.command.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyDbReset } from "./reset.handler.ts"; +import { legacyDbResetRuntimeLayer } from "./reset.layers.ts"; const noSqlPaths: ReadonlyArray = []; @@ -42,5 +46,24 @@ export type LegacyDbResetFlags = CliCommand.Command.Config.Infer; export const legacyDbResetCommand = Command.make("reset", config).pipe( Command.withDescription("Resets the local database to current migrations."), Command.withShortDescription("Resets the local database to current migrations"), - Command.withHandler((flags) => legacyDbReset(flags)), + Command.withHandler((flags) => + legacyDbReset(flags).pipe( + withLegacyCommandInstrumentation({ + flags: { + "db-url": flags.dbUrl, + linked: flags.linked, + local: flags.local, + "no-seed": flags.noSeed, + "sql-paths": flags.sqlPaths, + version: flags.version, + last: flags.last, + }, + // Go marks `--version` telemetry-safe for migration/squash; reset reuses + // the same package-level var, so keep it safe here too. + safeFlags: ["version"], + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyDbResetRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.e2e.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.e2e.test.ts new file mode 100644 index 0000000000..3095c918eb --- /dev/null +++ b/apps/cli/src/legacy/commands/db/reset/reset.e2e.test.ts @@ -0,0 +1,62 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +import { runSupabase } from "../../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; + +/** + * Golden-path e2e: exercises the real compiled-binary boundary for the + * Docker-free paths of `db reset` — the flag validations that run BEFORE the + * local/remote split (so no container, config override, or seam subprocess is + * touched). The full local/remote reset behavior is covered by the integration + * suite with the bootstrap seam and DB connection mocked; booting a real stack is + * deliberately out of scope here (matching the sibling `db diff` / `seed buckets` + * legacy e2e tests). + */ +describe("supabase db reset (legacy)", () => { + let projectDir: string; + + beforeAll(() => { + projectDir = mkdtempSync(join(tmpdir(), "supabase-db-reset-e2e-")); + mkdirSync(join(projectDir, "supabase"), { recursive: true }); + writeFileSync(join(projectDir, "supabase", "config.toml"), 'project_id = "test"\n'); + }); + + afterAll(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + test("rejects mutually exclusive target flags", { timeout: E2E_TIMEOUT_MS }, async () => { + const { exitCode, stdout, stderr } = await runSupabase(["db", "reset", "--linked", "--local"], { + entrypoint: "legacy", + cwd: projectDir, + }); + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain( + "if any flags in the group [db-url linked local] are set none of the others can be", + ); + }); + + test("rejects a non-integer --version", { timeout: E2E_TIMEOUT_MS }, async () => { + const { exitCode, stdout, stderr } = await runSupabase( + ["db", "reset", "--version", "not-a-number"], + { entrypoint: "legacy", cwd: projectDir }, + ); + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain("invalid version number"); + }); + + test("rejects --version together with --last", { timeout: E2E_TIMEOUT_MS }, async () => { + const { exitCode, stdout, stderr } = await runSupabase( + ["db", "reset", "--linked", "--version", "20240101000000", "--last", "1"], + { entrypoint: "legacy", cwd: projectDir }, + ); + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain( + "if any flags in the group [last version] are set none of the others can be", + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.errors.ts b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts new file mode 100644 index 0000000000..bb9ec23330 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts @@ -0,0 +1,88 @@ +import { Data } from "effect"; + +/** + * Conflicting database-target flags. Reproduces cobra's + * `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` (`cmd/db.go:573`). + */ +export class LegacyDbResetTargetFlagsError extends Data.TaggedError( + "LegacyDbResetTargetFlagsError", +)<{ + readonly message: string; +}> {} + +/** + * `--version` and `--last` together. Reproduces cobra's + * `MarkFlagsMutuallyExclusive("version", "last")` (`cmd/db.go:576`). + */ +export class LegacyDbResetVersionFlagsError extends Data.TaggedError( + "LegacyDbResetVersionFlagsError", +)<{ + readonly message: string; +}> {} + +/** + * `--version` is not a valid integer. Byte-matches Go's + * `failed to parse : invalid version number` (`repair.go:24-29`). + */ +export class LegacyDbResetInvalidVersionError extends Data.TaggedError( + "LegacyDbResetInvalidVersionError", +)<{ + readonly message: string; +}> {} + +/** + * No migration file matches `--version`. Byte-matches Go's + * `glob supabase/migrations/_*.sql: file does not exist` + * (`repair.GetMigrationFile`). + */ +export class LegacyDbResetMigrationFileError extends Data.TaggedError( + "LegacyDbResetMigrationFileError", +)<{ + readonly message: string; +}> {} + +/** + * The user declined the reset confirmation. Go returns + * `errors.New(context.Canceled)` (`internal/db/reset/reset.go:248`). + */ +export class LegacyDbResetCancelledError extends Data.TaggedError("LegacyDbResetCancelledError")<{ + readonly message: string; +}> {} + +/** `supabase/config.toml` failed to parse. */ +export class LegacyDbResetConfigLoadError extends Data.TaggedError("LegacyDbResetConfigLoadError")<{ + readonly message: string; +}> {} + +/** A drop / migrate / seed / vault statement failed during the remote reset. */ +export class LegacyDbResetApplyError extends Data.TaggedError("LegacyDbResetApplyError")<{ + readonly message: string; +}> {} + +/** + * The local database container is not running. Byte-matches Go's + * `utils.ErrNotRunning` (`internal/utils/misc.go:116`), `"supabase start + * is not running."`, returned by `AssertSupabaseDbIsRunning` before the local + * reset (`internal/db/reset/reset.go:57`). + */ +export class LegacyDbResetNotRunningError extends Data.TaggedError("LegacyDbResetNotRunningError")<{ + readonly message: string; +}> {} + +/** + * `--last` was given a negative value. Go declares `--last` as an unsigned flag + * (`UintVar`, `cmd/db.go`), so cobra rejects a negative at parse time. Byte-matches + * cobra's parse error for `strconv.ParseUint`. + */ +export class LegacyDbResetLastFlagError extends Data.TaggedError("LegacyDbResetLastFlagError")<{ + readonly message: string; +}> {} + +/** + * Invalid `--sql-paths` usage. Byte-matches Go's `validateDbResetSeedFlags` + * (`cmd/db.go`): `"--no-seed cannot be used with --sql-paths"` and + * `"--sql-paths requires a non-empty path or glob pattern"`. + */ +export class LegacyDbResetSeedFlagsError extends Data.TaggedError("LegacyDbResetSeedFlagsError")<{ + readonly message: string; +}> {} 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 de35923ac2..9e83d62bd1 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts @@ -1,16 +1,373 @@ -import { Effect, Option } from "effect"; +import { + loadProjectConfig, + type LoadProjectConfigOptions, + ProjectConfigSchema, +} from "@supabase/config"; +import { Effect, FileSystem, Option, Path, Schema } from "effect"; + +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { detectGitBranch } from "../../../../shared/git/git-branch.ts"; +import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; +import { + legacyResolveExperimental, + legacyResolveYes, +} from "../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyAqua, legacyYellow } from "../../../shared/legacy-colors.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { legacyApplyMigrations } from "../../../shared/legacy-migration-apply.ts"; +import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; +import { resolveLegacyDbTargetFlags } from "../../../shared/legacy-db-target-flags.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyDropUserSchemas } from "../shared/legacy-drop-schemas.ts"; +import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.ts"; +import { + legacyMigrationsEnabled, + legacySeedEnabled, +} from "../shared/legacy-config-env-override.ts"; +import { legacyListLocalMigrations } from "../shared/legacy-pgdelta.cache.ts"; +import { + legacyGetPendingSeeds, + legacyMatchPattern, + legacySeedData, +} from "../shared/legacy-seed-ops.ts"; +import { legacyReadVaultDocument, legacyUpsertVaultSecrets } from "../shared/legacy-vault.ts"; +import { legacySeedBucketsRun } from "../../../shared/legacy-seed-buckets.ts"; import type { LegacyDbResetFlags } from "./reset.command.ts"; +import { + LegacyDbResetApplyError, + LegacyDbResetCancelledError, + LegacyDbResetConfigLoadError, + LegacyDbResetInvalidVersionError, + LegacyDbResetLastFlagError, + LegacyDbResetMigrationFileError, + LegacyDbResetNotRunningError, + LegacyDbResetSeedFlagsError, + LegacyDbResetTargetFlagsError, + LegacyDbResetVersionFlagsError, +} from "./reset.errors.ts"; -export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: LegacyDbResetFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["db", "reset"]; +const decodeDefaultConfig = Schema.decodeUnknownSync(ProjectConfigSchema); + +const INTEGER_PATTERN = /^[+-]?\d+$/u; +const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/u; + +const applyError = (message: string) => new LegacyDbResetApplyError({ message }); + +/** Go's `toLogMessage` (`internal/db/reset/reset.go:88-91`). */ +const toLogMessage = (version: string): string => + version.length > 0 ? ` to version: ${version}` : "..."; + +/** + * Rebuilds the `db reset` argv for the remaining Go-delegated path: a remote + * `--experimental` reset with no resolved version. Only the flags reachable on + * that path are forwarded — `--local` always takes the native path, and a set + * `--version`/`--last` resolves a non-empty version which disables the experimental + * delegation (a degenerate `--last 0` resolves to "" and is behaviourally identical + * whether or not it is forwarded, so it is omitted). + */ +const buildResetArgs = (flags: LegacyDbResetFlags): Array => { + const args = ["db", "reset"]; if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); 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); + for (const p of flags.sqlPaths) args.push("--sql-paths", p); + return args; +}; + +/** + * `supabase db reset` — reinitialise a database from local migrations (+ seed). + * + * Strict 1:1 port of `apps/cli-go/internal/db/reset/reset.go`. The remote path + * (`--linked` / a remote `--db-url`) is native. The local path (and the niche + * `--experimental` schema-files path) delegate to the Go binary as a documented + * interim until the container-bootstrap seam is ported (CLI-1325 Stage 3). + */ +export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: LegacyDbResetFlags) { + const output = yield* Output; + const resolver = yield* LegacyDbConfigResolver; + const dbConn = yield* LegacyDbConnection; + const proxy = yield* LegacyGoProxy; + const seam = yield* LegacyDbBootstrapSeam; + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cliArgs = yield* CliArgs; + const dnsResolver = yield* LegacyDnsResolverFlag; + // Env-aware (honors `SUPABASE_EXPERIMENTAL`, not just `--experimental`), matching + // Go's `viper.GetBool("EXPERIMENTAL")`. + const experimental = yield* legacyResolveExperimental; + const yes = yield* legacyResolveYes; + + const workdir = cliConfig.workdir; + const migrationsDir = path.join(workdir, "supabase", "migrations"); + let linkedRefForCache: string | undefined; + + const body = Effect.gen(function* () { + const target = resolveLegacyDbTargetFlags(cliArgs.args); + // cobra MarkFlagsMutuallyExclusive("db-url", "linked", "local"). + if (target.setFlags.length > 1) { + return yield* Effect.fail( + new LegacyDbResetTargetFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, + }), + ); + } + // Go declares `--last` as `UintVar`, so cobra rejects a negative at parse time + // (`Flag.integer` here accepts it). Reject it the same way rather than silently + // treating it as "no --last" and resetting the full history. + if (Option.isSome(flags.last) && flags.last.value < 0) { + return yield* Effect.fail( + new LegacyDbResetLastFlagError({ + message: `invalid argument "${flags.last.value}" for "--last" flag: strconv.ParseUint: parsing "${flags.last.value}": invalid syntax`, + }), + ); + } + // cobra MarkFlagsMutuallyExclusive("version", "last") — alphabetical group. + if (Option.isSome(flags.version) && Option.isSome(flags.last)) { + return yield* Effect.fail( + new LegacyDbResetVersionFlagsError({ + message: + "if any flags in the group [last version] are set none of the others can be; [last version] were all set", + }), + ); + } + + // Go's validateDbResetSeedFlags (PreRunE): `--no-seed` conflicts with + // `--sql-paths`, and each `--sql-paths` value must be non-empty. + if (flags.noSeed && flags.sqlPaths.length > 0) { + return yield* Effect.fail( + new LegacyDbResetSeedFlagsError({ + message: "--no-seed cannot be used with --sql-paths", + }), + ); + } + if (flags.sqlPaths.some((p) => p.length === 0)) { + return yield* Effect.fail( + new LegacyDbResetSeedFlagsError({ + message: "--sql-paths requires a non-empty path or glob pattern", + }), + ); + } + // Go's warnRemoteResetSeedOverride (PreRunE): a remote target flag + --sql-paths. + if ( + flags.sqlPaths.length > 0 && + (target.setFlags.includes("linked") || target.setFlags.includes("db-url")) + ) { + yield* output.raw( + `${legacyYellow("WARNING:")} --sql-paths overrides [db.seed].sql_paths and seeds the remote database selected by --linked or --db-url.\n`, + "stderr", + ); + } + + // Version / last resolution (Go's reset.Run lines 34-52), filesystem only. + let resolvedVersion = ""; + if (Option.isSome(flags.version)) { + const v = flags.version.value; + if (!INTEGER_PATTERN.test(v)) { + return yield* Effect.fail( + new LegacyDbResetInvalidVersionError({ + message: `failed to parse ${v}: invalid version number`, + }), + ); + } + // Go validates the version with `repair.GetMigrationFile` (repair.go:90-100), + // which globs `supabase/migrations/_*.sql` DIRECTLY with no filtering — + // so a deprecated first migration (e.g. `20200101000000_init.sql`) that + // `legacyListLocalMigrations` excludes is still accepted. Mirror that with a raw + // directory read + Go-glob match instead of the filtered migration listing. + const entries = yield* fs + .readDirectory(migrationsDir) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + const found = entries.some((name) => legacyMatchPattern(`${v}_*.sql`, path.basename(name))); + if (!found) { + return yield* Effect.fail( + new LegacyDbResetMigrationFileError({ + message: `glob supabase/migrations/${v}_*.sql: file does not exist`, + }), + ); + } + resolvedVersion = v; + } else if (Option.isSome(flags.last) && flags.last.value > 0) { + const locals = yield* legacyListLocalMigrations(fs, path, migrationsDir); + const versions = locals.flatMap((p) => { + const m = MIGRATE_FILE_PATTERN.exec(path.basename(p)); + return m?.[1] !== undefined ? [m[1]] : []; + }); + const total = versions.length; + const last = flags.last.value; + resolvedVersion = last < total ? versions[total - last - 1]! : "-"; + } + + const connType = target.connType ?? "local"; + const cfg = yield* resolver.resolve({ dbUrl: flags.dbUrl, connType, dnsResolver }); + + // Local target → native local reset. The container-recreate primitives live + // behind the hidden Go `db __db-bootstrap` seam; TS orchestrates the rest + // (running check, messages, bucket seeding, git-branch line, output shaping). + // Mirrors `internal/db/reset/reset.go:57-77`. + if (cfg.isLocal) { + // AssertSupabaseDbIsRunning — error if the local db container is down. + const running = yield* seam.isDbRunning(); + if (!running) { + return yield* Effect.fail( + new LegacyDbResetNotRunningError({ + message: `${legacyAqua("supabase start")} is not running.`, + }), + ); + } + // resetDatabase: "Resetting local database…" then recreate + migrate + seed. + yield* output.raw(`Resetting local database${toLogMessage(resolvedVersion)}\n`, "stderr"); + yield* seam.recreateDatabase({ + version: resolvedVersion, + noSeed: flags.noSeed, + sqlPaths: flags.sqlPaths, + }); + + // Seed objects from supabase/buckets when storage is up (Go gates buckets on + // an existing, healthy storage container). Reuses the ported seed-buckets + // local path; its summary is suppressed (reset emits its own result). + const storageReady = yield* seam.awaitStorageReady(); + if (storageReady) { + // Go's `buckets.Run(ctx, "", false, fsys)` — non-interactive: overwrite/prune + // confirmations take their defaults instead of blocking on input. + yield* legacySeedBucketsRun({ projectRef: "", emitSummary: false, interactive: false }); + } + + // "Finished supabase db reset on branch ." (both Aqua). + const branch = Option.getOrElse(yield* detectGitBranch(workdir), () => "main"); + yield* output.raw( + `Finished ${legacyAqua("supabase db reset")} on branch ${legacyAqua(branch)}.\n`, + "stderr", + ); + if (output.format !== "text") { + yield* output.success("Reset local database.", { + target: "local", + version: resolvedVersion, + }); + } + return; + } + + // Resolve the linked ref before any return so the post-run cache (Go's + // `PersistentPostRun` `ensureProjectGroupsCached`) is written even on the + // delegated `--experimental` path below — the Go child runs with telemetry + // disabled and skips that cache, so the TS finalizer must own it. + const linkedRef = Option.getOrUndefined(cfg.ref ?? Option.none()); + if (connType === "linked" && linkedRef !== undefined) linkedRefForCache = linkedRef; + + // Remote path. The niche `--experimental` schema-files apply path + // (`apply.MigrateAndSeed`) is not ported; delegate it to the Go child. In text + // mode inherit its stdio. Under a machine-output mode (`--output-format + // json|stream-json`) the Go child emits no TS envelope, so suppress its stdout + // (capture + discard) and emit the same structured success the native local and + // remote paths do, keeping the JSON contract consistent across all reset paths. + if (experimental && resolvedVersion === "") { + const env = { SUPABASE_TELEMETRY_DISABLED: "1" }; + if (output.format === "text") { + yield* proxy.exec(buildResetArgs(flags), { env }); + } else { + // Machine-output mode is non-interactive: give the Go child a non-TTY stdin + // (`stdin: "ignore"`) so it can't block on (or be answered at) Go's + // destructive reset prompt — it takes the default `false`, matching the + // native reset path which suppresses prompts under json/stream-json. + yield* proxy.execCapture(buildResetArgs(flags), { env, stdin: "ignore" }); + yield* output.success("Reset remote database.", { + target: "remote", + version: resolvedVersion, + }); + } + return; + } + + const loadOptions: LoadProjectConfigOptions | undefined = + connType === "linked" && linkedRef !== undefined ? { projectRef: linkedRef } : undefined; + const loaded = yield* loadProjectConfig(workdir, loadOptions).pipe( + Effect.catchTag( + "ProjectConfigParseError", + (cause) => + new LegacyDbResetConfigLoadError({ + message: `failed to parse supabase/config.toml: ${String(cause.cause)}`, + }), + ), + ); + const config = loaded === null ? decodeDefaultConfig({}) : loaded.config; + const document = loaded === null ? undefined : loaded.document; + if (loaded !== null && loaded.appliedRemote !== undefined) { + yield* output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr"); + } + + // Go's resetRemote: prompt (default false) → cancel, then ResetAll. + const shouldReset = yield* legacyPromptYesNo( + output, + yes, + "Do you want to reset the remote database?", + false, + ); + if (!shouldReset) { + return yield* Effect.fail(new LegacyDbResetCancelledError({ message: "context canceled" })); + } + yield* output.raw(`Resetting remote database${toLogMessage(resolvedVersion)}\n`, "stderr"); + + // Go connects with io.Discard, so NO "Connecting to ... database..." line. + yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* dbConn.connect(cfg.conn, { isLocal: false, dnsResolver }); + // ResetAll: drop user schemas → upsert vault → migrate + seed. + yield* legacyDropUserSchemas(session, applyError); + yield* legacyUpsertVaultSecrets(session, legacyReadVaultDocument(document), applyError); + + if (legacyMigrationsEnabled(config.db.migrations.enabled)) { + const locals = yield* legacyListLocalMigrations(fs, path, migrationsDir); + // LoadPartialMigrations filter: version === "" || v <= version. + const pending = locals.filter((p) => { + if (resolvedVersion === "") return true; + const m = MIGRATE_FILE_PATTERN.exec(path.basename(p)); + return m?.[1] !== undefined && m[1] <= resolvedVersion; + }); + yield* legacyApplyMigrations(session, fs, path, pending, applyError); + } + + // `--no-seed` disables seeding; `--sql-paths` overrides [db.seed].sql_paths + // and force-enables it (Go's applyDbResetSeedFlags). The two are mutually + // exclusive (validated above). `--sql-paths` values are supabase-relative, + // the same convention `legacyGetPendingSeeds` resolves. + const overrideSeed = flags.sqlPaths.length > 0; + // `--sql-paths` force-enables seeding (Go's applyDbResetSeedFlags); otherwise + // honor `db.seed.enabled` WITH Go's `SUPABASE_DB_SEED_ENABLED` viper override. + const seedEnabled = + overrideSeed || (legacySeedEnabled(config.db.seed.enabled) && !flags.noSeed); + if (seedEnabled) { + const seedPaths = overrideSeed ? flags.sqlPaths : config.db.seed.sql_paths; + const seeds = yield* legacyGetPendingSeeds(session, fs, path, seedPaths, workdir); + yield* legacySeedData(session, fs, workdir, path, seeds, applyError); + } + // Go's best-effort pgcache catalog warning is not ported (no output impact). + }), + ); + + if (output.format !== "text") { + yield* output.success("Reset remote database.", { + target: "remote", + version: resolvedVersion, + }); + } + }); + + yield* body.pipe( + Effect.ensuring( + Effect.suspend(() => + linkedRefForCache !== undefined && linkedRefForCache !== "" + ? linkedProjectCache.cache(linkedRefForCache) + : Effect.void, + ), + ), + Effect.ensuring(telemetryState.flush), + ); }); 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 index bd533d2adc..a0c8fd3808 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts @@ -1,26 +1,58 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; 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 { Effect, Exit, Layer, Option } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; +import { + LEGACY_VALID_REF, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApiService, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { + LegacyDnsResolverFlag, + LegacyExperimentalFlag, + LegacyYesFlag, +} from "../../../../shared/legacy/global-flags.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 type { OutputFormat } from "../../../../shared/output/types.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import type { + LegacyDbConfigFlags, + LegacyResolvedDbConfig, +} from "../../../shared/legacy-db-config.types.ts"; +import { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import { + LegacyDbConnection, + type LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.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 LIST_MIGRATIONS = + "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; +const SELECT_SEEDS = "SELECT path, hash FROM supabase_migrations.seed_files"; -const baseFlags: LegacyDbResetFlags = { +const CONN: LegacyPgConnInput = { + host: "db.example.supabase.co", + port: 5432, + user: "postgres", + password: "secret", + database: "postgres", +}; + +const DEFAULT_FLAGS: LegacyDbResetFlags = { dbUrl: Option.none(), linked: false, local: false, @@ -30,118 +62,764 @@ const baseFlags: LegacyDbResetFlags = { last: Option.none(), }; +function mockResolver(opts: { isLocal: boolean; ref?: string; omitRef?: boolean }) { + return Layer.succeed(LegacyDbConfigResolver, { + resolve: (_flags: LegacyDbConfigFlags) => + Effect.succeed( + (opts.omitRef === true + ? { conn: CONN, isLocal: opts.isLocal } + : { + conn: CONN, + isLocal: opts.isLocal, + ref: opts.ref !== undefined ? Option.some(opts.ref) : Option.none(), + }) satisfies LegacyResolvedDbConfig, + ), + resolvePoolerFallback: () => Effect.succeed(Option.none()), + }); +} + +function mockConnection(opts: { remoteSeeds?: Readonly> }) { + const execs: Array = []; + const queries: Array<{ sql: string; params?: ReadonlyArray }> = []; + const layer = Layer.succeed(LegacyDbConnection, { + connect: () => + Effect.succeed({ + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + exec: (sql: string): Effect.Effect => + Effect.sync(() => { + execs.push(sql); + }), + query: ( + sql: string, + params?: ReadonlyArray, + ): Effect.Effect>, LegacyDbExecError> => + Effect.suspend( + (): Effect.Effect>, LegacyDbExecError> => { + queries.push({ sql, params }); + if (sql === SELECT_SEEDS) { + return Effect.succeed( + Object.entries(opts.remoteSeeds ?? {}).map(([path, hash]) => ({ path, hash })), + ); + } + if (sql === LIST_MIGRATIONS) return Effect.succeed([]); + return Effect.succeed([]); + }, + ), + }), + }); + return { + layer, + get execs() { + return execs; + }, + get queries() { + return queries; + }, + }; +} + +/** + * Stateful mock of the container-bootstrap seam. `running` drives + * `AssertSupabaseDbIsRunning`; `storageReady` drives the bucket-seed gate. Records + * the recreate args so tests can assert version / `--no-seed` propagation. + */ +function mockBootstrapSeam(opts: { running?: boolean; storageReady?: boolean }) { + const recreateCalls: Array<{ + version: string; + noSeed: boolean; + sqlPaths: ReadonlyArray; + }> = []; + let storageChecked = false; + const layer = Layer.succeed(LegacyDbBootstrapSeam, { + isDbRunning: () => Effect.succeed(opts.running ?? true), + startDatabase: () => Effect.void, + recreateDatabase: (args: { + version: string; + noSeed: boolean; + sqlPaths: ReadonlyArray; + }) => + Effect.sync(() => { + recreateCalls.push(args); + }), + awaitStorageReady: () => + Effect.sync(() => { + storageChecked = true; + return opts.storageReady ?? false; + }), + }); + return { + layer, + get recreateCalls() { + return recreateCalls; + }, + get storageChecked() { + return storageChecked; + }, + }; +} + +// Dummy HTTP client; the local-reset bucket-seed core only reaches it when storage +// is ready AND buckets are configured (no reset test configures buckets, so the +// gateway is never actually called). Present to satisfy the handler's R. +const mockStorageHttp = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response("{}", { status: 404 }))), + ), +); + +function mockProxy() { + const calls: Array<{ args: ReadonlyArray; env?: Record }> = []; + const layer = Layer.succeed(LegacyGoProxy, { + exec: (args, opts) => + Effect.sync(() => { + calls.push({ args, env: opts?.env }); + }), + execCapture: () => Effect.succeed(""), + }); + return { + layer, + get calls() { + return calls; + }, + }; +} + +function setup( + workdir: string, + opts: { + toml?: string; + files?: Readonly>; + format?: OutputFormat; + confirm?: ReadonlyArray; + args?: ReadonlyArray; + isLocal?: boolean; + ref?: string; + experimental?: boolean; + remoteSeeds?: Readonly>; + yes?: boolean; + omitRef?: boolean; + running?: boolean; + storageReady?: boolean; + }, +) { + if (opts.toml !== undefined) { + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), opts.toml); + } + for (const [rel, content] of Object.entries(opts.files ?? {})) { + const abs = join(workdir, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); + } + + const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm }); + const conn = mockConnection(opts); + const proxy = mockProxy(); + const seam = mockBootstrapSeam({ running: opts.running, storageReady: opts.storageReady }); + const telemetry = mockLegacyTelemetryStateTracked(); + const linkedCache = mockLegacyLinkedProjectCacheTracked(); + // The local-reset bucket-seed core statically requires the (lazy) Management-API + // factory; never invoked on `--local` (projectRef === ""). + const platformApi = mockLegacyPlatformApiService({}); + + const layer = Layer.mergeAll( + out.layer, + conn.layer, + proxy.layer, + seam.layer, + mockResolver({ + isLocal: opts.isLocal ?? false, + ref: opts.ref ?? LEGACY_VALID_REF, + omitRef: opts.omitRef, + }), + mockLegacyCliConfig({ workdir }), + BunServices.layer, + mockRuntimeInfo(), + mockStorageHttp, + Layer.succeed(LegacyPlatformApiFactory, { + make: LegacyPlatformApi.pipe(Effect.provide(platformApi.layer)), + }), + Layer.succeed(CliArgs, { args: opts.args ?? ["db", "reset", "--linked"] }), + Layer.succeed(LegacyYesFlag, opts.yes ?? false), + Layer.succeed(LegacyDnsResolverFlag, "native"), + Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? false), + telemetry.layer, + linkedCache.layer, + ); + return { layer, out, conn, proxy, seam, telemetry, linkedCache }; +} + +const migrationFile = (version: string, body = "create table t ();") => ({ + [`supabase/migrations/${version}_test.sql`]: body, +}); + describe("legacy db reset", () => { - it.live("forwards the empty-array baseline without seed override flags", () => { - const { layer, calls } = setupLegacyDbReset(); + const tmp = useLegacyTempWorkdir("supabase-db-reset-"); + + it.live("resets the local database via the bootstrap seam", () => { + const { layer, out, seam, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + // Native path — no Go delegation. + expect(proxy.calls).toHaveLength(0); + expect(out.stderrText).toContain("Resetting local database..."); + expect(seam.recreateCalls).toEqual([{ version: "", noSeed: false, sqlPaths: [] }]); + // Storage gate checked; with no buckets configured nothing is seeded. + expect(seam.storageChecked).toBe(true); + expect(out.stderrText).toContain("Finished "); + expect(out.stderrText).toContain("on branch "); + }); + }); + + it.live("fails a local reset when the database is not running", () => { + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\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)) expect(JSON.stringify(exit.cause)).toContain("is not running."); + 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', + args: ["db", "reset"], + isLocal: true, + running: true, + storageReady: true, + }); + return Effect.gen(function* () { + // No buckets configured → the seed-buckets core short-circuits, but the + // storage gate is still consulted (Go inspects storage before buckets.Run). + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.storageChecked).toBe(true); + expect(seam.recreateCalls).toHaveLength(1); + }); + }); + + it.live("uses the detected git branch in the Finished line", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + }); + // `detectGitBranch` checks `$GITHUB_HEAD_REF` first (matching Go's + // `GetGitBranchOrDefault`). Set it explicitly so the test is deterministic in + // both a plain checkout and a GitHub Actions PR run (where it is preset to the + // PR branch); restore it afterwards. + const previous = process.env["GITHUB_HEAD_REF"]; + process.env["GITHUB_HEAD_REF"] = "feature-x"; + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + // The branch name is wrapped in ANSI (legacyAqua), so assert on the token. + expect(out.stderrText).toContain("on branch "); + expect(out.stderrText).toContain("feature-x"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["GITHUB_HEAD_REF"]; + else process.env["GITHUB_HEAD_REF"] = previous; + }), + ), + ); + }); + + it.live("fails a remote reset on a malformed config.toml", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "unterminated\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to parse supabase/config.toml"); + } + }); + }); + + it.live("emits a json result for a local reset", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + format: "json", + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["target"]).toBe("local"); + }); + }); + + it.live("rejects mutually exclusive target flags", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--linked", "--local"], + }); return Effect.gen(function* () { - yield* legacyDbReset(baseFlags); - expect(calls).toEqual([["db", "reset"]]); - }).pipe(Effect.provide(layer)); + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }); }); - it.live("forwards --no-seed alone", () => { - const { layer, calls } = setupLegacyDbReset(); + it.live("rejects --version together with --last", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); return Effect.gen(function* () { - yield* legacyDbReset({ ...baseFlags, noSeed: true }); - expect(calls).toEqual([["db", "reset", "--no-seed"]]); - }).pipe(Effect.provide(layer)); + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("20240101000000"), + last: Option.some(1), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("[last version]"); + }); }); - it.live("forwards a single --sql-paths flag", () => { - const { layer, calls } = setupLegacyDbReset(); + it.live("rejects a non-integer --version", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("not-a-number"), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(JSON.stringify(exit.cause)).toContain("invalid version number"); + }); + }); + + it.live("fails when --version has no matching migration file", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("20240101000000"), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "glob supabase/migrations/20240101000000_*.sql: file does not exist", + ); + } + }); + }); + + it.live("returns context canceled when the reset prompt is declined", () => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + confirm: [false], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("context canceled"); + expect(conn.execs).toHaveLength(0); + }); + }); + + it.live("drops schemas and applies migrations + seed on a confirmed remote reset", () => { + const { layer, out, conn, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + "supabase/seed.sql": "insert into t values (1);", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting remote database..."); + // No "Connecting to ... database..." line (Go uses io.Discard). + expect(out.stderrText).not.toContain("Connecting to"); + // Drop block ran, then the migration applied. + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); + expect(linkedCache.cached).toBe(true); + }); + }); + + it.live("resets to a specific version, applying only migrations up to it", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + ...migrationFile("20240202000000"), + }, + confirm: [true], + }); 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)); + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("20240101000000"), + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting remote database to version: 20240101000000"); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + expect(out.stderrText).not.toContain("Applying migration 20240202000000_test.sql..."); + expect(conn).toBeDefined(); + }); }); - it.live("forwards repeated --sql-paths flags in order", () => { - const { layer, calls } = setupLegacyDbReset(); + it.live("resolves --last to a version prefix", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + ...migrationFile("20240202000000"), + }, + confirm: [true], + }); + return Effect.gen(function* () { + // last=1 → revert the most recent → reset to version 20240101000000. + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true, last: Option.some(1) }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Resetting remote database to version: 20240101000000"); + }); + }); + + it.live("reverts all migrations when --last covers the full history", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, + confirm: [true], + }); + return Effect.gen(function* () { + // last=2 with 2 local migrations → revert all → version "-". + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true, last: Option.some(2) }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Resetting remote database to version: -"); + }); + }); + + it.live("skips seeding with --no-seed", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + "supabase/seed.sql": "insert into t values (1);", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true, noSeed: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).not.toContain("Seeding data from"); + }); + }); + + it.live("delegates an experimental remote reset to the Go binary", () => { + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(proxy.calls).toHaveLength(1); + expect(proxy.calls[0]!.args).toEqual(["db", "reset", "--linked"]); + expect(proxy.calls[0]!.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); + }); + }); + + it.live("forwards --db-url and --no-seed on an experimental remote db-url reset", () => { + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + args: ["db", "reset", "--db-url", "postgresql://db.example.com:5432/postgres"], + }); 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"], + ...DEFAULT_FLAGS, + dbUrl: Option.some("postgresql://db.example.com:5432/postgres"), + noSeed: true, + }).pipe(Effect.provide(layer)); + expect(proxy.calls[0]!.args).toEqual([ + "db", + "reset", + "--db-url", + "postgresql://db.example.com:5432/postgres", + "--no-seed", ]); - }).pipe(Effect.provide(layer)); + }); }); - it.live("forwards --no-seed with --sql-paths so Go owns the diagnostic", () => { - const { layer, calls } = setupLegacyDbReset(); + it.live("passes --no-seed and the resolved --last version to the recreate seam", () => { + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, + args: ["db", "reset", "--local"], + isLocal: true, + running: true, + }); return Effect.gen(function* () { + // last=1 with 2 local migrations → recreate up to version 20240101000000. yield* legacyDbReset({ - ...baseFlags, + ...DEFAULT_FLAGS, + local: true, noSeed: true, - sqlPaths: ["./seeds/base.sql"], - }); - expect(calls).toEqual([["db", "reset", "--no-seed", "--sql-paths", "./seeds/base.sql"]]); - }).pipe(Effect.provide(layer)); + last: Option.some(1), + }).pipe(Effect.provide(layer)); + expect(seam.recreateCalls).toEqual([ + { version: "20240101000000", noSeed: true, sqlPaths: [] }, + ]); + }); }); - it.live("forwards an empty --sql-paths value so Go owns the diagnostic", () => { - const { layer, calls } = setupLegacyDbReset(); + it.live("recreates to a specific --version on a local db-url reset", () => { + const { layer, out, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + args: ["db", "reset", "--db-url", "postgresql://localhost:54322/postgres"], + isLocal: true, + running: true, + }); return Effect.gen(function* () { yield* legacyDbReset({ - ...baseFlags, + ...DEFAULT_FLAGS, + dbUrl: Option.some("postgresql://localhost:54322/postgres"), + version: Option.some("20240101000000"), + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting local database to version: 20240101000000"); + expect(seam.recreateCalls).toEqual([ + { version: "20240101000000", noSeed: false, sqlPaths: [] }, + ]); + }); + }); + + it.live("resets a remote --db-url target without loading a remote config override", () => { + const { layer, out, conn } = setup(tmp.current, { + // No config file → embedded defaults (migrations + seed enabled). + files: migrationFile("20240101000000"), + args: ["db", "reset", "--db-url", "postgresql://db.example.com:5432/postgres"], + isLocal: false, + omitRef: true, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + dbUrl: Option.some("postgresql://db.example.com:5432/postgres"), + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting remote database..."); + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + }); + }); + + it.live("announces a matching [remotes.*] override", () => { + const { layer, out } = setup(tmp.current, { + toml: `project_id = "base"\n\n[remotes.preview]\nproject_id = "${LEGACY_VALID_REF}"\n`, + confirm: [true], + ref: LEGACY_VALID_REF, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Loading config override: [remotes.preview]"); + }); + }); + + it.live("skips migrations and seed when both are disabled in config", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nenabled = false\n\n[db.seed]\nenabled = false\n', + files: { + ...migrationFile("20240101000000"), + "supabase/seed.sql": "insert into t values (1);", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + // Schemas are still dropped, but nothing is applied or seeded. + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + expect(out.stderrText).not.toContain("Applying migration"); + expect(out.stderrText).not.toContain("Seeding data from"); + }); + }); + + it.live("emits a json result for a confirmed remote reset (--yes)", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + format: "json", + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["target"]).toBe("remote"); + }); + }); + + it.live("emits a json result for a confirmed remote reset", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + format: "json", + }); + return Effect.gen(function* () { + // json mode is non-interactive → prompt takes the default (false) → cancel. + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + // default-false prompt in non-text mode declines → context canceled. + expect(Exit.isFailure(exit)).toBe(true); + expect(out).toBeDefined(); + }); + }); + + it.live("rejects --no-seed together with --sql-paths", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + noSeed: true, + sqlPaths: ["seed.sql"], + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("--no-seed cannot be used with --sql-paths"); + } + }); + }); + + it.live("rejects an empty --sql-paths value", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, 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, - ); + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "--sql-paths requires a non-empty path or glob pattern", + ); + } + }); }); - 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, - ); + it.live("rejects a negative --last value", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + last: Option.some(-1), + }).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("invalid argument"); + expect(cause).toContain("strconv.ParseUint"); + } + }); + }); + + it.live("seeds an absolute --sql-paths file on a remote reset", () => { + const absSeed = join(tmp.current, "external-seed.sql"); + writeFileSync(absSeed, "insert into t values (3);"); + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + sqlPaths: [absSeed], + }).pipe(Effect.provide(layer)); + // Absolute paths are preserved (not prefixed with supabase/) and seeded. + expect(out.stderrText).toContain(`Seeding data from ${absSeed}...`); + }); + }); + + it.live("warns and seeds from --sql-paths overriding config on a remote reset", () => { + const { layer, out } = setup(tmp.current, { + // Seed disabled in config — --sql-paths must force-enable it. + toml: 'project_id = "test"\n\n[db.seed]\nenabled = false\n', + files: { + ...migrationFile("20240101000000"), + "supabase/custom-seed.sql": "insert into t values (2);", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + sqlPaths: ["custom-seed.sql"], + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("--sql-paths overrides [db.seed].sql_paths"); + expect(out.stderrText).toContain("Seeding data from supabase/custom-seed.sql..."); + }); + }); + + it.live("forwards --sql-paths to the recreate seam on a local reset", () => { + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + running: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + local: true, + sqlPaths: ["custom-seed.sql", "demo/*.sql"], + }).pipe(Effect.provide(layer)); + expect(seam.recreateCalls).toEqual([ + { version: "", noSeed: false, sqlPaths: ["custom-seed.sql", "demo/*.sql"] }, + ]); + }); + }); + + it.live("forwards --sql-paths to the Go binary on an experimental remote reset", () => { + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + sqlPaths: ["custom-seed.sql"], + }).pipe(Effect.provide(layer)); + expect(proxy.calls[0]!.args).toEqual([ + "db", + "reset", + "--linked", + "--sql-paths", + "custom-seed.sql", + ]); + }); }); }); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.layers.ts b/apps/cli/src/legacy/commands/db/reset/reset.layers.ts new file mode 100644 index 0000000000..ef37ddbcc7 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/reset/reset.layers.ts @@ -0,0 +1,75 @@ +import { Layer } from "effect"; + +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { legacyCredentialsLayer } from "../../../auth/legacy-credentials.layer.ts"; +import { legacyHttpClientLayer } from "../../../auth/legacy-http-debug.layer.ts"; +import { legacyPlatformApiFactoryLayer } from "../../../auth/legacy-platform-api-factory.layer.ts"; +import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.ts"; +import { legacyDbConfigLayer } from "../../../shared/legacy-db-config.layer.ts"; +import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { legacyIdentityStitchLayer } from "../../../shared/legacy-identity-stitch.ts"; +import { legacyLinkedProjectCacheLayer } from "../../../telemetry/legacy-linked-project-cache.layer.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; +import { legacyDbBootstrapSeamLayer } from "../shared/legacy-db-bootstrap.seam.layer.ts"; + +/** + * Runtime layer for `supabase db reset`. Same composition as `db push` / `db lint`: + * the Postgres connection, the db-config resolver, project-ref resolution, and the + * linked-project cache, all over the lazy management-API factory so the local / + * `--db-url` paths never resolve an access token at layer-build time. `LegacyGoProxy` + * (used to delegate the local / experimental reset paths) is ambient from the root. + */ +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const credentials = legacyCredentialsLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), +); + +const platformApiFactory = legacyPlatformApiFactoryLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), +); + +const projectRef = legacyProjectRefLayer.pipe( + Layer.provide(platformApiFactory), + Layer.provide(cliConfig), +); + +const linkedProjectCache = legacyLinkedProjectCacheLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(httpClient), + Layer.provide(legacyIdentityStitchLayer), +); + +const dbConfig = legacyDbConfigLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), +); + +export const legacyDbResetRuntimeLayer = Layer.mergeAll( + dbConfig, + legacyDbConnectionLayer, + cliConfig, + httpClient, + credentials, + projectRef, + // Exposed (not just provided to `projectRef`) because the local reset path reuses + // the seed-buckets core, whose `legacyResolveStorageCredentials` requires the + // (lazy) Management-API factory for the linked branch — never hit on `--local`, + // but a static service requirement of the shared core. + platformApiFactory, + linkedProjectCache, + legacyIdentityStitchLayer, + legacyTelemetryStateLayer, + // Container-recreate / storage-health primitives for the native local reset. + legacyDbBootstrapSeamLayer.pipe(Layer.provide(cliConfig)), + commandRuntimeLayer(["db", "reset"]), +); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-config-env-override.ts b/apps/cli/src/legacy/commands/db/shared/legacy-config-env-override.ts new file mode 100644 index 0000000000..b68b560d3f --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-config-env-override.ts @@ -0,0 +1,37 @@ +/** + * Applies Go's viper `AutomaticEnv` override for a boolean config field. + * + * Go loads config through viper with `SetEnvPrefix("SUPABASE")` + `AutomaticEnv()` + * and an EnvKeyReplacer of `.`→`_` (`pkg/config/config.go:529-535`), so any config + * key is overridable by `SUPABASE_` (e.g. `db.migrations.enabled` → + * `SUPABASE_DB_MIGRATIONS_ENABLED`, `db.seed.enabled` → `SUPABASE_DB_SEED_ENABLED`). + * `@supabase/config` only interpolates explicit `env(...)` references in values, not + * this implicit AutomaticEnv override, so apply it here for the db push / db reset + * migration + seed gates to match Go. + * + * An UNSET or EMPTY env var leaves the config/default value in force: viper's + * `AutomaticEnv` is configured without `AllowEmptyEnv` (config.go:529-535), so it + * ignores an env var whose value is `""` and falls back to the loaded config. + * + * For a non-empty value, mirrors viper's `GetBool` → `cast.ToBool` + * (`strconv.ParseBool`): only `1/t/T/TRUE/true/True` are truthy; any other + * (unparseable) token is false (cast swallows the parse error). + */ +const VIPER_TRUE_VALUES = new Set(["1", "t", "T", "TRUE", "true", "True"]); + +function legacyConfigBoolEnvOverride(envKey: string, configValue: boolean): boolean { + const override = process.env[envKey]; + // Unset or empty → no override (Go's AutomaticEnv without AllowEmptyEnv). + if (override === undefined || override === "") return configValue; + return VIPER_TRUE_VALUES.has(override); +} + +/** `db.migrations.enabled`, honoring `SUPABASE_DB_MIGRATIONS_ENABLED`. */ +export function legacyMigrationsEnabled(configEnabled: boolean): boolean { + return legacyConfigBoolEnvOverride("SUPABASE_DB_MIGRATIONS_ENABLED", configEnabled); +} + +/** `db.seed.enabled`, honoring `SUPABASE_DB_SEED_ENABLED`. */ +export function legacySeedEnabled(configEnabled: boolean): boolean { + return legacyConfigBoolEnvOverride("SUPABASE_DB_SEED_ENABLED", configEnabled); +} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-config-env-override.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-config-env-override.unit.test.ts new file mode 100644 index 0000000000..35838b9384 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-config-env-override.unit.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { legacyMigrationsEnabled, legacySeedEnabled } from "./legacy-config-env-override.ts"; + +const ENV_KEYS = ["SUPABASE_DB_MIGRATIONS_ENABLED", "SUPABASE_DB_SEED_ENABLED"] as const; + +describe("legacy config bool env overrides", () => { + const saved = new Map(); + + beforeEach(() => { + for (const key of ENV_KEYS) { + saved.set(key, process.env[key]); + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + const prev = saved.get(key); + if (prev === undefined) delete process.env[key]; + else process.env[key] = prev; + } + }); + + it("uses the config value when the env override is unset", () => { + expect(legacyMigrationsEnabled(true)).toBe(true); + expect(legacyMigrationsEnabled(false)).toBe(false); + expect(legacySeedEnabled(true)).toBe(true); + expect(legacySeedEnabled(false)).toBe(false); + }); + + it("lets SUPABASE_DB_MIGRATIONS_ENABLED override the config value (Go viper AutomaticEnv)", () => { + for (const truthy of ["1", "t", "T", "TRUE", "true", "True"]) { + process.env["SUPABASE_DB_MIGRATIONS_ENABLED"] = truthy; + expect(legacyMigrationsEnabled(false)).toBe(true); + } + for (const falsy of ["0", "false", "False", "FALSE", "no", "garbage"]) { + process.env["SUPABASE_DB_MIGRATIONS_ENABLED"] = falsy; + expect(legacyMigrationsEnabled(true)).toBe(false); + } + }); + + it("treats an empty SUPABASE_* override as unset (Go AutomaticEnv has no AllowEmptyEnv)", () => { + process.env["SUPABASE_DB_MIGRATIONS_ENABLED"] = ""; + expect(legacyMigrationsEnabled(true)).toBe(true); + expect(legacyMigrationsEnabled(false)).toBe(false); + process.env["SUPABASE_DB_SEED_ENABLED"] = ""; + expect(legacySeedEnabled(true)).toBe(true); + expect(legacySeedEnabled(false)).toBe(false); + }); + + it("lets SUPABASE_DB_SEED_ENABLED override the config value (Go viper AutomaticEnv)", () => { + process.env["SUPABASE_DB_SEED_ENABLED"] = "false"; + expect(legacySeedEnabled(true)).toBe(false); + process.env["SUPABASE_DB_SEED_ENABLED"] = "true"; + expect(legacySeedEnabled(false)).toBe(true); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts new file mode 100644 index 0000000000..f0aa2942f1 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts @@ -0,0 +1,14 @@ +import { Data } from "effect"; + +/** + * Driving the bundled Go binary's hidden `db __db-bootstrap` seam failed — the + * container-lifecycle primitives that back native `db start` / `db reset --local` + * (create/recreate the local Postgres container, apply the initial schema, the + * storage health gate) are not yet ported to TypeScript. Wraps a failed inspect, + * a missing `supabase-go` binary, or a non-zero seam exit. The seam tees its own + * progress to stderr, so this message is the fallback shown when the subprocess + * dies without surfacing a more specific Go error. + */ +export class LegacyDbBootstrapError extends Data.TaggedError("LegacyDbBootstrapError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts new file mode 100644 index 0000000000..b567124d7e --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts @@ -0,0 +1,223 @@ +import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import { + LegacyNetworkIdFlag, + LegacyProfileFlag, + legacyResolveExperimental, +} from "../../../../shared/legacy/global-flags.ts"; +import { resolveBinary } from "../../../../shared/legacy/go-proxy.layer.ts"; +import { ProcessControl } from "../../../../shared/runtime/process-control.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { spawnContainerCli } from "../../../shared/legacy-container-cli.ts"; +import { legacyReadDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; +import { + legacyResolveLocalProjectId, + localDbContainerId, +} from "../../../shared/legacy-docker-ids.ts"; +import { LegacyDbBootstrapError } from "./legacy-db-bootstrap.errors.ts"; +import { LegacyDbBootstrapSeam } from "./legacy-db-bootstrap.seam.service.ts"; + +const seamFailure = (message: string) => new LegacyDbBootstrapError({ message }); + +const decodeChunks = (chunks: ReadonlyArray): string => { + const total = chunks.reduce((size, chunk) => size + chunk.length, 0); + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return new TextDecoder().decode(bytes); +}; + +/** + * Real {@link LegacyDbBootstrapSeam}: drives the bundled `supabase-go`'s hidden + * `db __db-bootstrap --mode ` command. The binary is resolved exactly like + * `LegacyGoProxy` (`resolveBinary`); the child's telemetry is disabled and its + * progress teed to stderr, matching the `db __shadow` seam. `--network-id` and a + * flag-selected `--profile` are forwarded so the spawned containers land on the + * same network and the child re-runs Go's identical config resolution. + */ +export const legacyDbBootstrapSeamLayer = Layer.effect( + LegacyDbBootstrapSeam, + Effect.gen(function* () { + const cliConfig = yield* LegacyCliConfig; + const networkId = yield* LegacyNetworkIdFlag; + const profile = yield* LegacyProfileFlag; + const profileArgs = profile !== "supabase" ? ["--profile", profile] : []; + const networkArgs = Option.isSome(networkId) ? ["--network-id", networkId.value] : []; + // Forward `--experimental` (env-aware) so the seam's `SetupLocalDatabase` / + // `apply.MigrateAndSeed` takes Go's experimental schema-file path on a + // versionless reset/start, matching `viper.GetBool("EXPERIMENTAL")`. + const experimental = yield* legacyResolveExperimental; + const experimentalArgs = experimental ? ["--experimental"] : []; + const spawner = yield* ChildProcessSpawner; + const processControl = yield* ProcessControl; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolved = resolveBinary(); + + /** + * Run `db __db-bootstrap` with the given mode args. `captureStdout` pipes + * stdout (for the `await-storage` marker); otherwise stdout is inherited. + * Returns the captured stdout (empty when inherited). + */ + const runBootstrap = (modeArgs: ReadonlyArray, captureStdout: boolean) => + Effect.scoped( + Effect.gen(function* () { + if (!("found" in resolved)) { + return yield* Effect.fail( + seamFailure( + "Could not find the supabase-go binary required to bootstrap the local database.", + ), + ); + } + // `runCli` treats `db start`/`db reset` as self-managed and installs no + // global signal handler, and this direct child spawn (unlike + // `LegacyGoProxy.exec`) inherits the foreground process group. Hold + // SIGINT/SIGTERM/SIGHUP with no-op listeners so an interactive Ctrl-C + // during container startup/restore does not default-terminate the TS + // parent out from under the Go child's docker-cleanup path — the parent + // stays blocked on the child's exit and propagates its real status. + // Scoped, so the listeners are removed on completion/failure/interrupt. + yield* processControl.holdSignals(["SIGINT", "SIGTERM", "SIGHUP"]); + const args = [ + "db", + "__db-bootstrap", + ...modeArgs, + ...networkArgs, + ...profileArgs, + ...experimentalArgs, + ]; + const command = ChildProcess.make(resolved.found, args, { + cwd: cliConfig.workdir, + stdin: "inherit", + stdout: captureStdout ? "pipe" : "inherit", + stderr: "inherit", + extendEnv: true, + // Disable the child's telemetry so the hidden seam never records its + // own `cli_command_executed` on top of the user's TS command, matching + // the `db __shadow` seam and the explicit LegacyGoProxy delegates. + env: { SUPABASE_TELEMETRY_DISABLED: "1" }, + detached: false, + }); + if (!captureStdout) { + const exitCode = yield* spawner + .exitCode(command) + .pipe(Effect.mapError(() => seamFailure("failed to run supabase-go."))); + if (exitCode !== 0) { + // Fail (rather than `processControl.exit`) so the handler's finalizers — + // `Effect.ensuring(telemetryState.flush)` + the legacy command + // instrumentation — still run; an immediate `process.exit` here would + // skip them. Go likewise exits non-zero on a bootstrap error only after + // its `PersistentPostRun`. The child's detailed failure is already on the + // inherited stderr. (Preserving the child's *exact* exit code while still + // running finalizers would require a shared `runCli` change — deferred.) + return yield* Effect.fail( + seamFailure(`failed to bootstrap the local database: exit ${exitCode}`), + ); + } + return ""; + } + const handle = yield* spawner + .spawn(command) + .pipe(Effect.mapError(() => seamFailure("failed to run supabase-go."))); + const chunks: Array = []; + yield* Stream.runForEach(handle.stdout, (chunk) => + Effect.sync(() => { + chunks.push(chunk); + }), + ).pipe(Effect.mapError(() => seamFailure("failed to bootstrap the local database."))); + const exitCode = yield* handle.exitCode.pipe( + Effect.mapError(() => seamFailure("failed to bootstrap the local database.")), + ); + if (exitCode !== 0) { + return yield* Effect.fail( + seamFailure(`failed to bootstrap the local database: exit ${exitCode}`), + ); + } + return decodeChunks(chunks); + }), + ); + + return LegacyDbBootstrapSeam.of({ + isDbRunning: () => + Effect.scoped( + Effect.gen(function* () { + // Resolve `utils.DbId` exactly as Go does (env → config.toml → workdir + // basename); the config.toml read is best-effort since the handler has + // already validated config. + const tomlProjectId = yield* legacyReadDbToml(fs, path, cliConfig.workdir).pipe( + Effect.map((toml) => toml.projectId), + Effect.orElseSucceed(() => Option.none()), + ); + const projectId = legacyResolveLocalProjectId( + Option.getOrUndefined(cliConfig.projectId), + Option.getOrUndefined(tomlProjectId), + cliConfig.workdir, + ); + const containerId = localDbContainerId(projectId); + // Go's AssertSupabaseDbIsRunning = ContainerInspect → NotFound ⇒ not + // running. Discard stdout (the inspect JSON) so the unconsumed pipe can + // never deadlock; only the exit code + stderr matter. + const child = yield* spawnContainerCli(spawner, ["container", "inspect", containerId], { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + extendEnv: true, + }).pipe(Effect.mapError(() => seamFailure("failed to inspect service"))); + const stderrChunks: Array = []; + yield* Stream.runForEach(child.stderr, (chunk) => + Effect.sync(() => { + stderrChunks.push(chunk); + }), + ).pipe(Effect.mapError(() => seamFailure("failed to inspect service"))); + const inspectExit = yield* child.exitCode.pipe( + Effect.map(Number), + Effect.mapError(() => seamFailure("failed to inspect service")), + ); + if (inspectExit === 0) return true; // container exists ⇒ running + + const stderr = decodeChunks(stderrChunks).trim(); + // Only a missing container means "not running". Docker reports this as + // either "No such container" or "No such object" depending on daemon + // version/CLI path (the same pair handled in `shared/functions/serve.ts`). + // Any other inspect failure (e.g. the Docker daemon is down) propagates, + // matching Go's `AssertSupabaseDbIsRunning`. + if (!stderr.includes("No such container") && !stderr.includes("No such object")) { + return yield* Effect.fail( + seamFailure( + stderr.length > 0 + ? `failed to inspect service: ${stderr}` + : "failed to inspect service", + ), + ); + } + return false; + }), + ), + startDatabase: ({ fromBackup }) => + runBootstrap( + ["--mode", "start", ...(fromBackup !== undefined ? ["--from-backup", fromBackup] : [])], + false, + ).pipe(Effect.asVoid), + recreateDatabase: ({ version, noSeed, sqlPaths }) => + runBootstrap( + [ + "--mode", + "recreate", + ...(version !== "" ? ["--version", version] : []), + ...(noSeed ? ["--no-seed"] : []), + ...sqlPaths.flatMap((p) => ["--sql-paths", p]), + ], + false, + ).pipe(Effect.asVoid), + awaitStorageReady: () => + runBootstrap(["--mode", "await-storage"], true).pipe( + Effect.map((stdout) => stdout.trim() === "ready"), + ), + }); + }), +); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts new file mode 100644 index 0000000000..4c29ddb67a --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts @@ -0,0 +1,68 @@ +import { Context, type Effect } from "effect"; + +import type { LegacyDbBootstrapError } from "./legacy-db-bootstrap.errors.ts"; + +/** + * Seam over the bundled Go binary's hidden `db __db-bootstrap` command, exposing + * the container-bootstrap primitives that native `db start` / `db reset --local` + * still need but that are not ported to TypeScript: the local-stack "is running?" + * probe, the database container create/recreate flows, and the storage health gate + * before bucket seeding. The TS handlers orchestrate everything else (user-facing + * messages, version resolution, bucket seeding, the git-branch line, telemetry, + * and `--output-format` shaping); only the Docker lifecycle lives behind here. + * + * Mirrors {@link LegacyDeclarativeSeam} (`db __shadow`): each method shells out to + * the same resolved `supabase-go`, with the child's telemetry disabled so the + * hidden seam never double-counts the user's command, and its progress teed to + * stderr. + */ +interface LegacyDbBootstrapSeamShape { + /** + * Go's `utils.AssertSupabaseDbIsRunning` (`internal/utils/misc.go:144`): inspect + * the local Postgres container. `true` when it exists (the stack is up), `false` + * when Docker reports "No such container" (Go's `ErrNotRunning`). Any other + * inspect failure (e.g. the Docker daemon is unreachable) fails with + * {@link LegacyDbBootstrapError}, matching Go, which returns the wrapped inspect + * error rather than treating the database as stopped. + */ + readonly isDbRunning: () => Effect.Effect; + /** + * `db start`'s container bootstrap — `start.StartDatabase(fromBackup)` plus Go's + * `DockerRemoveAll` cleanup on failure (`internal/db/start/start.go:54-60`): + * create the Postgres container, wait for health, apply the initial schema + + * roles + migrations + seed on a fresh volume, and write `_current_branch`. + * Progress (`Starting database...`, `Initialising schema...`) is teed to stderr. + */ + readonly startDatabase: (opts: { + readonly fromBackup?: string; + }) => Effect.Effect; + /** + * The PG14/PG15 container-recreate half of local `db reset` + * (`reset.RecreateLocalDatabase`): recreate the db container/volume, init schema, + * migrate + seed up to `version`, and restart the satellite containers. The + * caller has already printed `Resetting local database…`; the seam tees the + * remaining progress (`Recreating database...`, `Restarting containers...`) to + * stderr. `version` is the resolved migration version ("" for all migrations); + * `noSeed` disables the seed and `sqlPaths` overrides `[db.seed].sql_paths` + * inside the recreate's MigrateAndSeed, mirroring the `db reset` + * `--no-seed` / `--sql-paths` handling (`cmd/db.go` `dbResetCmd`). + */ + readonly recreateDatabase: (opts: { + readonly version: string; + readonly noSeed: boolean; + readonly sqlPaths: ReadonlyArray; + }) => Effect.Effect; + /** + * The storage health gate local `db reset` runs before seeding buckets + * (`reset.AwaitStorageReady`): if the storage container exists but is unhealthy, + * wait up to 30s for it. Resolves `true` when the storage container exists (so + * the caller should run the ported bucket seeding) and `false` when it does not + * — matching Go, which silently skips buckets when storage is absent. + */ + readonly awaitStorageReady: () => Effect.Effect; +} + +export class LegacyDbBootstrapSeam extends Context.Service< + LegacyDbBootstrapSeam, + LegacyDbBootstrapSeamShape +>()("supabase/legacy/DbBootstrapSeam") {} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-drop-schemas.ts b/apps/cli/src/legacy/commands/db/shared/legacy-drop-schemas.ts new file mode 100644 index 0000000000..88d394498c --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-drop-schemas.ts @@ -0,0 +1,169 @@ +import { Effect } from "effect"; + +import type { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; + +/** + * Verbatim port of Go's embedded `pkg/migration/queries/drop.sql` + * (`DropUserSchemas`). A single PL/pgSQL `DO` block that drops user schemas, + * extensions, public-schema objects, and non-managed publications, then + * truncates the auth / supabase_functions / supabase_migrations tables. Run as a + * single simple-query statement, matching Go's one-statement `ExecBatch`. + */ +const DROP_OBJECTS = `do $$ declare + rec record; +begin + -- schemas + for rec in + select pn.* + from pg_namespace pn + left join pg_depend pd on pd.objid = pn.oid + where pd.deptype is null + and not pn.nspname like any(array['information\\_schema', 'pg\\_%', '\\_analytics', '\\_realtime', '\\_supavisor', 'pgbouncer', 'pgmq', 'pgsodium', 'pgtle', 'supabase\\_migrations', 'vault', 'extensions', 'public']) + and pn.nspowner::regrole::text != 'supabase_admin' + loop + -- If an extension uses a schema it doesn't create, dropping the schema will cascade to also + -- drop the extension. But if an extension creates its own schema, dropping the schema will + -- throw an error. Hence, we drop schemas first while excluding those created by extensions. + raise notice 'dropping schema: %', rec.nspname; + execute format('drop schema if exists %I cascade', rec.nspname); + end loop; + + -- extensions + for rec in + select * + from pg_extension p + where p.extname not in ('pg_graphql', 'pg_net', 'pg_stat_statements', 'pgcrypto', 'pgjwt', 'pgsodium', 'plpgsql', 'supabase_vault', 'uuid-ossp') + loop + raise notice 'dropping extension: %', rec.extname; + execute format('drop extension if exists %I cascade', rec.extname); + end loop; + + -- functions + for rec in + select * + from pg_proc p + where p.pronamespace::regnamespace::name = 'public' + loop + -- supports aggregate, function, and procedure + raise notice 'dropping function: %.%', rec.pronamespace::regnamespace::name, rec.proname; + execute format('drop routine if exists %I.%I(%s) cascade', rec.pronamespace::regnamespace::name, rec.proname, pg_catalog.pg_get_function_identity_arguments(rec.oid)); + end loop; + + -- views (necessary for views referencing objects in Supabase-managed schemas) + for rec in + select * + from pg_class c + where + c.relnamespace::regnamespace::name = 'public' + and c.relkind = 'v' + loop + raise notice 'dropping view: %.%', rec.relnamespace::regnamespace::name, rec.relname; + execute format('drop view if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); + end loop; + + -- materialized views (necessary for materialized views referencing objects in Supabase-managed schemas) + for rec in + select * + from pg_class c + where + c.relnamespace::regnamespace::name = 'public' + and c.relkind = 'm' + loop + raise notice 'dropping materialized view: %.%', rec.relnamespace::regnamespace::name, rec.relname; + execute format('drop materialized view if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); + end loop; + + -- tables (cascade to dependent objects) + for rec in + select * + from pg_class c + where + c.relnamespace::regnamespace::name = 'public' + and c.relkind not in ('c', 'S', 'v', 'm') + order by c.relkind desc + loop + -- supports all table like relations, except views, complex types, and sequences + raise notice 'dropping table: %.%', rec.relnamespace::regnamespace::name, rec.relname; + execute format('drop table if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); + end loop; + + -- truncate tables in auth, webhooks, and migrations schema + for rec in + select * + from pg_class c + where + (c.relnamespace::regnamespace::name = 'auth' and c.relname != 'schema_migrations' + or c.relnamespace::regnamespace::name = 'supabase_functions' and c.relname != 'migrations' + or c.relnamespace::regnamespace::name = 'supabase_migrations') + and c.relkind = 'r' + loop + raise notice 'truncating table: %.%', rec.relnamespace::regnamespace::name, rec.relname; + execute format('truncate %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); + end loop; + + -- sequences + for rec in + select * + from pg_class c + where + c.relnamespace::regnamespace::name = 'public' + and c.relkind = 's' + loop + raise notice 'dropping sequence: %.%', rec.relnamespace::regnamespace::name, rec.relname; + execute format('drop sequence if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); + end loop; + + -- types + for rec in + select * + from pg_type t + where + t.typnamespace::regnamespace::name = 'public' + and typtype != 'b' + loop + raise notice 'dropping type: %.%', rec.typnamespace::regnamespace::name, rec.typname; + execute format('drop type if exists %I.%I cascade', rec.typnamespace::regnamespace::name, rec.typname); + end loop; + + -- policies + for rec in + select * + from pg_policies p + loop + raise notice 'dropping policy: %', rec.policyname; + execute format('drop policy if exists %I on %I.%I cascade', rec.policyname, rec.schemaname, rec.tablename); + end loop; + + -- publications + for rec in + select * + from pg_publication p + where + not p.pubname like any(array['supabase\\_realtime%', 'realtime\\_messages%']) + loop + raise notice 'dropping publication: %', rec.pubname; + execute format('drop publication if exists %I', rec.pubname); + end loop; +end $$;`; + +/** + * Drops all user-created database objects, mirroring Go's + * `migration.DropUserSchemas` (`pkg/migration/drop.go:34-38`): the `drop.sql` `DO` + * block runs as a single transactional statement (no migration-history row). + */ +export const legacyDropUserSchemas = ( + session: LegacyDbSession, + mapError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + // Go's `DropUserSchemas` runs only `drop.sql` via `ExecBatch` (drop.go:34-38) — + // no `RESET ALL`. Resetting here would clear caller-supplied DB URL runtime + // params (e.g. `options=-c statement_timeout=…`) before the destructive drop, so + // the remote `db reset --db-url` path must NOT reset (matches Go's ExecBatch). + yield* session.exec("BEGIN"); + yield* session + .exec(DROP_OBJECTS) + .pipe(Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore))); + yield* session.exec("COMMIT"); + }).pipe(Effect.mapError((error: LegacyDbExecError) => mapError(error.message))); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.ts new file mode 100644 index 0000000000..579a0b675a --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.ts @@ -0,0 +1,123 @@ +import { legacyBold } from "../../../shared/legacy-colors.ts"; + +/** + * `pkg/migration/file.go` — local migration filenames are `_.sql`. + * `ListLocalMigrations` guarantees every path in `localMigrations` matches, so the + * version capture group is always present. + */ +const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/u; + +/** Last path segment, mirroring Go's `filepath.Base`. */ +const baseName = (path: string): string => { + const normalized = path.replace(/[/\\]+$/u, ""); + const slash = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\")); + return slash === -1 ? normalized : normalized.slice(slash + 1); +}; + +/** + * `pkg/migration/apply.go:14-16` — the exact error strings Go raises so the legacy + * handler can byte-match them on stderr. + */ +export const LEGACY_ERR_MISSING_REMOTE = + "Found local migration files to be inserted before the last migration on remote database."; +export const LEGACY_ERR_MISSING_LOCAL = + "Remote migration versions not found in local migrations directory."; + +/** + * The outcome of comparing local migration files against the remote + * `schema_migrations` history. Pure 1:1 port of Go's `FindPendingMigrations` + * (`pkg/migration/apply.go:21-54`). + * + * - `ok` — `pending` are the local migration paths to apply (those + * beyond the remote history, in order). + * - `missing-local` — remote has versions with no local file (`ErrMissingLocal`). + * `versions` are the offending remote versions. + * - `missing-remote`— local has files ordered before the remote head + * (`ErrMissingRemote`). `paths` are the out-of-order local + * migration paths. + */ +export type LegacyPendingMigrations = + | { readonly kind: "ok"; readonly pending: ReadonlyArray } + | { readonly kind: "missing-local"; readonly versions: ReadonlyArray } + | { readonly kind: "missing-remote"; readonly paths: ReadonlyArray }; + +/** + * Two-pointer reconciliation of local migration paths vs remote applied versions. + * Mirrors Go's `FindPendingMigrations` exactly, including its **string** + * comparison of versions (`remote == local` / `remote < local`) — version + * prefixes are fixed-width timestamps, so lexical order equals chronological + * order, matching Go. + */ +export function legacyFindPendingMigrations( + localMigrations: ReadonlyArray, + remoteMigrations: ReadonlyArray, +): LegacyPendingMigrations { + const unapplied: Array = []; + const missing: Array = []; + let i = 0; + let j = 0; + while (i < remoteMigrations.length && j < localMigrations.length) { + const remote = remoteMigrations[i]!; + const filename = baseName(localMigrations[j]!); + // ListLocalMigrations guarantees a match, so the capture group is present. + const local = MIGRATE_FILE_PATTERN.exec(filename)![1]!; + if (remote === local) { + i++; + j++; + } else if (remote < local) { + missing.push(remote); + i++; + } else { + // Include out-of-order local migrations. + unapplied.push(localMigrations[j]!); + j++; + } + } + // Ensure all remote versions exist on local. + if (j === localMigrations.length) { + missing.push(...remoteMigrations.slice(i)); + } + if (missing.length > 0) { + return { kind: "missing-local", versions: missing }; + } + // Enforce migrations are applied in chronological order by default. + if (unapplied.length > 0) { + return { kind: "missing-remote", paths: unapplied }; + } + return { kind: "ok", pending: localMigrations.slice(remoteMigrations.length) }; +} + +/** + * Computes the `--include-all` pending set when reconciliation reports + * `missing-remote`. Mirrors Go's `GetPendingMigrations` includeAll branch + * (`internal/migration/up/up.go:46-48`): the out-of-order paths first, then the + * local migrations beyond `len(remote)+len(diff)`. + */ +export function legacyIncludeAllPending( + localMigrations: ReadonlyArray, + remoteCount: number, + diff: ReadonlyArray, +): ReadonlyArray { + return [...diff, ...localMigrations.slice(remoteCount + diff.length)]; +} + +/** + * Go's `suggestRevertHistory` (`internal/migration/up/up.go:55-61`). `fmt.Sprintln` + * appends a trailing newline to each line, so the suggestion ends with `\n`. + */ +export function legacySuggestRevertHistory(versions: ReadonlyArray): string { + return ( + "\nMake sure your local git repo is up-to-date. If the error persists, try repairing the migration history table:\n" + + `${legacyBold(`supabase migration repair --status reverted ${versions.join(" ")}`)}\n` + + "\nAnd update local migrations to match remote database:\n" + + `${legacyBold("supabase db pull")}\n` + ); +} + +/** Go's `suggestIgnoreFlag` (`internal/migration/up/up.go:63-67`). */ +export function legacySuggestIgnoreFlag(paths: ReadonlyArray): string { + return ( + "\nRerun the command with --include-all flag to apply these migrations:\n" + + `${legacyBold(paths.join("\n"))}\n` + ); +} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.unit.test.ts new file mode 100644 index 0000000000..eaf5400d60 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.unit.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { + legacyFindPendingMigrations, + legacyIncludeAllPending, + legacySuggestIgnoreFlag, + legacySuggestRevertHistory, +} from "./legacy-migration-pending.ts"; + +const local = (...versions: ReadonlyArray) => + versions.map((v) => `supabase/migrations/${v}_name.sql`); + +describe("legacyFindPendingMigrations", () => { + it("returns the local migrations beyond the remote history when in sync", () => { + const result = legacyFindPendingMigrations(local("0001", "0002", "0003"), ["0001"]); + expect(result).toEqual({ + kind: "ok", + pending: ["supabase/migrations/0002_name.sql", "supabase/migrations/0003_name.sql"], + }); + }); + + it("is up to date when local and remote match exactly", () => { + const result = legacyFindPendingMigrations(local("0001", "0002"), ["0001", "0002"]); + expect(result).toEqual({ kind: "ok", pending: [] }); + }); + + it("reports missing-local when remote has a version with no local file", () => { + const result = legacyFindPendingMigrations(local("0001", "0003"), ["0001", "0002", "0003"]); + expect(result).toEqual({ kind: "missing-local", versions: ["0002"] }); + }); + + it("reports missing-local for trailing remote versions absent locally", () => { + const result = legacyFindPendingMigrations(local("0001"), ["0001", "0002"]); + expect(result).toEqual({ kind: "missing-local", versions: ["0002"] }); + }); + + it("reports missing-remote for an out-of-order local migration", () => { + const result = legacyFindPendingMigrations(local("0001", "0002"), ["0002"]); + expect(result).toEqual({ + kind: "missing-remote", + paths: ["supabase/migrations/0001_name.sql"], + }); + }); + + it("treats an empty remote history as all-local pending", () => { + const result = legacyFindPendingMigrations(local("0001", "0002"), []); + expect(result).toEqual({ + kind: "ok", + pending: ["supabase/migrations/0001_name.sql", "supabase/migrations/0002_name.sql"], + }); + }); +}); + +describe("legacyIncludeAllPending", () => { + it("prepends the out-of-order diff then the migrations beyond remote+diff", () => { + const locals = local("0001", "0002", "0003"); + const diff = ["supabase/migrations/0001_name.sql"]; + // remoteCount 1, diff length 1 → slice from index 2. + expect(legacyIncludeAllPending(locals, 1, diff)).toEqual([ + "supabase/migrations/0001_name.sql", + "supabase/migrations/0003_name.sql", + ]); + }); +}); + +describe("suggestion strings", () => { + it("builds the revert-history suggestion with a trailing newline per line", () => { + expect(legacySuggestRevertHistory(["0002", "0003"])).toContain( + "supabase migration repair --status reverted 0002 0003", + ); + expect(legacySuggestRevertHistory(["0002"])).toMatch(/\n$/u); + expect(legacySuggestRevertHistory(["0002"])).toContain("supabase db pull"); + }); + + it("builds the include-all suggestion listing each path on its own line", () => { + const suggestion = legacySuggestIgnoreFlag([ + "supabase/migrations/0001_a.sql", + "supabase/migrations/0002_b.sql", + ]); + expect(suggestion).toContain("--include-all"); + expect(suggestion).toContain("supabase/migrations/0001_a.sql\nsupabase/migrations/0002_b.sql"); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts index 634c4c7b56..29469069f8 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts @@ -209,9 +209,11 @@ export const legacyDeclarativeSeamLayer = Layer.effect( })(), ) .trim(); - // Only a missing container means "not running" → start it. Any other - // inspect failure (e.g. Docker daemon down) propagates, matching Go. - if (!stderr.includes("No such container")) { + // Only a missing container means "not running" → start it. Docker reports + // this as either "No such container" or "No such object" (the same pair + // handled in `shared/functions/serve.ts`). Any other inspect failure (e.g. + // Docker daemon down) propagates, matching Go. + if (!stderr.includes("No such container") && !stderr.includes("No such object")) { return yield* Effect.fail( new LegacyDeclarativeShadowDbError({ message: diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts new file mode 100644 index 0000000000..b14455f631 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts @@ -0,0 +1,308 @@ +import { createHash } from "node:crypto"; +import { Effect, type FileSystem, Option, type Path } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import type { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; +import { legacyCreateSeedTable } from "../../../shared/legacy-migration-history.ts"; +import { legacySplitAndTrim } from "../../../shared/legacy-sql-split.ts"; + +/** + * Seed-history DML, verbatim from Go's `pkg/migration/history.go`. The schema/table + * DDL (with a transaction-scoped lock timeout) lives in `legacyCreateSeedTable`. + */ +const UPSERT_SEED_FILE = + "INSERT INTO supabase_migrations.seed_files(path, hash) VALUES($1, $2) ON CONFLICT (path) DO UPDATE SET hash = EXCLUDED.hash"; +const SELECT_SEED_TABLE = "SELECT path, hash FROM supabase_migrations.seed_files"; + +/** A local seed file resolved from `[db.seed].sql_paths`, with its content hash. */ +export interface LegacySeedFile { + /** Workdir-relative, forward-slashed path (Go's `filepath.ToSlash`). */ + readonly path: string; + /** Lowercase hex SHA-256 of the file content (Go's `NewSeedFile`). */ + readonly hash: string; + /** True when the remote `seed_files` row has a different hash (re-hash only). */ + readonly dirty: boolean; +} + +const META_CHARS = /[*?[\\]/u; + +/** + * Go's `path.Match` for a single filename (no `/`). Supports `*` (any run of + * non-separator chars), `?` (one char), `[...]` classes with ranges and a + * leading `^`/`!` negation, and `\` escapes. Filenames never contain `/`, so the + * separator subtlety in Go's matcher does not apply here. + */ +export function legacyMatchPattern(pattern: string, name: string): boolean { + const matchClass = (cls: string, ch: string): boolean => { + let negated = false; + let body = cls; + if (body.startsWith("^") || body.startsWith("!")) { + negated = true; + body = body.slice(1); + } + let matched = false; + for (let k = 0; k < body.length; k++) { + if (body[k + 1] === "-" && k + 2 < body.length) { + if (ch >= body[k]! && ch <= body[k + 2]!) matched = true; + k += 2; + } else if (body[k] === ch) { + matched = true; + } + } + return matched !== negated; + }; + + const match = (p: number, n: number): boolean => { + while (p < pattern.length) { + const pc = pattern[p]!; + if (pc === "*") { + // Collapse consecutive stars, then try to match the rest at every offset. + while (pattern[p] === "*") p++; + if (p === pattern.length) return true; + for (let k = n; k <= name.length; k++) { + if (match(p, k)) return true; + } + return false; + } + if (n >= name.length) return false; + if (pc === "?") { + p++; + n++; + continue; + } + if (pc === "[") { + const end = pattern.indexOf("]", p + 1); + if (end === -1) return false; + if (!matchClass(pattern.slice(p + 1, end), name[n]!)) return false; + p = end + 1; + n++; + continue; + } + if (pc === "\\" && p + 1 < pattern.length) { + if (pattern[p + 1] !== name[n]) return false; + p += 2; + n++; + continue; + } + if (pc !== name[n]) return false; + p++; + n++; + } + return n === name.length; + }; + + return match(0, 0); +} + +/** Result of resolving `[db.seed].sql_paths` against the workspace. */ +interface LegacyGlobResult { + /** Workdir-relative, forward-slashed matches, deduplicated in pattern order. */ + readonly files: ReadonlyArray; + /** Per-pattern warnings (`no files matched pattern: …`), joined by Go's `errors.Join`. */ + readonly warning: Option.Option; +} + +/** + * Resolves seed glob patterns to existing files, porting Go's `config.Glob.Files` + * over `fs.Glob` (`pkg/config/config.go:102-124`). Each pattern is first joined + * under the `supabase/` directory (Go resolves `sql_paths` at config load, + * `config.go:884`). Matches per pattern are sorted; the overall result preserves + * first-seen order across patterns. A pattern that matches nothing contributes a + * `no files matched pattern: ` warning but is not fatal. + */ +const legacyGlobSeedFiles = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + patterns: ReadonlyArray, + workdir: string, +) { + const seen = new Set(); + const files: Array = []; + const errors: Array = []; + + for (const rawPattern of patterns) { + // Go joins each *relative* pattern under SupabaseDirPath before globbing but + // preserves absolute paths as-is (config.go / resolveSeedSqlPaths). + const pattern = path.isAbsolute(rawPattern) + ? toSlash(rawPattern) + : toSlash(path.join("supabase", rawPattern)); + const matches = yield* globOne(fs, path, workdir, pattern); + if (matches.length === 0) { + errors.push(`no files matched pattern: ${pattern}`); + continue; + } + for (const match of [...matches].sort()) { + const fp = toSlash(match); + if (!seen.has(fp)) { + seen.add(fp); + files.push(fp); + } + } + } + + return { + files, + warning: errors.length > 0 ? Option.some(errors.join("\n")) : Option.none(), + } satisfies LegacyGlobResult; +}); + +const toSlash = (p: string): string => p.replaceAll("\\", "/"); + +/** Splits a forward-slashed path into its directory prefix and final element. */ +const splitPath = (p: string): { readonly dir: string; readonly file: string } => { + const slash = p.lastIndexOf("/"); + return slash === -1 ? { dir: "", file: p } : { dir: p.slice(0, slash), file: p.slice(slash + 1) }; +}; + +/** Faithful port of Go's `fs.Glob` for one pattern, rooted at `workdir`. */ +const globOne = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + pattern: string, +): Effect.Effect, never> => + Effect.gen(function* () { + // Absolute patterns resolve against the filesystem root (Go preserves absolute + // seed paths); relative ones are rooted at the workdir. + const resolve = (p: string): string => (path.isAbsolute(p) ? p : path.join(workdir, p)); + // No metacharacters: a direct existence check (Go's `fs.Glob` fast path). + if (!META_CHARS.test(pattern)) { + const exists = yield* fs.exists(resolve(pattern)).pipe(Effect.orElseSucceed(() => false)); + return exists ? [pattern] : []; + } + const { dir, file } = splitPath(pattern); + // Resolve the directory level first (recursively if it, too, is a glob). + const dirs = + dir === "" || !META_CHARS.test(dir) ? [dir] : yield* globOne(fs, path, workdir, dir); + const result: Array = []; + for (const d of dirs) { + const absDir = d === "" ? workdir : resolve(d); + const names = yield* fs + .readDirectory(absDir) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + for (const name of names) { + if (legacyMatchPattern(file, name)) { + result.push(d === "" ? name : `${d}/${name}`); + } + } + } + return result; + }); + +/** `SELECT path, hash FROM supabase_migrations.seed_files`, `42P01` → empty map. */ +const readRemoteSeeds = (session: LegacyDbSession) => + session.query(SELECT_SEED_TABLE).pipe( + Effect.map((rows) => { + const applied = new Map(); + for (const row of rows) applied.set(String(row["path"]), String(row["hash"])); + return applied; + }), + Effect.catch((error: LegacyDbExecError) => + isUndefinedTable(error) ? Effect.succeed(new Map()) : Effect.fail(error), + ), + ); + +const isUndefinedTable = (error: LegacyDbExecError): boolean => + error.code !== undefined + ? error.code === "42P01" + : /relation .* does not exist/iu.test(error.message) && + !/column .* does not exist/iu.test(error.message); + +/** + * Resolves the pending seed files for `db push --include-seed`. Mirrors Go's + * `GetPendingSeeds` (`pkg/migration/seed.go:34-63`): glob the configured paths + * (warn, don't fail, on empty patterns), read the remote `seed_files` hashes, + * and emit each local file that is new (`dirty=false`) or hash-changed + * (`dirty=true`); files whose hash already matches are skipped. + */ +export const legacyGetPendingSeeds = Effect.fnUntraced(function* ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + patterns: ReadonlyArray, + workdir: string, +) { + const output = yield* Output; + const { files, warning } = yield* legacyGlobSeedFiles(fs, path, patterns, workdir); + if (Option.isSome(warning)) { + yield* output.raw(`WARN: ${warning.value}\n`, "stderr"); + } + if (files.length === 0) return [] as ReadonlyArray; + + const applied = yield* readRemoteSeeds(session); + const pending: Array = []; + for (const file of files) { + const content = yield* fs.readFileString( + path.isAbsolute(file) ? file : path.join(workdir, file), + ); + const hash = createHash("sha256").update(content).digest("hex"); + const appliedHash = applied.get(file); + if (appliedHash !== undefined) { + if (appliedHash === hash) continue; // Already applied, unchanged. + pending.push({ path: file, hash, dirty: true }); + continue; + } + pending.push({ path: file, hash, dirty: false }); + } + return pending as ReadonlyArray; +}); + +/** + * Applies pending seed files. Mirrors Go's `SeedData` + `ExecBatchWithCache` + * (`pkg/migration/seed.go:65-83`, `file.go:198-217`): create the `seed_files` + * table, then per file emit the dirty/clean status line and, in one transaction, + * run the file's statements (skipped when dirty — only the hash is refreshed) + * followed by the `seed_files` hash upsert. + */ +export const legacySeedData = ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + workdir: string, + path: Path.Path, + seeds: ReadonlyArray, + mapError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + const output = yield* Output; + if (seeds.length === 0) return; + // Go's `CreateSeedTable` (history.go:54-64) runs `SET lock_timeout = '4s'` + + // schema/table DDL in one implicit transaction, so a conflicting schema/table lock + // fails promptly but the timeout reverts on COMMIT and never leaks into the seed + // SQL run below. `legacyCreateSeedTable` reproduces that with BEGIN + SET LOCAL + + // DDL + COMMIT (creating the schema first so a seed-only run doesn't fail). + yield* legacyCreateSeedTable(session); + for (const seed of seeds) { + yield* output.raw( + seed.dirty + ? `Updating seed hash to ${seed.path}...\n` + : `Seeding data from ${seed.path}...\n`, + "stderr", + ); + // Go's `ExecBatchWithCache` parses the file (read + `SplitAndTrim`) + // UNCONDITIONALLY before the dirty check (`file.go:198-211`), so a dirty seed + // that is unreadable or contains malformed SQL still fails and leaves the + // previous hash — only the queueing of statements is gated on `Dirty`. + const lines = legacySplitAndTrim( + yield* fs.readFileString( + path.isAbsolute(seed.path) ? seed.path : path.join(workdir, seed.path), + ), + ); + const statements = seed.dirty ? [] : lines; + yield* session.exec("BEGIN"); + const body = Effect.gen(function* () { + for (const statement of statements) yield* session.exec(statement); + yield* session.query(UPSERT_SEED_FILE, [seed.path, seed.hash]); + yield* session.exec("COMMIT"); + }); + yield* body.pipe(Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore))); + } + }).pipe( + Effect.mapError((error) => + mapError( + typeof error === "object" && error !== null && "message" in error + ? String((error as { message: unknown }).message) + : String(error), + ), + ), + ); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.unit.test.ts new file mode 100644 index 0000000000..3c50f914c8 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.unit.test.ts @@ -0,0 +1,126 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Data, Effect, Exit, FileSystem, Path } from "effect"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; +import { legacyMatchPattern, legacySeedData } from "./legacy-seed-ops.ts"; + +class TestError extends Data.TaggedError("TestError")<{ readonly message: string }> {} + +function fakeSeedSession() { + const calls: Array<{ kind: "exec" | "query"; sql: string }> = []; + const session: LegacyDbSession = { + exec: (sql) => { + calls.push({ kind: "exec", sql }); + return Effect.void; + }, + query: (sql) => { + calls.push({ kind: "query", sql }); + return Effect.succeed([]); + }, + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }; + return { session, calls }; +} + +describe("legacyMatchPattern", () => { + it("matches a literal filename", () => { + expect(legacyMatchPattern("seed.sql", "seed.sql")).toBe(true); + expect(legacyMatchPattern("seed.sql", "other.sql")).toBe(false); + }); + + it("matches `*` against any run of characters", () => { + expect(legacyMatchPattern("*.sql", "seed.sql")).toBe(true); + expect(legacyMatchPattern("*.sql", "0001_init.sql")).toBe(true); + expect(legacyMatchPattern("*.sql", "seed.txt")).toBe(false); + expect(legacyMatchPattern("seed.*", "seed.sql")).toBe(true); + }); + + it("matches `?` against exactly one character", () => { + expect(legacyMatchPattern("seed?.sql", "seed1.sql")).toBe(true); + expect(legacyMatchPattern("seed?.sql", "seed12.sql")).toBe(false); + expect(legacyMatchPattern("seed?.sql", "seed.sql")).toBe(false); + }); + + it("matches character classes with ranges and negation", () => { + expect(legacyMatchPattern("seed[0-9].sql", "seed5.sql")).toBe(true); + expect(legacyMatchPattern("seed[0-9].sql", "seedx.sql")).toBe(false); + expect(legacyMatchPattern("seed[!0-9].sql", "seedx.sql")).toBe(true); + expect(legacyMatchPattern("seed[!0-9].sql", "seed5.sql")).toBe(false); + }); + + it("honors backslash escapes", () => { + expect(legacyMatchPattern("seed\\*.sql", "seed*.sql")).toBe(true); + expect(legacyMatchPattern("seed\\*.sql", "seedx.sql")).toBe(false); + }); + + it("collapses consecutive stars", () => { + expect(legacyMatchPattern("**.sql", "seed.sql")).toBe(true); + }); +}); + +const runSeed = ( + session: LegacyDbSession, + workdir: string, + seeds: ReadonlyArray<{ readonly path: string; readonly hash: string; readonly dirty: boolean }>, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacySeedData( + session, + fs, + workdir, + path, + seeds, + (message) => new TestError({ message }), + ); + }).pipe(Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(BunServices.layer)); + +describe("legacySeedData (dirty parse)", () => { + it.effect("fails on an unreadable dirty seed instead of refreshing its hash", () => { + // Go's `ExecBatchWithCache` reads + parses the file UNCONDITIONALLY before the + // dirty check, so a dirty seed pointing at a missing file must fail (and leave + // the previous hash) rather than silently upserting the new hash. + const dir = mkdtempSync(join(tmpdir(), "legacy-seed-")); + const { session, calls } = fakeSeedSession(); + return runSeed(session, dir, [{ path: "missing.sql", hash: "newhash", dirty: true }]).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + // The hash upsert is a `query`; the only execs that ran are the + // schema/table creation (whose DDL also mentions `seed_files`), so assert + // no `query` ran rather than substring-matching the table name. + expect(calls.some((c) => c.kind === "query")).toBe(false); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("refreshes the hash for a dirty seed that parses, without running statements", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-seed-")); + writeFileSync(join(dir, "data.sql"), "insert into t values (1);"); + const { session, calls } = fakeSeedSession(); + return runSeed(session, dir, [{ path: "data.sql", hash: "newhash", dirty: true }]).pipe( + Effect.tap(() => + Effect.sync(() => { + // Go's CreateSeedTable scopes the lock timeout to the DDL transaction + // (BEGIN + SET LOCAL + COMMIT) so it never leaks into the seed SQL below. + expect(calls.some((c) => c.sql === "SET LOCAL lock_timeout = '4s'")).toBe(true); + // Statements are NOT executed for a dirty seed, but the hash IS upserted. + expect(calls.some((c) => c.sql.includes("insert into t"))).toBe(false); + expect(calls.some((c) => c.kind === "query" && c.sql.includes("seed_files"))).toBe(true); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-vault.ts b/apps/cli/src/legacy/commands/db/shared/legacy-vault.ts new file mode 100644 index 0000000000..65f33a6243 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-vault.ts @@ -0,0 +1,108 @@ +import { Effect } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import type { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; + +/** + * Vault SQL, verbatim from Go's `pkg/vault/batch.go`. `create_secret(value, name)` + * and `update_secret(id, value)` argument orders match Go exactly. + */ +const READ_VAULT_KV = "SELECT id, name FROM vault.secrets WHERE name = ANY($1)"; +const CREATE_VAULT_KV = "SELECT vault.create_secret($1, $2)"; +const UPDATE_VAULT_KV = "SELECT vault.update_secret($1, $2)"; + +// Go's secret env-reference form (`pkg/config/decode_hooks.go:11` — +// `envPattern = ^env\((.*)\)$`); env() references are never synced to the vault — +// Go's decode hook leaves any `env(...)` value verbatim with an empty SHA256, +// regardless of the inner name's casing. Mirror Go's broad pattern exactly (`.` +// excludes newline in both RE2 and JS without the `s` flag) so a lowercase/oddly +// named reference such as `env(foo)` is skipped, not synced as a literal. +const ENV_REFERENCE_PATTERN = /^env\(.*\)$/u; +// dotenvx-encrypted secrets (Go decrypts before hashing). Decryption is not yet +// ported, so encrypted entries are skipped rather than sent as ciphertext. +const ENCRYPTED_PREFIX = "encrypted:"; + +/** + * Extracts the raw `[db.vault]` string entries from a loaded config document. + * The document is the post-`env()` raw TOML (values are typed `unknown`), so + * non-string entries are defensively skipped. + */ +export function legacyReadVaultDocument( + document: Record | undefined, +): Readonly> | undefined { + const db = document?.["db"]; + const vault = + typeof db === "object" && db !== null ? (db as Record)["vault"] : undefined; + if (typeof vault !== "object" || vault === null) return undefined; + const result: Record = {}; + for (const [key, value] of Object.entries(vault)) { + if (typeof value === "string") result[key] = value; + } + return result; +} + +/** + * Selects the `[db.vault]` entries Go would sync. Go's secret decode + * (`pkg/config/secret.go:86-108`) sets a non-empty `SHA256` — the gate + * `UpsertVaultSecrets` keys on — only for non-empty, non-`env()` values, so those + * are exactly the syncable ones. Encrypted values are excluded pending the + * decryption port (documented in SIDE_EFFECTS.md). + */ +export function legacySyncableVaultSecrets( + vault: Readonly> | undefined, +): ReadonlyArray<{ readonly key: string; readonly value: string }> { + if (vault === undefined) return []; + const result: Array<{ readonly key: string; readonly value: string }> = []; + for (const [key, value] of Object.entries(vault)) { + if (value.length === 0) continue; + if (ENV_REFERENCE_PATTERN.test(value)) continue; + if (value.startsWith(ENCRYPTED_PREFIX)) continue; + result.push({ key, value }); + } + return result; +} + +/** + * Upserts configured `[db.vault]` secrets into the target database. Mirrors Go's + * `vault.UpsertVaultSecrets` (`pkg/vault/batch.go:25-60`): no-op when nothing is + * syncable; otherwise read existing secrets by name, `update_secret` the matches + * (by id) and `create_secret` the rest. Emits `Updating vault secrets...` to + * stderr only when there is at least one secret to sync. + */ +export const legacyUpsertVaultSecrets = ( + session: LegacyDbSession, + vault: Readonly> | undefined, + mapError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + const output = yield* Output; + const secrets = legacySyncableVaultSecrets(vault); + if (secrets.length === 0) return; + const toInsert = new Map(secrets.map((s) => [s.key, s.value])); + + yield* output.raw("Updating vault secrets...\n", "stderr"); + + const existing = yield* session.query(READ_VAULT_KV, [secrets.map((s) => s.key)]); + + // Go queues every update/create in a single `pgx.Batch` and `SendBatch().Close()` + // runs it as one implicit transaction (`pkg/vault/batch.go:46-58`), so a later + // failure leaves Vault entirely unchanged. Mirror that atomicity with an explicit + // transaction around the write phase (the read above stays outside, as in Go). + yield* session.exec("BEGIN"); + const writes = Effect.gen(function* () { + for (const row of existing) { + const name = String(row["name"]); + const id = String(row["id"]); + const value = toInsert.get(name); + if (value === undefined) continue; + yield* session.query(UPDATE_VAULT_KV, [id, value]); + toInsert.delete(name); + } + for (const [key, value] of toInsert) { + yield* session.query(CREATE_VAULT_KV, [value, key]); + } + yield* session.exec("COMMIT"); + }); + yield* writes.pipe(Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore))); + }).pipe(Effect.mapError((error: LegacyDbExecError) => mapError(error.message))); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-vault.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-vault.unit.test.ts new file mode 100644 index 0000000000..31d2808ee1 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-vault.unit.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Data, Effect, Exit } from "effect"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; +import { + legacyReadVaultDocument, + legacySyncableVaultSecrets, + legacyUpsertVaultSecrets, +} from "./legacy-vault.ts"; + +class FakeExecError extends Data.TaggedError("LegacyDbExecError")<{ readonly message: string }> {} + +function fakeVaultSession(opts: { failOn?: string } = {}) { + const calls: Array<{ kind: "exec" | "query"; sql: string }> = []; + const session: LegacyDbSession = { + exec: (sql) => { + calls.push({ kind: "exec", sql }); + return Effect.void; + }, + query: (sql) => { + calls.push({ kind: "query", sql }); + return opts.failOn !== undefined && sql.includes(opts.failOn) + ? Effect.fail(new FakeExecError({ message: "boom" })) + : Effect.succeed([] as ReadonlyArray>); + }, + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }; + return { session, calls }; +} + +describe("legacyReadVaultDocument", () => { + it("returns undefined when the document or db.vault is absent", () => { + expect(legacyReadVaultDocument(undefined)).toBeUndefined(); + expect(legacyReadVaultDocument({})).toBeUndefined(); + expect(legacyReadVaultDocument({ db: 5 })).toBeUndefined(); + expect(legacyReadVaultDocument({ db: { vault: "nope" } })).toBeUndefined(); + }); + + it("keeps only string-valued entries", () => { + expect(legacyReadVaultDocument({ db: { vault: { a: "x", b: 1, c: "y" } } })).toEqual({ + a: "x", + c: "y", + }); + }); +}); + +describe("legacySyncableVaultSecrets", () => { + it("returns nothing for an absent table", () => { + expect(legacySyncableVaultSecrets(undefined)).toEqual([]); + }); + + it("skips empty, env-reference, and encrypted values", () => { + const result = legacySyncableVaultSecrets({ + empty: "", + fromEnv: "env(MY_SECRET)", + encrypted: "encrypted:abc", + literal: "plain-value", + }); + expect(result).toEqual([{ key: "literal", value: "plain-value" }]); + }); + + it("skips any env(...) reference regardless of inner casing (Go's broad envPattern)", () => { + // Go's `^env\((.*)\)$` matches any inner name, so a lowercase/odd reference is + // left verbatim and never synced — it must NOT be treated as a literal value. + const result = legacySyncableVaultSecrets({ + lower: "env(foo)", + mixed: "env(My_Secret)", + empty: "env()", + dotted: "env(foo.bar)", + literal: "plain-value", + }); + expect(result).toEqual([{ key: "literal", value: "plain-value" }]); + }); +}); + +describe("legacyUpsertVaultSecrets", () => { + const run = (session: LegacyDbSession, vault: Record) => + legacyUpsertVaultSecrets(session, vault, (m) => new FakeExecError({ message: m })).pipe( + Effect.provide(mockOutput({ format: "text" }).layer), + ); + + it.effect("wraps the create/update writes in a single transaction", () => { + const { session, calls } = fakeVaultSession(); + return run(session, { a: "one", b: "two" }).pipe( + Effect.tap(() => + Effect.sync(() => { + const execs = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execs).toContain("BEGIN"); + expect(execs).toContain("COMMIT"); + // Both secrets created inside the transaction (no pre-existing rows). + expect(calls.filter((c) => c.sql.includes("create_secret")).length).toBe(2); + }), + ), + ); + }); + + it.effect("rolls back so a mid-write failure leaves Vault unchanged", () => { + const { session, calls } = fakeVaultSession({ failOn: "create_secret" }); + return run(session, { a: "one", b: "two" }).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + const execs = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execs).toContain("BEGIN"); + expect(execs).toContain("ROLLBACK"); + expect(execs).not.toContain("COMMIT"); + }), + ), + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md index 0c980a749c..dcf8466552 100644 --- a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md @@ -1,17 +1,35 @@ # `supabase db start` +Native TS port of `apps/cli-go/internal/db/start/start.go` `Run`. The handler +validates config, checks whether the local Postgres container is already running, +and otherwise delegates the container bootstrap to the bundled Go binary's hidden +`db __db-bootstrap --mode start` seam (the container-lifecycle primitives are not +ported). This is `db start`, **not** the top-level `supabase start`: no status +table, no `cli_stack_started` event, no `Finished` line. + ## Files Read -| Path | Format | When | -| -------------------------------- | ------ | ---------------------------------- | -| `/supabase/config.toml` | TOML | always, to resolve local DB config | -| `` (from `--from-backup`) | binary | when `--from-backup` flag is set | +| Path | Format | When | +| -------------------------------- | ------ | --------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always — parsed up front; a malformed config aborts before work | +| `` (from `--from-backup`) | binary | when `--from-backup` is set (read by the Go seam on start) | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------------------------- | ------ | --------------------------------------------------------------------------- | +| `/supabase/.branches/_current_branch` | text | by the Go seam (`initCurrentBranch`) when starting; writes `main` if absent | +| local Docker volume `supabase_db_` | — | by the Go seam — the Postgres data volume created on first start | +| `~/.supabase/telemetry.json` | JSON | always (telemetry flush, success and failure) | + +## Subprocesses + +| Command | When | Purpose | +| ---------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `docker container inspect supabase_db_` | always | `AssertSupabaseDbIsRunning` probe (Podman fallback) | +| `supabase-go db __db-bootstrap --mode start [--from-backup

]` | when the database is not running | create container + health check + initial schema/roles/migrations/seed + `_current_branch`; telemetry disabled (`SUPABASE_TELEMETRY_DISABLED=1`), progress teed to stderr | + +`--network-id` and a flag-selected `--profile` are forwarded to the seam. ## API Routes @@ -19,34 +37,45 @@ | ------ | ---- | ---- | ------------ | ---------------------- | | — | — | — | — | — | +(The Go seam may call Auth's JWKS endpoint while applying service migrations on a +fresh PG15 volume; that is internal to the seam, not the TS handler.) + ## Environment Variables -| Variable | Purpose | Required? | -| -------- | ------- | --------- | -| — | — | — | +| Variable | Purpose | Required? | +| ----------------------------- | ---------------------------------------------------- | ---------- | +| `SUPABASE_PROJECT_ID` | overrides the local container id (`utils.DbId`) | no | +| `SUPABASE_TELEMETRY_DISABLED` | set on the seam subprocess so it never double-counts | (internal) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------ | -| `0` | success | -| `1` | Docker not running | -| `1` | database container start error | +| Code | Condition | +| ---- | --------------------------------------------------------------------- | +| `0` | success — database started, or already running | +| `1` | malformed `supabase/config.toml` | +| `1` | Docker daemon unreachable / inspect failure | +| `1` | container bootstrap failed (the seam cleans up via `DockerRemoveAll`) | ## Output ### `--output-format text` (Go CLI compatible) -Prints progress to stderr as the local Postgres container starts. +- Already running → `Postgres database is already running.` on **stderr**, exit 0. +- Starting → the Go seam tees `Starting database...` / `Initialising schema...` to + **stderr**. No stdout output, no `Finished` line. ### `--output-format json` -Not applicable. +Emits a single result object to stdout: `{ status: "already-running" }` or +`{ status: "started" }`. Progress stays on stderr. ### `--output-format stream-json` -Not applicable. +Same result object as the terminal `result` event; progress on stderr. ## Notes -- `--from-backup` restores the database from a logical backup file on start. +- `--from-backup` restores the database from a logical backup file on start; the + health check is skipped for backups (a large restore can exceed the timeout). +- No `cli_stack_started` telemetry — that event belongs to `supabase start`, not + `db start`. The only event is the standard `cli_command_executed`. diff --git a/apps/cli/src/legacy/commands/db/start/start.command.ts b/apps/cli/src/legacy/commands/db/start/start.command.ts index cc4081ae84..c9457733ae 100644 --- a/apps/cli/src/legacy/commands/db/start/start.command.ts +++ b/apps/cli/src/legacy/commands/db/start/start.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyDbStart } from "./start.handler.ts"; +import { legacyDbStartRuntimeLayer } from "./start.layers.ts"; const config = { fromBackup: Flag.string("from-backup").pipe( @@ -14,5 +18,15 @@ export type LegacyDbStartFlags = CliCommand.Command.Config.Infer; export const legacyDbStartCommand = Command.make("start", config).pipe( Command.withDescription("Starts local Postgres database."), Command.withShortDescription("Starts local Postgres database"), - Command.withHandler((flags) => legacyDbStart(flags)), + Command.withHandler((flags) => + legacyDbStart(flags).pipe( + withLegacyCommandInstrumentation({ + // `--from-backup` is not telemetry-safe in Go (no markFlagTelemetrySafe), + // so a set value reaches telemetry as ``. + flags: { "from-backup": flags.fromBackup }, + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyDbStartRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/db/start/start.e2e.test.ts b/apps/cli/src/legacy/commands/db/start/start.e2e.test.ts new file mode 100644 index 0000000000..ec031f3e1d --- /dev/null +++ b/apps/cli/src/legacy/commands/db/start/start.e2e.test.ts @@ -0,0 +1,42 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +import { runSupabase } from "../../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; + +/** + * Golden-path e2e: exercises the real compiled-binary boundary for the one + * environment-independent path of `db start` — a malformed `config.toml`, which + * the handler validates BEFORE the "already running?" probe, so it fails fast + * without touching Docker. The container-bootstrap behavior (the running check, + * the `__db-bootstrap` seam, the "already running" line) is covered by the + * integration suite with the seam mocked; booting a real Postgres container is + * deliberately out of scope here (matching the sibling `db diff` / `seed buckets` + * legacy e2e tests, none of which boot a live stack). + */ +describe("supabase db start (legacy)", () => { + let projectDir: string; + + beforeAll(() => { + projectDir = mkdtempSync(join(tmpdir(), "supabase-db-start-e2e-")); + mkdirSync(join(projectDir, "supabase"), { recursive: true }); + // Invalid TOML — aborts config loading before any container work. + writeFileSync(join(projectDir, "supabase", "config.toml"), 'project_id = "unterminated\n'); + }); + + afterAll(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + test("fails fast on a malformed config.toml", { timeout: E2E_TIMEOUT_MS }, async () => { + const { exitCode, stdout, stderr } = await runSupabase(["db", "start"], { + entrypoint: "legacy", + cwd: projectDir, + }); + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain("failed to parse supabase/config.toml"); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/start/start.errors.ts b/apps/cli/src/legacy/commands/db/start/start.errors.ts new file mode 100644 index 0000000000..4a80ad4448 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/start/start.errors.ts @@ -0,0 +1,10 @@ +import { Data } from "effect"; + +/** + * `supabase/config.toml` failed to parse. Go loads the config first thing in + * `start.Run` (`flags.LoadConfig`, `internal/db/start/start.go:45`), so a + * malformed config aborts before the container is touched. + */ +export class LegacyDbStartConfigLoadError extends Data.TaggedError("LegacyDbStartConfigLoadError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/db/start/start.handler.ts b/apps/cli/src/legacy/commands/db/start/start.handler.ts index a1aa3e586a..f6a80c9244 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -1,10 +1,72 @@ import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { loadProjectConfig } from "@supabase/config"; +import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.ts"; import type { LegacyDbStartFlags } from "./start.command.ts"; +import { LegacyDbStartConfigLoadError } from "./start.errors.ts"; +/** + * `supabase db start` — start the local Postgres database. + * + * Strict 1:1 port of `apps/cli-go/internal/db/start/start.go` `Run`. Native TS + * orchestrates: it validates config, checks whether the database is already + * running (printing Go's "already running" line), and otherwise delegates the + * container bootstrap to the hidden Go `__db-bootstrap` seam (create container + + * health + initial schema + `_current_branch`), whose progress is teed to stderr. + * + * Parity notes: this is `db start`, NOT the top-level `supabase start`. It does + * NOT print a status table and does NOT fire `cli_stack_started` — those belong to + * `internal/start/start.go`. There is no `Finished` line. + */ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: LegacyDbStartFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["db", "start"]; - if (Option.isSome(flags.fromBackup)) args.push("--from-backup", flags.fromBackup.value); - yield* proxy.exec(args); + const output = yield* Output; + const cliConfig = yield* LegacyCliConfig; + const seam = yield* LegacyDbBootstrapSeam; + const telemetryState = yield* LegacyTelemetryState; + + const body = Effect.gen(function* () { + // Go's `flags.LoadConfig(fsys)` runs first; a malformed config aborts before + // any container work. A missing config is tolerated here (loadProjectConfig + // returns null) — the seam's Go LoadConfig then surfaces Go's authoritative + // missing-config error on the not-running path. + yield* loadProjectConfig(cliConfig.workdir).pipe( + Effect.catchTag( + "ProjectConfigParseError", + (cause) => + new LegacyDbStartConfigLoadError({ + message: `failed to parse supabase/config.toml: ${String(cause.cause)}`, + }), + ), + ); + + // Go's AssertSupabaseDbIsRunning: if the db container is already up, print to + // stderr and return nil (exit 0). + const running = yield* seam.isDbRunning(); + if (running) { + if (output.format === "text") { + yield* output.raw("Postgres database is already running.\n", "stderr"); + } else { + yield* output.success("Postgres database is already running.", { + status: "already-running", + }); + } + return; + } + + // Not running → bootstrap the container (StartDatabase + DockerRemoveAll on + // failure). The seam tees "Starting database...", "Initialising schema...", + // etc. to stderr. + yield* seam.startDatabase({ fromBackup: Option.getOrUndefined(flags.fromBackup) }); + + if (output.format !== "text") { + yield* output.success("Started local database.", { status: "started" }); + } + }); + + // db start is local-only — no project ref, so no linked-project cache write. + // Telemetry still flushes on success and failure (Go's PersistentPostRun). + yield* body.pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts new file mode 100644 index 0000000000..538bffb7a3 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts @@ -0,0 +1,190 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + mockLegacyCliConfig, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import type { OutputFormat } from "../../../../shared/output/types.ts"; +import { LegacyDbBootstrapError } from "../shared/legacy-db-bootstrap.errors.ts"; +import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.ts"; +import { legacyDbStart } from "./start.handler.ts"; +import type { LegacyDbStartFlags } from "./start.command.ts"; + +const DEFAULT_FLAGS: LegacyDbStartFlags = { fromBackup: Option.none() }; + +/** + * Stateful mock of the container-bootstrap seam. `running` drives + * `AssertSupabaseDbIsRunning`; `runningFails` / `startFails` make the respective + * call fail (Docker daemon down / StartDatabase error). Records the args passed to + * `startDatabase`. + */ +function mockSeam(opts: { running?: boolean; runningFails?: boolean; startFails?: boolean } = {}) { + const startCalls: Array<{ fromBackup?: string }> = []; + const layer = Layer.succeed(LegacyDbBootstrapSeam, { + isDbRunning: () => + opts.runningFails === true + ? Effect.fail(new LegacyDbBootstrapError({ message: "failed to inspect service" })) + : Effect.succeed(opts.running ?? false), + startDatabase: (args: { fromBackup?: string }) => + opts.startFails === true + ? Effect.fail(new LegacyDbBootstrapError({ message: "failed to bootstrap" })) + : Effect.sync(() => { + startCalls.push(args); + }), + recreateDatabase: () => Effect.void, + awaitStorageReady: () => Effect.succeed(false), + }); + return { + layer, + get startCalls() { + return startCalls; + }, + }; +} + +function setup( + workdir: string, + opts: { + toml?: string; + format?: OutputFormat; + running?: boolean; + runningFails?: boolean; + startFails?: boolean; + }, +) { + if (opts.toml !== undefined) { + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), opts.toml); + } + const out = mockOutput({ format: opts.format ?? "text" }); + const seam = mockSeam(opts); + const telemetry = mockLegacyTelemetryStateTracked(); + const layer = Layer.mergeAll( + out.layer, + seam.layer, + mockLegacyCliConfig({ workdir }), + telemetry.layer, + BunServices.layer, + ); + return { layer, out, seam, telemetry }; +} + +describe("legacy db start", () => { + const tmp = useLegacyTempWorkdir("supabase-db-start-"); + + it.live("reports an already-running database without starting a container", () => { + const { layer, out, seam, telemetry } = setup(tmp.current, { + toml: 'project_id = "test"\n', + running: true, + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Postgres database is already running."); + expect(seam.startCalls).toHaveLength(0); + expect(telemetry.flushed).toBe(true); + }); + }); + + it.live("starts the database when it is not running", () => { + const { layer, out, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + running: false, + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.startCalls).toEqual([{ fromBackup: undefined }]); + // db start prints no "Finished" line and no status table. + expect(out.stderrText).not.toContain("Finished"); + }); + }); + + it.live("forwards --from-backup to the bootstrap seam", () => { + const { layer, seam } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + yield* legacyDbStart({ fromBackup: Option.some("/tmp/dump.sql") }).pipe( + Effect.provide(layer), + ); + expect(seam.startCalls).toEqual([{ fromBackup: "/tmp/dump.sql" }]); + }); + }); + + it.live("proceeds with no config file (missing config is tolerated)", () => { + const { layer, seam } = setup(tmp.current, { running: false }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.startCalls).toHaveLength(1); + }); + }); + + it.live("fails fast on a malformed config.toml", () => { + const { layer, seam, telemetry } = setup(tmp.current, { + toml: 'project_id = "unterminated\n', + }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(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 parse supabase/config.toml"); + } + // No container work attempted; telemetry still flushes on failure. + expect(seam.startCalls).toHaveLength(0); + expect(telemetry.flushed).toBe(true); + }); + }); + + it.live("propagates a Docker inspect failure", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', runningFails: true }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(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 inspect service"); + } + }); + }); + + it.live("propagates a StartDatabase failure", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', startFails: true }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(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 bootstrap"); + } + }); + }); + + it.live("emits a json result when the database is already running", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + running: true, + format: "json", + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["status"]).toBe("already-running"); + }); + }); + + it.live("emits a json result after starting the database", () => { + const { layer, out, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + running: false, + format: "json", + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.startCalls).toHaveLength(1); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["status"]).toBe("started"); + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/start/start.layers.ts b/apps/cli/src/legacy/commands/db/start/start.layers.ts new file mode 100644 index 0000000000..71603292ee --- /dev/null +++ b/apps/cli/src/legacy/commands/db/start/start.layers.ts @@ -0,0 +1,27 @@ +import { Layer } from "effect"; + +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; +import { legacyDbBootstrapSeamLayer } from "../shared/legacy-db-bootstrap.seam.layer.ts"; + +/** + * Runtime layer for `supabase db start`. The command is local-only, so it needs + * far less than the remote-capable db commands: just the container-bootstrap seam + * (`db __db-bootstrap`), the CLI config (workdir + project id), and the telemetry + * flush. The seam's other dependencies (`LegacyNetworkIdFlag`, `LegacyProfileFlag`, + * `ChildProcessSpawner`, `FileSystem`, `Path`) are ambient from the root runtime, + * matching how `db diff` composes the `db __shadow` seam. `LegacyCliConfig` is + * provided to the seam explicitly (legacy CLAUDE.md rule 5). + */ +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +const seam = legacyDbBootstrapSeamLayer.pipe(Layer.provide(cliConfig)); + +export const legacyDbStartRuntimeLayer = Layer.mergeAll( + seam, + cliConfig, + legacyTelemetryStateLayer, + commandRuntimeLayer(["db", "start"]), +); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts index 9ddb8fdb86..24b2543055 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts @@ -1,138 +1,31 @@ -import { - loadProjectConfig, - type LoadProjectConfigOptions, - ProjectConfigSchema, -} from "@supabase/config"; -import { Effect, FileSystem, Option, Path, Schema } from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; -import type { PlatformError } from "effect/PlatformError"; +import { Effect, Option } from "effect"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; -import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; -import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; -import { legacySeedChangedTargetFlags } from "./buckets.flags.ts"; -import { legacyBold, legacyYellow } from "../../../shared/legacy-colors.ts"; -import { - legacyResolveStorageCredentials, - legacyStorageGatewayFetch, -} from "../../../shared/legacy-storage-credentials.ts"; -import { - legacyParseFileSizeLimit, - legacyResolveBucketProps, -} from "../../../shared/legacy-storage-bucket-config.ts"; -import { - type LegacyStorageGateway, - type LegacyUpsertBucketProps, - legacyMakeStorageGateway, -} from "../../../shared/legacy-storage-gateway.ts"; -import type { LegacyStorageGatewayError } from "../../../shared/legacy-storage-gateway.errors.ts"; -import { Output } from "../../../../shared/output/output.service.ts"; -import { - legacyIsLocalVectorBucketsUnavailable, - legacyIsVectorBucketsFeatureNotEnabled, -} from "./buckets.classify.ts"; -import { LegacySeedConfigLoadError } from "./buckets.errors.ts"; -import { legacyBucketObjectKey } from "./buckets.upload.ts"; -import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; -import { - legacyContentTypeForUpload, - legacyReadSniffBytes, -} from "../../../shared/legacy-storage-content-type.ts"; +import { legacySeedBucketsRun } from "../../../shared/legacy-seed-buckets.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { legacySeedChangedTargetFlags } from "./buckets.flags.ts"; import type { LegacyBucketsFlags } from "./buckets.command.ts"; -const CONFIG_PATH = "supabase/config.toml"; -const UPLOAD_CONCURRENCY = 5; - -/** - * Mirrors Go's `ValidateBucketName` regex (`apps/cli-go/pkg/config/config.go:1382`). - * Used to validate `[storage.buckets]` names before any Storage API call, matching - * Go's config-load-time check (`config.go:899-903`). Vector and analytics names are - * NOT validated here — Go only validates `[storage.buckets]`. - */ -const LEGACY_BUCKET_NAME_PATTERN = /^(?:[0-9A-Za-z_]|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$/; - -/** - * Verbatim Go regex literal (`config.go:1382`) — used in the error message so it - * is byte-identical to Go's output. Do NOT derive from `LEGACY_BUCKET_NAME_PATTERN.source`. - */ -const LEGACY_BUCKET_NAME_PATTERN_SOURCE = - "^(\\w|!|-|\\.|\\*|'|\\(|\\)| |&|\\$|@|=|;|:|\\+|,|\\?)*$"; - -const legacyValidateBucketName = Effect.fnUntraced(function* (name: string) { - if (!LEGACY_BUCKET_NAME_PATTERN.test(name)) { - return yield* new LegacySeedConfigLoadError({ - message: `Invalid Bucket name: ${name}. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (${LEGACY_BUCKET_NAME_PATTERN_SOURCE})`, - }); - } -}); - -interface CollectedFile { - readonly absPath: string; - readonly displayPath: string; -} - -/** Mutable run summary, emitted as the structured result in json/stream-json mode. */ -interface SeedSummary { - readonly buckets_created: Array; - readonly buckets_updated: Array; - readonly buckets_skipped: Array; - readonly vector_created: Array; - readonly vector_pruned: Array; - vector_skipped: boolean; - readonly objects_uploaded: Array; - readonly analytics_created: Array; - readonly analytics_pruned: Array; -} - -function emptySummary(): SeedSummary { - return { - buckets_created: [], - buckets_updated: [], - buckets_skipped: [], - vector_created: [], - vector_pruned: [], - vector_skipped: false, - objects_uploaded: [], - analytics_created: [], - analytics_pruned: [], - }; -} - -/** - * Embedded-default project config, decoded from an empty object — the same - * `decodeUnknownSync(ProjectConfigSchema)({})` the loader uses internally - * (`packages/config/src/io.ts:54-56`). Go's `seed buckets` never aborts on a - * missing `config.toml`: it reads the package-global `utils.Config`, initialized - * to embedded defaults, and `config.Load` no-ops on a missing file. So "no - * config file" behaves like the embedded-default config. - */ -const legacyDecodeDefaultProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); - /** * `supabase seed buckets` — seeds Storage buckets from * `[storage.buckets]` / `[storage.vector]` in `supabase/config.toml`. * * Port of `apps/cli-go/internal/seed/buckets/buckets.go`. When `--linked` is * passed, the remote Storage gateway is used with the project's service-role key; - * otherwise the local stack is used. + * otherwise the local stack is used. The seeding work lives in the hoisted + * `legacySeedBucketsRun` (shared with `db reset --local`); this handler owns the + * target-flag resolution and the post-run cache + telemetry side effects. */ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( // Target is selected from the changed-flag set (Go's flag.Changed), not the // parsed value, so the flags arg itself is unused here. _flags: LegacyBucketsFlags, ) { - const output = yield* Output; - const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; const linkedProjectCache = yield* LegacyLinkedProjectCache; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const cliArgs = yield* CliArgs; - // `--yes` OR `SUPABASE_YES` (Go's viper AutomaticEnv, root.go:318-320). - const yes = yield* legacyResolveYes; // Set once --linked resolves a ref; drives the post-run linked-project cache // write + org/project group identify, mirroring Go's `ensureProjectGroupsCached` @@ -141,135 +34,18 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( let linkedRef = ""; yield* Effect.gen(function* () { - // 1. Resolve the project ref for --linked BEFORE loading config, so that - // the matching `[remotes.]` override (whose `project_id == ref`) is - // merged over the base config by `loadProjectConfig`. Go selects the target - // from `flag.Changed`, not the flag value: `--linked` is the linked path - // whenever it's *set* (even `--linked=false`). + // Resolve the project ref for --linked BEFORE loading config, so that the + // matching `[remotes.]` override (whose `project_id == ref`) is merged + // over the base config by `loadProjectConfig`. Go selects the target from + // `flag.Changed`, not the flag value: `--linked` is the linked path whenever + // it's *set* (even `--linked=false`). const setFlags = legacySeedChangedTargetFlags(cliArgs.args); const projectRefResolver = yield* LegacyProjectRefResolver; const projectRef = setFlags.includes("linked") ? yield* projectRefResolver.loadProjectRef(Option.none()) : ""; linkedRef = projectRef; - - // 2. Load config.toml, passing projectRef so `[remotes.*]` overrides are - // merged for --linked. A parse failure aborts before any network call. - const loadOptions: LoadProjectConfigOptions | undefined = - projectRef !== "" ? { projectRef } : undefined; - const loaded = yield* loadProjectConfig(cliConfig.workdir, loadOptions).pipe( - Effect.catchTag( - "ProjectConfigParseError", - (cause) => - new LegacySeedConfigLoadError({ - message: `failed to parse supabase/config.toml: ${String(cause.cause)}`, - }), - ), - ); - // A missing config file is NOT an early exit: Go uses embedded defaults and - // still gates the no-op on `len(projectRef) == 0`. So local + no-config falls - // into the no-op short-circuit; `--linked` + no-config falls through to the - // remote path so auth/project/API failures surface. - const config = loaded === null ? legacyDecodeDefaultProjectConfig({}) : loaded.config; - const document = loaded === null ? undefined : loaded.document; - - // Go prints this from inside config load (`config.go:513`) whenever a - // `[remotes.*]` block matched the linked ref. stderr in all output modes. - if (loaded !== null && loaded.appliedRemote !== undefined) { - yield* output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr"); - } - const bucketsConfig = config.storage.buckets ?? {}; - const bucketNames = Object.keys(bucketsConfig); - const vectorEnabled = config.storage.vector.enabled; - const vectorBucketNames = Object.keys(config.storage.vector.buckets); - const hasVectorBuckets = vectorBucketNames.length > 0; - - // 3. Config-load-time validations run BEFORE the no-op short-circuit: Go - // decodes the whole config (storage.FileSizeLimit, bucket sizes) and runs - // ValidateBucketName during config.Load — before `buckets.Run` can take its - // no-op path — so an invalid value fails even when there's nothing to seed. - // - // 3a. Bucket names (Go ValidateBucketName, config.go:899-903). - for (const name of bucketNames) { - yield* legacyValidateBucketName(name); - } - - // 3b. Storage-level file_size_limit, parsed unconditionally. - const storageFileSizeLimitBytes = yield* parseFileSizeLimitOrFail( - config.storage.file_size_limit, - ); - - // 3c. Per-bucket props (sizes parsed before any Storage call). - const bucketPropsByName = new Map(); - for (const [name, bucket] of Object.entries(bucketsConfig)) { - bucketPropsByName.set( - name, - yield* computeBucketProps(document, name, bucket, storageFileSizeLimitBytes), - ); - } - - // 3d. Short-circuit: nothing to seed (ref present → never short-circuits). - if (projectRef === "" && bucketNames.length === 0 && !hasVectorBuckets) { - if (output.format !== "text") { - yield* output.success("", { ...emptySummary() }); - } - return; - } - - // 4. Build the Storage service-gateway client (local or remote). - const credentials = yield* legacyResolveStorageCredentials({ projectRef, config }); - - // All gateway operations run with an explicit non-DoH fetch (CA-trusting for - // local + https, plain `globalThis.fetch` otherwise). The api-keys lookup - // inside `legacyResolveStorageCredentials` runs BEFORE this scope, so it - // still honors `--dns-resolver https`, matching Go's `tenant.GetApiKeys`. - const gatewayOps = Effect.gen(function* () { - const gateway = yield* legacyMakeStorageGateway({ - baseUrl: credentials.baseUrl, - apiKey: credentials.apiKey, - userAgent: cliConfig.userAgent, - }); - - const summary = emptySummary(); - - // 5. Upsert configured buckets. - yield* upsertBuckets(output, yes, gateway, bucketPropsByName, summary); - - // 6. Upsert analytics buckets (remote --linked only). - if (config.storage.analytics.enabled && projectRef !== "") { - yield* output.raw("Updating analytics buckets...\n", "stderr"); - yield* upsertAnalyticsBuckets( - output, - yes, - gateway, - Object.keys(config.storage.analytics.buckets), - summary, - ); - } - - // 7. Upsert vector buckets (local), with graceful skip on unavailability. - if (vectorEnabled && hasVectorBuckets) { - yield* output.raw("Updating vector buckets...\n", "stderr"); - yield* upsertVectorBuckets(output, yes, gateway, vectorBucketNames, summary).pipe( - Effect.catch((error) => handleVectorError(output, error, summary)), - ); - } - - // 8. Upload objects for each bucket with a configured objects_path. - yield* uploadObjects(fs, path, output, gateway, cliConfig.workdir, bucketsConfig, summary); - - // 9. Machine-readable summary (Go has none; text mode emits nothing extra). - if (output.format !== "text") { - yield* output.success("", { ...summary }); - } - }); - - yield* gatewayOps.pipe( - Effect.provideService( - FetchHttpClient.Fetch, - legacyStorageGatewayFetch(credentials.localKongCa), - ), - ); + yield* legacySeedBucketsRun({ projectRef, emitSummary: true }); }).pipe( // Go's root `Execute` caches the linked project + fires org/project group // identify whenever `flags.ProjectRef` is set — only on the --linked path. @@ -279,315 +55,3 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( Effect.ensuring(telemetryState.flush), ); }); - -type BucketsConfig = Readonly< - Record< - string, - { - readonly public: boolean; - readonly file_size_limit: string; - readonly allowed_mime_types: ReadonlyArray; - readonly objects_path: string; - } - > ->; - -// Parse a `file_size_limit` string to bytes, mapping a parse failure to a -// config-load error (Go rejects an invalid `sizeInBytes` during `config.Load`, -// before NewStorageAPI). -const parseFileSizeLimitOrFail = (value: string) => - Effect.try({ - try: () => legacyParseFileSizeLimit(value), - catch: (cause) => - new LegacySeedConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); - -const computeBucketProps = ( - document: Record | undefined, - name: string, - bucket: BucketsConfig[string], - storageFileSizeLimitBytes: number, -) => - Effect.try({ - try: () => legacyResolveBucketProps({ document, name, bucket, storageFileSizeLimitBytes }), - catch: (cause) => - new LegacySeedConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); - -// Port of `pkg/storage/batch.go:UpsertBuckets`. `propsByName` is precomputed and -// size-validated before this runs (Go parses sizes at config-load, before any -// Storage call). -const upsertBuckets = Effect.fnUntraced(function* ( - output: typeof Output.Service, - yes: boolean, - gateway: LegacyStorageGateway, - propsByName: ReadonlyMap, - summary: SeedSummary, -) { - const existing = yield* gateway.listBuckets(); - const byName = new Map(existing.map((b) => [b.name, b.id])); - - for (const [name, props] of propsByName) { - const bucketId = byName.get(name); - if (bucketId !== undefined) { - const overwrite = yield* legacyPromptYesNo( - output, - yes, - `Bucket ${legacyBold(bucketId)} already exists. Do you want to overwrite its properties?`, - true, - ); - if (!overwrite) { - summary.buckets_skipped.push(bucketId); - continue; - } - yield* output.raw(`Updating Storage bucket: ${bucketId}\n`, "stderr"); - yield* gateway.updateBucket(bucketId, props); - summary.buckets_updated.push(bucketId); - } else { - yield* output.raw(`Creating Storage bucket: ${name}\n`, "stderr"); - yield* gateway.createBucket(name, props); - summary.buckets_created.push(name); - } - } -}); - -// Port of `pkg/storage/vector.go:UpsertVectorBuckets`. -const upsertVectorBuckets = Effect.fnUntraced(function* ( - output: typeof Output.Service, - yes: boolean, - gateway: LegacyStorageGateway, - configuredNames: ReadonlyArray, - summary: SeedSummary, -) { - const existing = yield* gateway.listVectorBuckets(); - const existingSet = new Set(existing); - const configuredSet = new Set(configuredNames); - const toDelete = existing.filter((name) => !configuredSet.has(name)); - - for (const name of configuredNames) { - if (existingSet.has(name)) { - yield* output.raw(`Bucket already exists: ${name}\n`, "stderr"); - continue; - } - yield* output.raw(`Creating vector bucket: ${name}\n`, "stderr"); - yield* gateway.createVectorBucket(name); - summary.vector_created.push(name); - } - - for (const name of toDelete) { - const prune = yield* legacyPromptYesNo( - output, - yes, - `Bucket ${legacyBold(name)} not found in ${legacyBold(CONFIG_PATH)}. Do you want to prune it?`, - false, - ); - if (!prune) { - continue; - } - yield* output.raw(`Pruning vector bucket: ${name}\n`, "stderr"); - yield* gateway.deleteVectorBucket(name); - summary.vector_pruned.push(name); - } -}); - -// Port of `pkg/storage/analytics.go:UpsertAnalyticsBuckets`. -const upsertAnalyticsBuckets = Effect.fnUntraced(function* ( - output: typeof Output.Service, - yes: boolean, - gateway: LegacyStorageGateway, - configuredNames: ReadonlyArray, - summary: SeedSummary, -) { - const existing = yield* gateway.listAnalyticsBuckets(); - const existingSet = new Set(existing); - const configuredSet = new Set(configuredNames); - const toDelete = existing.filter((name) => !configuredSet.has(name)); - - for (const name of configuredNames) { - if (existingSet.has(name)) { - yield* output.raw(`Bucket already exists: ${name}\n`, "stderr"); - continue; - } - yield* output.raw(`Creating analytics bucket: ${name}\n`, "stderr"); - yield* gateway.createAnalyticsBucket(name); - summary.analytics_created.push(name); - } - - for (const name of toDelete) { - const prune = yield* legacyPromptYesNo( - output, - yes, - `Bucket ${legacyBold(name)} not found in ${legacyBold(CONFIG_PATH)}. Do you want to prune it?`, - false, - ); - if (!prune) { - continue; - } - yield* output.raw(`Pruning analytics bucket: ${name}\n`, "stderr"); - yield* gateway.deleteAnalyticsBucket(name); - summary.analytics_pruned.push(name); - } -}); - -/** - * Vector graceful-skip (`buckets.go:57-66`): on `FeatureNotEnabled` / - * local-unavailable errors, print the matching WARNING and continue (object - * upload still runs). Any other error propagates. - */ -const handleVectorError = Effect.fnUntraced(function* ( - output: typeof Output.Service, - error: LegacyStorageGatewayError, - summary: SeedSummary, -) { - if (legacyIsVectorBucketsFeatureNotEnabled(error.message)) { - yield* output.raw( - `${legacyYellow("WARNING:")} Vector buckets are not available in this project's region yet. Skipping vector bucket seeding.\n`, - "stderr", - ); - summary.vector_skipped = true; - return; - } - if (legacyIsLocalVectorBucketsUnavailable(error.message)) { - yield* output.raw( - `${legacyYellow("WARNING:")} Vector buckets are not available in the local storage service. If this project is linked, run \`supabase link\` to update service versions, then restart the local stack. Skipping vector bucket seeding.\n`, - "stderr", - ); - summary.vector_skipped = true; - return; - } - return yield* Effect.fail(error); -}); - -// Port of `pkg/storage/batch.go:UpsertObjects` (+ object walk in objects.go). -const uploadObjects = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - output: typeof Output.Service, - gateway: LegacyStorageGateway, - workdir: string, - bucketsConfig: BucketsConfig, - summary: SeedSummary, -) { - for (const [name, bucket] of Object.entries(bucketsConfig)) { - const objectsPath = bucket.objects_path; - if (objectsPath.length === 0) { - continue; - } - // Go resolves a relative bucket objects_path against SupabaseDirPath at - // config-resolve time (`pkg/config/config.go:757-759`); absolute paths are - // left untouched. `displayRoot` (workdir-relative) drives the `Uploading:` - // stderr and the destination key so both stay byte-identical to Go. - const displayRoot = path.isAbsolute(objectsPath) - ? objectsPath - : path.join("supabase", objectsPath); - const absRoot = path.isAbsolute(objectsPath) - ? objectsPath - : path.join(workdir, "supabase", objectsPath); - const files = yield* collectFiles(fs, path, output, absRoot, displayRoot); - yield* Effect.forEach( - files, - (file) => - Effect.gen(function* () { - const dstPath = legacyBucketObjectKey(name, displayRoot, file.displayPath); - yield* output.raw(`Uploading: ${file.displayPath} => ${dstPath}\n`, "stderr"); - // Content-type is byte-driven: Go sniffs the first 512 bytes with - // http.DetectContentType, refining only a generic text/plain by - // extension (`pkg/storage/objects.go:77-108`). - const sniff = yield* legacyReadSniffBytes(fs, file.absPath); - // Go's seed upload always sets Cache-Control max-age=3600 and x-upsert - // (Overwrite) true (`pkg/storage/batch.go`). - yield* gateway.uploadObject(dstPath, file.absPath, { - contentType: legacyContentTypeForUpload(sniff, file.absPath), - cacheControl: "max-age=3600", - overwrite: true, - }); - summary.objects_uploaded.push(dstPath); - }), - { concurrency: UPLOAD_CONCURRENCY }, - ); - } -}); - -/** - * Collect uploadable files under `absRoot`, lexically ordered, mirroring Go's - * `fs.WalkDir` + `isUploadableEntry` (`pkg/storage/batch.go:65-131`). - * - * Parity details: - * - The **root** is resolved with a following stat (Go's `fs.Stat`), so a - * symlinked `objects_path` is followed; a missing/dangling root fails. - * - **Nested** entries use no-follow detection: real directories are descended; - * symlinks are NOT descended — Go's `isUploadableEntry` OPENS the symlink - * target then stats the handle, uploading only a regular file and skipping - * dangling symlinks / symlinks-to-directories / unreadable targets. - */ -const collectFiles = ( - fs: FileSystem.FileSystem, - path: Path.Path, - output: typeof Output.Service, - absRoot: string, - displayRoot: string, -): Effect.Effect, PlatformError> => - Effect.gen(function* () { - const info = yield* fs.stat(absRoot); - if (info.type === "Directory") { - return yield* collectDir(fs, path, output, absRoot, displayRoot); - } - if (info.type === "File") { - return [{ absPath: absRoot, displayPath: displayRoot }]; - } - yield* output.raw(`Skipping non-regular file: ${displayRoot}\n`, "stderr"); - return []; - }); - -const collectDir = ( - fs: FileSystem.FileSystem, - path: Path.Path, - output: typeof Output.Service, - absDir: string, - displayDir: string, -): Effect.Effect, PlatformError> => - Effect.gen(function* () { - const names = [...(yield* fs.readDirectory(absDir))].sort(); - const collected: Array = []; - for (const name of names) { - const absChild = path.join(absDir, name); - const displayChild = path.join(displayDir, name); - // `readLink` succeeds only on a symlink — our no-follow detector (Effect's - // `stat` follows symlinks and has no `lstat`). - const isSymlink = yield* fs.readLink(absChild).pipe( - Effect.as(true), - Effect.catch(() => Effect.succeed(false)), - ); - if (isSymlink) { - // Go `isUploadableEntry` (batch.go:73-84) OPENS the target then stats the - // handle; it uploads only a regular file. `stat` alone would queue an - // unreadable target and abort later at upload, so mirror Go: open + stat. - const targetType = yield* Effect.scoped( - Effect.gen(function* () { - const handle = yield* fs.open(absChild, { flag: "r" }); - const targetInfo = yield* handle.stat; - return targetInfo.type; - }), - ).pipe(Effect.catch(() => Effect.succeed("Unknown" as const))); - if (targetType === "File") { - collected.push({ absPath: absChild, displayPath: displayChild }); - } else { - yield* output.raw(`Skipping non-regular file: ${displayChild}\n`, "stderr"); - } - continue; - } - const childInfo = yield* fs.stat(absChild); - if (childInfo.type === "Directory") { - collected.push(...(yield* collectDir(fs, path, output, absChild, displayChild))); - } else if (childInfo.type === "File") { - collected.push({ absPath: absChild, displayPath: displayChild }); - } else { - yield* output.raw(`Skipping non-regular file: ${displayChild}\n`, "stderr"); - } - } - return collected; - }); diff --git a/apps/cli/src/legacy/commands/services/services.integration.test.ts b/apps/cli/src/legacy/commands/services/services.integration.test.ts index 564e8f6e11..e2431f3dfa 100644 --- a/apps/cli/src/legacy/commands/services/services.integration.test.ts +++ b/apps/cli/src/legacy/commands/services/services.integration.test.ts @@ -67,6 +67,7 @@ function setup( apiUrl: "https://api.supabase.com", projectHost: "supabase.co", poolerHost: "supabase.com", + dashboardUrl: "https://supabase.com/dashboard", accessToken: Option.none(), projectId: Option.none(), workdir: process.cwd(), diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts index 70ec567d30..9ced346d5f 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts @@ -2,7 +2,11 @@ import { Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; import { parse as parseYaml } from "yaml"; import { CLI_VERSION } from "../../shared/cli/version.ts"; import { LegacyProfileFlag, LegacyWorkdirFlag } from "../../shared/legacy/global-flags.ts"; -import { legacyPoolerHost, legacyProjectHost } from "../shared/legacy-profile.ts"; +import { + legacyDashboardUrl, + legacyPoolerHost, + legacyProjectHost, +} from "../shared/legacy-profile.ts"; import { LegacyDebugLogger, type LegacyDebugLoggerShape, @@ -16,6 +20,7 @@ interface ResolvedProfile { readonly apiUrl: string; readonly projectHost: string; readonly poolerHost: string; + readonly dashboardUrl: string; } const BUILTIN_PROFILE_API_URLS: Record = { @@ -38,6 +43,7 @@ function resolvedBuiltin(name: LegacyProfileName): ResolvedProfile { apiUrl: BUILTIN_PROFILE_API_URLS[name], projectHost: legacyProjectHost(name), poolerHost: legacyPoolerHost(name), + dashboardUrl: legacyDashboardUrl(name), }; } @@ -47,6 +53,7 @@ function safeParseYaml(text: string): api_url?: unknown; project_host?: unknown; pooler_host?: unknown; + dashboard_url?: unknown; } | undefined { try { @@ -57,6 +64,7 @@ function safeParseYaml(text: string): api_url?: unknown; project_host?: unknown; pooler_host?: unknown; + dashboard_url?: unknown; }) : undefined; } catch { @@ -147,6 +155,13 @@ function resolveProfile( // that omits `pooler_host:` yields an empty host, which disables the MITM // domain assertion — it must NOT fall back to the production `supabase.com`. poolerHost: typeof parsed.pooler_host === "string" ? parsed.pooler_host : "", + // Go's `Profile.DashboardURL` is `required` (`profile.go:20`); a YAML profile + // that omits it falls back to the built-in `supabase` dashboard here rather + // than erroring, since it only feeds the connect-failure suggestion text. + dashboardUrl: + typeof parsed.dashboard_url === "string" + ? parsed.dashboard_url + : legacyDashboardUrl("supabase"), }; }); } @@ -200,6 +215,7 @@ export const legacyCliConfigLayer = Layer.unwrap( apiUrl, projectHost, poolerHost, + dashboardUrl, } = yield* resolveProfile( profileFlag, env["SUPABASE_PROFILE"], @@ -236,6 +252,7 @@ export const legacyCliConfigLayer = Layer.unwrap( apiUrl, projectHost, poolerHost, + dashboardUrl, accessToken, projectId, workdir, diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts index 541e2d4a9b..f6d8ca20f8 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts @@ -56,6 +56,7 @@ describe("legacyCliConfigLayer", () => { expect(config.apiUrl).toBe("https://api.supabase.com"); expect(config.projectHost).toBe("supabase.co"); expect(config.poolerHost).toBe("supabase.com"); + expect(config.dashboardUrl).toBe("https://supabase.com/dashboard"); }).pipe(Effect.provide(makeLayer({ cwd: tempRoot }))), ); @@ -154,7 +155,7 @@ describe("legacyCliConfigLayer", () => { ), ); - it.effect("loads api_url, name, and pooler_host from a YAML profile file", () => { + it.effect("loads api_url, name, pooler_host, and dashboard_url from a YAML profile file", () => { const profilePath = join(tempRoot, "profile.yaml"); writeFileSync( profilePath, @@ -163,6 +164,7 @@ describe("legacyCliConfigLayer", () => { 'api_url: "http://127.0.0.1:9999"', "project_host: localhost", "pooler_host: staging.example.com", + 'dashboard_url: "http://127.0.0.1:9999"', ].join("\n"), ); return Effect.gen(function* () { @@ -171,6 +173,9 @@ describe("legacyCliConfigLayer", () => { expect(config.apiUrl).toBe("http://127.0.0.1:9999"); expect(config.projectHost).toBe("localhost"); expect(config.poolerHost).toBe("staging.example.com"); + // Go reads `dashboard_url` from the profile (used by the connect-failure hint); + // the cli-e2e harness points it at the replay server for parity. + expect(config.dashboardUrl).toBe("http://127.0.0.1:9999"); }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot }))); }); @@ -183,6 +188,8 @@ describe("legacyCliConfigLayer", () => { // Go's Profile.PoolerHost is `omitempty`: an absent pooler_host disables the // MITM domain assertion rather than falling back to supabase.com. expect(config.poolerHost).toBe(""); + // An omitted dashboard_url falls back to the built-in supabase dashboard. + expect(config.dashboardUrl).toBe("https://supabase.com/dashboard"); }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot }))); }); diff --git a/apps/cli/src/legacy/config/legacy-cli-config.service.ts b/apps/cli/src/legacy/config/legacy-cli-config.service.ts index 3bef2e2a1c..432d3d267a 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.service.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.service.ts @@ -29,6 +29,14 @@ interface LegacyCliConfigShape { * db-config resolver's MITM domain check. */ readonly poolerHost: string; + /** + * Dashboard base URL for the active profile (Go's `Profile.DashboardURL`, + * `apps/cli-go/internal/utils/profile.go:20`). Sourced from the resolved profile — + * the built-in table for named profiles, or the `dashboard_url:` key of a YAML + * profile file — so staging/custom dashboards are honored. Used by the + * connect-failure suggestion (Go's `SetConnectSuggestion` network-restrictions hint). + */ + readonly dashboardUrl: string; readonly accessToken: Option.Option>; readonly projectId: Option.Option; readonly workdir: string; diff --git a/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts b/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts index 3990163113..04b64ff06b 100644 --- a/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts +++ b/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts @@ -24,6 +24,7 @@ function mockCliConfig(opts: { workdir: string; projectId?: string }) { apiUrl: "https://api.supabase.com", projectHost: "supabase.co", poolerHost: "supabase.com", + dashboardUrl: "https://supabase.com/dashboard", accessToken: Option.none(), projectId: opts.projectId === undefined ? Option.none() : Option.some(opts.projectId), workdir: opts.workdir, diff --git a/apps/cli/src/legacy/shared/legacy-connect-errors.ts b/apps/cli/src/legacy/shared/legacy-connect-errors.ts index 9aa4675915..75bb8d9a53 100644 --- a/apps/cli/src/legacy/shared/legacy-connect-errors.ts +++ b/apps/cli/src/legacy/shared/legacy-connect-errors.ts @@ -45,3 +45,94 @@ export function legacyIsIPv6ConnectivityError(message: string): boolean { } return false; } + +/** + * Go's `utils.SuggestEnvVar` (`internal/utils/connect.go:191`): the hint shown when + * a connection fails on password authentication, pointing users at the + * `SUPABASE_DB_PASSWORD` env var. + */ +export const LEGACY_SUGGEST_ENV_VAR = + "Connect to your database by setting the env var correctly: SUPABASE_DB_PASSWORD"; + +/** Context the connect-suggestion needs but cannot derive from the error alone. */ +export interface LegacyConnectSuggestionContext { + /** Active profile's dashboard URL (Go's `CurrentProfile.DashboardURL`). */ + readonly dashboardUrl: string; + /** Active profile name (Go's `CurrentProfile.Name`). */ + readonly profileName: string; + /** Whether `--debug` is set (Go's `viper.GetBool("DEBUG")`). */ + readonly debug: boolean; +} + +/** + * Flatten an error's `cause` chain and any `AggregateError.errors` into a single + * searchable string of every nested `message` and `code`. The `@effect/sql` + * `SqlError` wraps the node-postgres / node `net` driver error on its `cause`; a + * multi-address dial wraps an `AggregateError` whose `errors[]` carry the per-IP + * `ECONNREFUSED` / `ENETUNREACH` system errors. Including the `code` strings lets + * the matcher key off node's `ECONNREFUSED` the way Go keys off pgconn's + * `connect: connection refused`. + */ +function legacyCollectConnectErrorText(error: unknown): string { + const parts: string[] = []; + const seen = new Set(); + const visit = (node: unknown, depth: number): void => { + if (depth > 8 || typeof node !== "object" || node === null || seen.has(node)) return; + seen.add(node); + const message = Reflect.get(node, "message"); + if (typeof message === "string") parts.push(message); + const code = Reflect.get(node, "code"); + if (typeof code === "string") parts.push(code); + visit(Reflect.get(node, "cause"), depth + 1); + const errors = Reflect.get(node, "errors"); + if (Array.isArray(errors)) for (const child of errors) visit(child, depth + 1); + }; + visit(error, 0); + return parts.join("\n"); +} + +/** + * Port of Go's `SetConnectSuggestion` (`internal/utils/connect.go:313-335`): map a + * Postgres connect failure to an actionable hint that replaces the generic + * "Try rerunning the command with --debug" suggestion. Go matches `pgconn`'s + * error text; this matches the equivalent node-postgres / node `net` driver text + * and codes (e.g. `ECONNREFUSED` for `connect: connection refused`) gathered from + * the `SqlError` cause/aggregate chain. The branch order mirrors Go's `if/else if`. + * Returns `undefined` when no specific suggestion applies (the caller then falls + * back to the generic suggestion, like Go leaving `CmdSuggestion` empty). + */ +export function legacyConnectSuggestion( + error: unknown, + ctx: LegacyConnectSuggestionContext, +): string | undefined { + const text = legacyCollectConnectErrorText(error); + // connect: connection refused / Address not in tenant allow_list → network restrictions. + if ( + text.includes("ECONNREFUSED") || + text.includes("connection refused") || + text.includes("Address not in tenant allow_list") + ) { + return `Make sure your local IP is allowed in Network Restrictions and Network Bans.\n${ctx.dashboardUrl}/project/_/database/settings`; + } + // SSL connection is required (only under --debug, which disables TLS). + if (text.includes("SSL connection is required") && ctx.debug) { + return "SSL connection is not supported with --debug flag"; + } + // Wrong password (Go: "SCRAM exchange: Wrong password" / "failed SASL auth"; + // node-postgres surfaces the server's `28P01` "password authentication failed"). + if ( + text.includes("SCRAM exchange: Wrong password") || + text.includes("failed SASL auth") || + text.includes("password authentication failed") + ) { + return LEGACY_SUGGEST_ENV_VAR; + } + if (legacyIsIPv6ConnectivityError(text)) { + return legacyIpv6Suggestion(); + } + // no route to host / Tenant or user not found → wrong profile. + if (text.includes("no route to host") || text.includes("Tenant or user not found")) { + return `Make sure your project exists on profile: ${ctx.profileName}`; + } + return undefined; +} diff --git a/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts b/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts index b8edbdfe10..c1b9abf92f 100644 --- a/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { legacyIsIPv6ConnectivityError } from "./legacy-connect-errors.ts"; +import { + LEGACY_SUGGEST_ENV_VAR, + legacyConnectSuggestion, + legacyIpv6Suggestion, + legacyIsIPv6ConnectivityError, +} from "./legacy-connect-errors.ts"; describe("legacyIsIPv6ConnectivityError", () => { it("classifies the getaddrinfo IPv6-only failures (case-insensitive)", () => { @@ -33,3 +38,74 @@ describe("legacyIsIPv6ConnectivityError", () => { expect(legacyIsIPv6ConnectivityError("")).toBe(false); }); }); + +describe("legacyConnectSuggestion", () => { + const ctx = { + dashboardUrl: "https://supabase.com/dashboard", + profileName: "supabase", + debug: false, + } as const; + + // The @effect/sql SqlError wraps the node driver error on `.cause`; a multi-address + // dial wraps an AggregateError whose `.errors[]` carry the per-IP system errors. + const sqlError = (cause: unknown) => + Object.assign(new Error("PgClient: Failed to connect"), { cause }); + const systemError = (message: string, code: string) => + Object.assign(new Error(message), { code }); + + it("maps a refused connection (node ECONNREFUSED) to the network-restrictions hint", () => { + const err = sqlError(systemError("connect ECONNREFUSED 127.0.0.1:54322", "ECONNREFUSED")); + expect(legacyConnectSuggestion(err, ctx)).toBe( + "Make sure your local IP is allowed in Network Restrictions and Network Bans.\nhttps://supabase.com/dashboard/project/_/database/settings", + ); + }); + + it("maps an AggregateError of refused dials to the network-restrictions hint", () => { + const err = sqlError( + Object.assign(new AggregateError([], "all attempts failed"), { + errors: [systemError("connect ECONNREFUSED [::1]:54322", "ECONNREFUSED")], + }), + ); + expect(legacyConnectSuggestion(err, ctx)).toContain( + "Make sure your local IP is allowed in Network Restrictions and Network Bans.", + ); + }); + + it("maps the pooler allow_list rejection to the network-restrictions hint", () => { + const err = sqlError(new Error("Address not in tenant allow_list")); + expect(legacyConnectSuggestion(err, ctx)).toContain("Network Restrictions and Network Bans"); + }); + + it("maps a password-auth failure to the env-var suggestion", () => { + const err = sqlError( + Object.assign(new Error('password authentication failed for user "postgres"'), { + code: "28P01", + }), + ); + expect(legacyConnectSuggestion(err, ctx)).toBe(LEGACY_SUGGEST_ENV_VAR); + }); + + it("suggests the --debug SSL note only under --debug", () => { + const err = sqlError(new Error("SSL connection is required")); + expect(legacyConnectSuggestion(err, ctx)).toBeUndefined(); + expect(legacyConnectSuggestion(err, { ...ctx, debug: true })).toBe( + "SSL connection is not supported with --debug flag", + ); + }); + + it("maps an IPv6-only connectivity failure to the IPv6 pooler suggestion", () => { + const err = sqlError(new Error("dial tcp: network is unreachable")); + expect(legacyConnectSuggestion(err, ctx)).toBe(legacyIpv6Suggestion()); + }); + + it("maps a tenant-not-found error to the wrong-profile hint", () => { + const err = sqlError(new Error("Tenant or user not found")); + expect(legacyConnectSuggestion(err, ctx)).toBe( + "Make sure your project exists on profile: supabase", + ); + }); + + it("returns undefined for an unrecognized connect error", () => { + expect(legacyConnectSuggestion(sqlError(new Error("some other failure")), ctx)).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts index b8c0c4d860..551ae62ef7 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts @@ -128,6 +128,13 @@ describe("legacyDbConfigResolver (local + db-url)", () => { user: "postgres", password: "hunter2", database: "postgres", + // The resolver attaches the connect-failure suggestion context (Go's + // ambient CurrentProfile) to every resolved connection. + suggestionContext: { + dashboardUrl: "https://supabase.com/dashboard", + profileName: "supabase", + debug: false, + }, }); expect(r.isLocal).toBe(true); rmSync(dir, { recursive: true, force: true }); @@ -176,6 +183,11 @@ describe("legacyDbConfigResolver (local + db-url)", () => { user: "alice", password: "p@ss", database: "appdb", + suggestionContext: { + dashboardUrl: "https://supabase.com/dashboard", + profileName: "supabase", + debug: false, + }, }); expect(r.isLocal).toBe(false); rmSync(dir, { recursive: true, force: true }); diff --git a/apps/cli/src/legacy/shared/legacy-db-config.layer.ts b/apps/cli/src/legacy/shared/legacy-db-config.layer.ts index d6d9e355cf..641c5f3636 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.layer.ts @@ -21,6 +21,10 @@ import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { Tty } from "../../shared/runtime/tty.service.ts"; import { Analytics } from "../../shared/telemetry/analytics.service.ts"; import { TelemetryRuntime } from "../../shared/telemetry/runtime.service.ts"; +import { + type LegacyConnectSuggestionContext, + LEGACY_SUGGEST_ENV_VAR, +} from "./legacy-connect-errors.ts"; import { LegacyDbConnection, type LegacyPgConnInput } from "./legacy-db-connection.service.ts"; import { LegacyIdentityStitch } from "./legacy-identity-stitch.ts"; import { @@ -44,9 +48,6 @@ const TCP_PROBE_TIMEOUT = Duration.seconds(5); const MAX_RETRIES = 8; const BACKOFF_INITIAL = Duration.seconds(3); const BACKOFF_MAX = Duration.seconds(60); -// Go: utils.SuggestEnvVar (`apps/cli-go/internal/utils/connect.go:174`). -const SUGGEST_ENV_VAR = - "Connect to your database by setting the env var correctly: SUPABASE_DB_PASSWORD"; const loginRoleErrorMapper = mapLegacyHttpError({ networkError: Errors.LegacyDbConfigLoginRoleNetworkError, @@ -106,6 +107,16 @@ export const legacyDbConfigLayer = Layer.effect( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + // Profile context for the connect-failure suggestion (Go's `SetConnectSuggestion` + // reads the ambient `CurrentProfile` + `viper.GetBool("DEBUG")`). Snapshot it once + // and attach it to every resolved connection so the driver layer can render Go's + // hint on a refused/auth/IPv6 connect error. + const suggestionContext: LegacyConnectSuggestionContext = { + dashboardUrl: cliConfig.dashboardUrl, + profileName: cliConfig.profile, + debug: yield* LegacyDebugFlag, + }; + // Capture the ambient services the Management API stack needs, so the // lazily-built linked stack is fully self-provided and `resolve`'s R stays // `never` (handler tests can mock this resolver without wiring the whole @@ -203,7 +214,7 @@ export const legacyDbConfigLayer = Layer.effect( return Effect.fail( new Errors.LegacyDbConfigConnectTempRoleError({ message: `failed to connect as temp role: ${cause.message}`, - suggestion: SUGGEST_ENV_VAR, + suggestion: LEGACY_SUGGEST_ENV_VAR, }), ); } @@ -552,6 +563,18 @@ export const legacyDbConfigLayer = Layer.effect( ); }); - return LegacyDbConfigResolver.of({ resolve, resolvePoolerFallback }); + // Attach the connect-failure suggestion context to every resolved connection in + // one place (Go sets it ambiently via `CurrentProfile`), so each connecting + // command inherits Go's `SetConnectSuggestion` hint without per-call-site wiring. + const withSuggestion = (conn: LegacyPgConnInput): LegacyPgConnInput => ({ + ...conn, + suggestionContext, + }); + return LegacyDbConfigResolver.of({ + resolve: (flags) => + resolve(flags).pipe(Effect.map((r) => ({ ...r, conn: withSuggestion(r.conn) }))), + resolvePoolerFallback: (flags) => + resolvePoolerFallback(flags).pipe(Effect.map(Option.map(withSuggestion))), + }); }), ); diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.service.ts b/apps/cli/src/legacy/shared/legacy-db-connection.service.ts index bbf1e81dbf..d2bfb2b261 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.service.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.service.ts @@ -1,4 +1,5 @@ import { Context, type Effect, type Scope } from "effect"; +import type { LegacyConnectSuggestionContext } from "./legacy-connect-errors.ts"; import type { LegacyDbConnectError, LegacyDbCopyError, @@ -71,6 +72,13 @@ export interface LegacyPgConnInput { * `ToPostgresURL`/`ConnectLocalPostgres`). */ readonly connectTimeoutSeconds?: number; + /** + * Profile context for the connect-failure suggestion (Go's `SetConnectSuggestion`, + * which reads the ambient `CurrentProfile` in `ConnectByUrl`). The resolver attaches + * it so the driver layer can map a refused/auth/IPv6 connect error to Go's actionable + * hint. Absent → the driver omits the suggestion (callers fall back to the generic one). + */ + readonly suggestionContext?: LegacyConnectSuggestionContext; } /** diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts index fbd5bfca90..f291140c54 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts @@ -10,6 +10,7 @@ import * as Reactivity from "effect/unstable/reactivity/Reactivity"; // resolves, so the COPY path and the pooled path use the same driver. import * as Pg from "pg"; import { to as pgCopyTo } from "pg-copy-streams"; +import { legacyConnectSuggestion } from "./legacy-connect-errors.ts"; import { LegacyDbConnectError, LegacyDbCopyError, @@ -407,8 +408,20 @@ const connect = ( connectionTimeoutMillis: connectTimeoutSeconds * 1000, }); - const toConnectError = (error: unknown) => - new LegacyDbConnectError({ message: `failed to connect to postgres: ${error}` }); + // Go's `ConnectByUrl` calls `SetConnectSuggestion(err)` on every connect failure + // (`connect.go:187`), mapping the driver error to an actionable hint that replaces + // the generic "--debug" suggestion. The resolver attaches the profile context to + // `cfg.suggestionContext`; map it here so the suggestion travels on the error. + const toConnectError = (error: unknown) => { + const suggestion = + cfg.suggestionContext === undefined + ? undefined + : legacyConnectSuggestion(error, cfg.suggestionContext); + return new LegacyDbConnectError({ + message: `failed to connect to postgres: ${error}`, + ...(suggestion === undefined ? {} : { suggestion }), + }); + }; // Load the `sslrootcert` CA bundle (pgconn reads it into `RootCAs` at parse // time; a missing/unreadable file aborts). Skipped for local connections, which @@ -548,8 +561,7 @@ const connect = ( const fresh = new Pg.Client(winningRawConfig); yield* Effect.tryPromise({ try: () => fresh.connect(), - catch: (error) => - new LegacyDbConnectError({ message: `failed to connect to postgres: ${error}` }), + catch: toConnectError, }); if (!isLocal && needsRoleStepDown(cfg.user)) { yield* Effect.tryPromise({ diff --git a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts index 2f47d3c84e..40d315aec6 100644 --- a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts +++ b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts @@ -49,6 +49,8 @@ export interface LegacyDbTargetSelection { export const VALUE_CONSUMING_LONG_FLAGS = new Set([ // db-family command flags "db-url", + "password", // db push/pull/dump/remote (StringVarP, short -p) + "sql-paths", "schema", "level", "fail-on", @@ -81,6 +83,7 @@ export const VALUE_CONSUMING_SHORT_FLAGS = new Set([ "o", // --output / -o "p", // --password / -p "j", // --jobs / -j (storage cp) + "p", // --password / -p (db push/pull/dump/remote) ]); /** diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index b9b376bea5..3ca0812bdd 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -1,5 +1,6 @@ import { Data, Effect, type FileSystem, type Path } from "effect"; +import { Output } from "../../shared/output/output.service.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; import { INSERT_MIGRATION_VERSION, @@ -60,7 +61,7 @@ const legacyTrimLeadingSqlComments = (sql: string): string => { * Whether a migration statement cannot run inside a transaction block — `CREATE * [UNIQUE] INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`, * `CLUSTER`. Such statements fail with SQLSTATE 25001 inside the `BEGIN`/`COMMIT` - * that wraps a migration, so `legacyApplyMigrationFile` runs them standalone. + * that wraps a migration, so `execMigrationBatch` runs them standalone. * Port of Go's `isPipelineIncompatible` (`pkg/migration/file.go`, supabase/cli#5156). */ export const legacyIsPipelineIncompatible = (sql: string): boolean => { @@ -79,55 +80,45 @@ type LegacyBatchItem = | { readonly kind: "exec"; readonly sql: string } | { readonly kind: "version" }; +const errMessage = (e: unknown): string => + typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" + ? e.message + : String(e); + /** - * Applies a single migration file to the connected database and records it in - * `supabase_migrations.schema_migrations`. Mirrors Go's `migration.ApplyMigrations` - * for one file (`pkg/migration/apply.go` + `(*MigrationFile).ExecBatch`): `RESET ALL` - * first to clear any session state leaked by a prior file, then create the history - * table, then run the file's statements + the history insert. - * - * Statements run inside a `BEGIN`/`COMMIT` batch, except pipeline-incompatible ones + * Runs a single migration/seed file's statements (plus the optional history insert). + * Mirrors Go's `(*MigrationFile).ExecBatch` (`pkg/migration/file.go`): statements run + * inside a `BEGIN`/`COMMIT` batch, except pipeline-incompatible ones * (`legacyIsPipelineIncompatible` — `CREATE INDEX CONCURRENTLY`, `VACUUM`, …) which - * cannot run in a transaction block: the batch is flushed (committed), the statement - * runs standalone, then batching resumes — mirroring Go's `ExecBatch` flush logic - * (supabase/cli#5156). The history insert goes in the final batch, so the migration - * is recorded only after every statement succeeds. A file with no such statements is - * a single `BEGIN`/`COMMIT` around everything, identical to the pre-fix behaviour. + * cannot run in a transaction block: the open batch is flushed (committed), the + * statement runs standalone, then batching resumes (supabase/cli#5156). The history + * insert goes in the final batch, so the migration is recorded only after every + * statement succeeds. A file with no such statements is a single `BEGIN`/`COMMIT`. * - * `mapError` lets the caller tag the failure (e.g. `LegacyDeclarativeApplyError`). + * Does NOT create the history table and does NOT `RESET ALL` — Go's `ExecBatch` does + * neither; those are the migration-apply path's responsibility (`ApplyMigrations`, + * apply.go:65-69), so role/globals files (`legacySeedGlobals`) stay reset-free like Go. + * When `forceNoVersion` is set the history insert is skipped regardless of filename + * (Go's `SeedGlobals` clears `Version`). */ -export const legacyApplyMigrationFile = ( +const execMigrationBatch = ( session: LegacyDbSession, fs: FileSystem.FileSystem, path: Path.Path, migrationPath: string, mapError: (message: string) => E, + forceNoVersion: boolean, ): Effect.Effect => Effect.gen(function* () { const content = yield* fs.readFileString(migrationPath); const statements = legacySplitAndTrim(content); const filename = path.basename(migrationPath); const matches = MIGRATE_FILE_PATTERN.exec(filename); - const version = matches?.[1] ?? ""; + const version = forceNoVersion ? "" : (matches?.[1] ?? ""); const name = matches?.[2] ?? ""; - // `RESET ALL` runs FIRST, before the history-table DDL: an earlier migration applied - // on this same connection may have left a session default (e.g. - // `SET default_transaction_read_only = on`) that would otherwise make this DDL fail - // before it is cleared. Go resets connection state at the top of each file's apply, - // ahead of any work (`apps/cli-go/pkg/migration/apply.go:65-69`). - yield* session.exec("RESET ALL"); - yield* legacyCreateMigrationTable(session); - // Mirror Go's `MigrationFile.ExecBatch` error context (`pkg/migration/file.go`): - // on a failed statement, append `At statement: ` and the statement text so the - // error (and the debug bundle) point at the exact failing SQL. (Go also adds a caret / - // pgErr.Detail / extension-type hint, which need the driver SQLSTATE the session does - // not currently surface — the statement number + text is the always-present context.) - const errMessage = (e: unknown): string => - typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" - ? e.message - : String(e); + // on a failed statement, append `At statement: ` and the statement text. const atStatement = (e: unknown, index: number, stat: string) => new Error(`${errMessage(e)}\nAt statement: ${index}\n${stat}`); @@ -183,10 +174,91 @@ export const legacyApplyMigrationFile = ( pending = [...pending, { kind: "version" }]; } yield* flushBatch; - }).pipe( - Effect.mapError((error) => - mapError( - "message" in error && typeof error.message === "string" ? error.message : String(error), - ), - ), - ); + }).pipe(Effect.mapError((error) => mapError(errMessage(error)))); + +/** + * Go's per-migration connection reset (`apply.go:65-69`): `RESET ALL` clears any + * connection settings a prior statement on the same session may have changed + * (e.g. `set_config('search_path', …)`), run before each migration's `ExecBatch`. + * Only the migration-apply path does this — `SeedGlobals` (role/globals files) + * must NOT, so this is a caller responsibility, never inside `execMigrationBatch`. + */ +const resetConnectionState = ( + session: LegacyDbSession, + mapError: (message: string) => E, +): Effect.Effect => + session.exec("RESET ALL").pipe(Effect.mapError((e) => mapError(errMessage(e)))); + +/** + * Applies a single migration file to the connected database and records it in + * `supabase_migrations.schema_migrations`. Mirrors Go's `migration.ApplyMigrations` + * for one file (`pkg/migration/apply.go` + `(*MigrationFile).ExecBatch`): `RESET ALL` + * first to clear any session state leaked by a prior file (e.g. + * `SET default_transaction_read_only = on`) before the history-table DDL, then create + * the history table, then run the file's statements + the history insert. + * + * `mapError` lets the caller tag the failure (e.g. `LegacyDeclarativeApplyError`). + */ +export const legacyApplyMigrationFile = ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + migrationPath: string, + mapError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + yield* resetConnectionState(session, mapError); + yield* legacyCreateMigrationTable(session).pipe( + Effect.mapError((e) => mapError(errMessage(e))), + ); + yield* execMigrationBatch(session, fs, path, migrationPath, mapError, false); + }); + +/** + * Applies a list of pending migration files, mirroring Go's + * `migration.ApplyMigrations` (`pkg/migration/apply.go:56-77`): create the + * history table once when there is anything to apply, then for each file emit + * `Applying migration ...` to stderr, `RESET ALL`, and run it transactionally. + */ +export const legacyApplyMigrations = ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + pending: ReadonlyArray, + mapError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + const output = yield* Output; + if (pending.length === 0) return; + yield* legacyCreateMigrationTable(session).pipe( + Effect.mapError((e) => mapError(errMessage(e))), + ); + for (const migrationPath of pending) { + yield* output.raw(`Applying migration ${path.basename(migrationPath)}...\n`, "stderr"); + // Go resets connection state per migration (apply.go:65-69) before ExecBatch. + yield* resetConnectionState(session, mapError); + yield* execMigrationBatch(session, fs, path, migrationPath, mapError, false); + } + }); + +/** + * Applies custom-role / globals files, mirroring Go's `migration.SeedGlobals` + * (`pkg/migration/seed.go:85-100`): for each file emit `Seeding globals from + * ...` to stderr and run it transactionally WITHOUT inserting a migration + * history row (Go clears `Version`), WITHOUT creating the history table, and WITHOUT + * `RESET ALL` (Go's `SeedGlobals` → `ExecBatch` never resets). + */ +export const legacySeedGlobals = ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + globals: ReadonlyArray, + mapError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + const output = yield* Output; + for (const globalPath of globals) { + yield* output.raw(`Seeding globals from ${path.basename(globalPath)}...\n`, "stderr"); + yield* execMigrationBatch(session, fs, path, globalPath, mapError, true); + } + }); diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index 567be21e75..a5039843de 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -5,10 +5,12 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Data, Effect, Exit, FileSystem, Path } from "effect"; +import { mockOutput } from "../../../tests/helpers/mocks.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; import { legacyApplyMigrationFile, legacyIsPipelineIncompatible, + legacySeedGlobals, } from "./legacy-migration-apply.ts"; class TestError extends Data.TaggedError("TestError")<{ readonly message: string }> {} @@ -230,3 +232,27 @@ describe("legacyIsPipelineIncompatible", () => { expect(legacyIsPipelineIncompatible(sql)).toBe(want); }); }); + +describe("legacySeedGlobals", () => { + it.effect("runs the globals file WITHOUT RESET ALL and without a history insert", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-globals-")); + const file = join(dir, "roles.sql"); + writeFileSync(file, "CREATE ROLE my_role;"); + const { session, calls } = fakeSession(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* legacySeedGlobals(session, fs, path, [file], (message) => new TestError({ message })); + const execs = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + // Go's SeedGlobals calls ExecBatch directly — no RESET ALL (that's only the + // migration-apply path) and no schema-migrations history insert. + expect(execs).not.toContain("RESET ALL"); + expect(execs).toContain("CREATE ROLE my_role"); + expect(calls.some((c) => c.kind === "query")).toBe(false); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide(BunServices.layer), + ); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts b/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts index 13ad746906..dda6e9fc6a 100644 --- a/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts +++ b/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts @@ -8,6 +8,9 @@ import { Output } from "../../shared/output/output.service.ts"; * `storage rm`: * - when `yes` is set, echoes `