From 5c07a87d602c1bcae42c2e17eb2153f5945f884f Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 02:11:21 +0100 Subject: [PATCH 1/3] fix(cli): port db reset local recreate to native TS (CLI-1955) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `supabase db reset`'s local path delegated its container-recreate work to the bundled Go binary via a hidden `db __db-bootstrap --mode recreate` / `--mode await-storage` seam. Ports this to native TS and deletes the seam entirely (both files). The issue's premise that reset "reuses the same create/health/ SetupLocalDatabase chain the native start port already implements" was wrong — Go's `resetDatabase15` never calls `StartDatabase`; it's a distinctly different composition (no volume probe, no `--from-backup` concept, unconditional setup with the *resolved* migration version instead of `""`, no rollback, no `_current_branch` write). This port builds a reset-specific `legacyRecreateLocalDatabase` directly over the same primitives `db start` uses, rather than wrapping `legacyStartDatabase`. Also native now: the PG14 recreate branch (template1 `DROP`/`CREATE DATABASE`, disconnect-clients with Go's swallow/surface semantics, replication-slot drain, `InitSchema14`/`ApplyApiPrivileges`), the concurrent satellite-container restart + Kong `nginx reload` (added same-day upstream to fix issue #6016 — this reload fails the whole command on error, unlike the existing best-effort one in `functions serve`), and the storage-container health gate. An empirical probe (real Postgres 14/15, the exact pinned pgconn/pgx versions) settled an open question about Go's `DROP`/`CREATE DATABASE` batching before this landed: it works via subtle protocol semantics the TS port doesn't need to replicate — four sequential, unwrapped statement execs reproduce the same real-world behavior more simply. Also, since this is the third `legacy/shared/db-bootstrap/` consumer: split the directory into `legacy/shared/containers/` (generic, cross- service Docker primitives) and a narrower `db-bootstrap/` (Postgres- specific), hoisted the container-CLI boilerplate duplicated across the new remove/restart primitives, and extracted the local container-input prelude `db start` and `db reset` were duplicating verbatim into a shared `legacyBuildLocalDbContainerInputs`. Known, deliberate scope boundary: `db schema declarative`'s smart-target and `db schema sync` still spawn `db reset --local` through the Go binary's own `reset.Run` via a wholly unrelated seam (`LegacyDeclarativeSeam.execInherit`) — so Go isn't fully removed from every `db reset --local` code path yet. Fixing that needs `legacyDbReset` made in-process-callable, a materially larger refactor out of scope here. Fixes CLI-1955 --- .../live/db-reset-start.live.e2e.test.ts | 14 +- apps/cli-go/cmd/db.go | 67 - apps/cli-go/internal/db/reset/reset.go | 32 - apps/cli/docs/go-cli-porting-status.md | 90 +- .../legacy/commands/db/reset/SIDE_EFFECTS.md | 205 +- .../commands/db/reset/await-storage-ready.ts | 62 + .../db/reset/await-storage-ready.unit.test.ts | 184 ++ .../legacy/commands/db/reset/reset.handler.ts | 118 +- .../db/reset/reset.integration.test.ts | 2472 ++++++++++------- .../legacy/commands/db/reset/reset.layers.ts | 13 +- .../db/shared/legacy-db-bootstrap.errors.ts | 22 - .../shared/legacy-db-bootstrap.seam.layer.ts | 166 -- .../legacy-db-bootstrap.seam.service.ts | 65 - .../db/shared/legacy-pgdelta.seam.layer.ts | 4 +- .../legacy/commands/db/start/SIDE_EFFECTS.md | 84 +- .../legacy/commands/db/start/start.handler.ts | 189 +- .../db/start/start.integration.test.ts | 2 +- .../legacy/commands/db/start/start.layers.ts | 12 +- .../src/legacy/commands/start/SIDE_EFFECTS.md | 22 +- .../start/services/edge-runtime.service.ts | 14 +- .../commands/start/services/gotrue.service.ts | 2 +- .../start/services/imgproxy.service.ts | 2 +- .../commands/start/services/kong.service.ts | 2 +- .../start/services/logflare.service.ts | 2 +- .../start/services/mailpit.service.ts | 2 +- .../start/services/pg-meta.service.ts | 2 +- .../start/services/postgrest.service.ts | 4 +- .../start/services/realtime.service.ts | 2 +- .../start/services/storage.service.ts | 2 +- .../commands/start/services/studio.service.ts | 2 +- .../start/services/supavisor.service.ts | 2 +- .../commands/start/services/vector.service.ts | 2 +- .../src/legacy/commands/start/start.gates.ts | 2 +- .../legacy/commands/start/start.handler.ts | 22 +- .../commands/start/start.integration.test.ts | 10 +- .../src/legacy/commands/stop/SIDE_EFFECTS.md | 2 +- .../container-lifecycle.ts | 173 +- .../container-lifecycle.unit.test.ts | 186 +- .../docker-create-args.ts | 4 +- .../docker-create-args.unit.test.ts | 0 .../health-check.ts | 0 .../health-check.unit.test.ts | 0 .../image-prepull.ts | 0 .../image-prepull.unit.test.ts | 0 .../pinned-image.ts | 0 .../legacy/shared/db-bootstrap/db-setup.ts | 390 ++- .../shared/db-bootstrap/db-setup.unit.test.ts | 10 +- .../db-bootstrap/local-container-inputs.ts | 248 ++ .../shared/db-bootstrap/local-db-running.ts | 15 +- .../shared/db-bootstrap/postgres.service.ts | 4 +- .../db-bootstrap/recreate-local-database.ts | 468 ++++ .../recreate-local-database.unit.test.ts | 103 + .../shared/db-bootstrap/restart-services.ts | 240 ++ .../restart-services.unit.test.ts | 288 ++ .../legacy/shared/db-bootstrap/rollback.ts | 2 +- .../shared/db-bootstrap/rollback.unit.test.ts | 2 +- .../shared/db-bootstrap/start-database.ts | 180 +- .../shared/legacy-bitbucket-pipeline.ts | 2 +- .../src/legacy/shared/legacy-container-cli.ts | 63 +- .../shared/legacy-docker-bind-classify.ts | 2 +- .../src/legacy/shared/legacy-docker-ids.ts | 2 +- .../cli/src/legacy/shared/legacy-kong-auth.ts | 2 +- .../shared/legacy-start-secrets-cleanup.ts | 2 +- apps/cli/src/shared/cli/run.ts | 20 +- apps/cli/src/shared/cli/run.unit.test.ts | 9 +- .../legacy/legacy-go-child-exit.error.ts | 4 +- 66 files changed, 4163 insertions(+), 2155 deletions(-) create mode 100644 apps/cli/src/legacy/commands/db/reset/await-storage-ready.ts create mode 100644 apps/cli/src/legacy/commands/db/reset/await-storage-ready.unit.test.ts delete mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts delete mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts delete mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts rename apps/cli/src/legacy/shared/{db-bootstrap => containers}/container-lifecycle.ts (85%) rename apps/cli/src/legacy/shared/{db-bootstrap => containers}/container-lifecycle.unit.test.ts (83%) rename apps/cli/src/legacy/shared/{db-bootstrap => containers}/docker-create-args.ts (99%) rename apps/cli/src/legacy/shared/{db-bootstrap => containers}/docker-create-args.unit.test.ts (100%) rename apps/cli/src/legacy/shared/{db-bootstrap => containers}/health-check.ts (100%) rename apps/cli/src/legacy/shared/{db-bootstrap => containers}/health-check.unit.test.ts (100%) rename apps/cli/src/legacy/shared/{db-bootstrap => containers}/image-prepull.ts (100%) rename apps/cli/src/legacy/shared/{db-bootstrap => containers}/image-prepull.unit.test.ts (100%) rename apps/cli/src/legacy/shared/{db-bootstrap => containers}/pinned-image.ts (100%) create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/restart-services.ts create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/restart-services.unit.test.ts 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 index 278b984341..d3fba9d7e7 100644 --- 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 @@ -16,12 +16,14 @@ import { testLive } from "./live-context.ts"; // Exercises `db start`'s native container-bootstrap sequence (network/volume/container // bring-up, health wait, the fresh-volume SetupLocalDatabase-equivalent pipeline, and // `_current_branch`) and `db reset --local`'s container-recreate flow end-to-end — the -// real-Docker boundary the in-process integration suites mock. `db reset --local` still -// delegates its container-recreate flow to the bundled Go binary's hidden -// `db __db-bootstrap --mode recreate` seam (CLI-1955, unclaimed as of CLI-1954); `db start` -// no longer does (see `commands/db/start/start.handler.ts`). 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. +// real-Docker boundary the in-process integration suites mock. Both are fully native TS +// now: `db reset --local`'s hidden Go `db __db-bootstrap` seam (`--mode recreate`/ +// `--mode await-storage`) was removed in CLI-1955 (see +// `commands/db/reset/reset.handler.ts` / `shared/db-bootstrap/recreate-local-database.ts`), +// the same way `db start`'s own seam usage was removed in CLI-1954 (see +// `commands/db/start/start.handler.ts`). 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", diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index f4c80517b1..df04364e44 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -270,66 +270,6 @@ var ( }, } - bootstrapMode string - bootstrapSqlPaths []string - bootstrapVersion string - bootstrapNoSeed bool - - // dbBootstrapCmd is a hidden seam used by the native-TypeScript `db reset --local` - // command to drive the container-bootstrap primitives that are not yet ported to - // TypeScript: recreating the local Postgres container, applying the initial - // schema, and the storage health gate. The TS caller orchestrates everything else - // (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"). `db start`'s own container - // bootstrap (--mode start) was removed from this seam by CLI-1954 — it is now a - // fully native TypeScript implementation - // (apps/cli/src/legacy/commands/db/start/start.handler.ts), reusing - // legacy/shared/db-bootstrap/'s already-ported container-bootstrap primitives - // instead of shelling out to this binary. `start.StartDatabase` itself (called - // below by the real, customer-facing `db start` Go command) is untouched — it - // remains the parity oracle this TS port was checked against. - 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 "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", @@ -680,13 +620,6 @@ 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", "recreate", "Bootstrap mode: recreate or await-storage.") - 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 7cfe42ff27..740423dc38 100644 --- a/apps/cli-go/internal/db/reset/reset.go +++ b/apps/cli-go/internal/db/reset/reset.go @@ -93,38 +93,6 @@ 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 ea5b297adc..6baa3892be 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` | `ported` | `legacy/commands/db/dump/` | `n/a` | `n/a` | Native TS port. Streams `pg_dump`/`pg_dumpall` via a Docker container (`LegacyDockerRun`); schema/data/role modes, `--dry-run` script print, IPv4 transaction-pooler fallback. | -| `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`. The initial-migra pull dumps the remote schema natively (`pg_dump`) then appends the migra diff. Only `--experimental` (structured dump) still delegates to Go, pending a TS PostgreSQL DDL parser for `format.WriteStructuredSchemas`. | -| `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 including decrypted `encrypted:` values; `--dry-run`; best-effort pg-delta migrations-catalog cache (warning-only on failure). | -| `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` | Fully native TS port (CLI-1954 removed the last Go delegation). Validates config, checks "already running" (prints Go's line, native `docker container inspect`), else natively brings up the Postgres container (network/volume/create/start via `legacy/shared/db-bootstrap/`'s shared primitives), waits for health, runs the fresh-volume `SetupLocalDatabase`-equivalent pipeline, and writes `_current_branch`. `--from-backup` is fully native too: a third entrypoint variant (schema.sql + a ported `restore.sh`, no `webhook.sql`), a `backup volume already exists` guard when the volume isn't fresh, and a swallowed (not failed) health-check timeout. No status table / `cli_stack_started` (those are `supabase start`). | -| `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` | No native TS implementation yet. Wrapped in legacy shell. | -| `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` | `ported` | `legacy/commands/db/dump/` | `n/a` | `n/a` | Native TS port. Streams `pg_dump`/`pg_dumpall` via a Docker container (`LegacyDockerRun`); schema/data/role modes, `--dry-run` script print, IPv4 transaction-pooler fallback. | +| `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`. The initial-migra pull dumps the remote schema natively (`pg_dump`) then appends the migra diff. Only `--experimental` (structured dump) still delegates to Go, pending a TS PostgreSQL DDL parser for `format.WriteStructuredSchemas`. | +| `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 including decrypted `encrypted:` values; `--dry-run`; best-effort pg-delta migrations-catalog cache (warning-only on failure). | +| `db reset` | `ported` | `legacy/commands/db/reset/` | `n/a` | `n/a` | Fully native TS port (CLI-1955 removed the last Go delegation on this command — the hidden `db __db-bootstrap` seam's `recreate`/`await-storage` modes no longer exist). Remote path: drop user schemas, vault upsert, MigrateAndSeed, `--version`/`--last`, `--sql-paths` seed override. Local path: running check, then a reset-specific PG14/PG15 recreate composition (`legacy/shared/db-bootstrap/recreate-local-database.ts`) over the same container-bootstrap primitives `db start` uses — container/volume remove-then-recreate (PG15) or a template1 `DROP`/`CREATE DATABASE` sequence + `InitSchema14`/`ApplyApiPrivileges` (PG14) — followed by a concurrent satellite-container restart (storage/auth/realtime/pooler) and a Kong `nginx` reload that FAILS the whole command on error (`legacy/shared/db-bootstrap/restart-services.ts`), then storage-gated bucket seeding (reuses `seed buckets`, native storage-health gate in `await-storage-ready.ts`) and the git-branch `Finished…` line. Only the niche `--experimental` schema-files path with no resolved version still delegates to the Go binary, and only for the REMOTE target (the local target's `--experimental` path is fully native via `legacyMigrateAndSeed`'s existing declarative-schema-files branch). Known, deliberate scope boundary: `db schema declarative`'s smart-target and `db schema sync` still spawn `db reset --local` through the Go binary's own real `reset.Run` (a wholly different, unrelated seam — `LegacyDeclarativeSeam.execInherit`), so Go is not fully removed from every `db reset --local` code path yet — see those two files' own comments. Accepted, documented divergence: the best-effort pg-delta migrations-catalog cache write (`pgcache.TryCacheMigrationsCatalog`, reachable via `SetupLocalDatabase` on the PG15 recreate path) is not ported, same as `db start`. | +| `db start` | `ported` | `legacy/commands/db/start/` | `n/a` | `n/a` | Fully native TS port (CLI-1954 removed the last Go delegation). Validates config, checks "already running" (prints Go's line, native `docker container inspect`), else natively brings up the Postgres container (network/volume/create/start via `legacy/shared/db-bootstrap/`'s shared primitives), waits for health, runs the fresh-volume `SetupLocalDatabase`-equivalent pipeline, and writes `_current_branch`. `--from-backup` is fully native too: a third entrypoint variant (schema.sql + a ported `restore.sh`, no `webhook.sql`), a `backup volume already exists` guard when the volume isn't fresh, and a swallowed (not failed) health-check timeout. No status table / `cli_stack_started` (those are `supabase start`). | +| `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` | No native TS implementation yet. Wrapped in legacy shell. | +| `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 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 81780b6ace..4438c8a397 100644 --- a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md @@ -4,22 +4,40 @@ Native TypeScript port of `apps/cli-go/internal/db/reset/reset.go`. Reinitialise 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. +pointing at the local stack) is ALSO fully native (CLI-1955 removed the hidden Go +`db __db-bootstrap` seam this used to delegate to): the running check, the PG14/PG15 +container-recreate composition (`legacy/shared/db-bootstrap/recreate-local-database.ts`, +reusing the same container-bootstrap primitives `db start` uses — see that command's +own `SIDE_EFFECTS.md`), the post-recreate satellite-restart + Kong reload +(`legacy/shared/db-bootstrap/restart-services.ts`), the storage-health gate +(`legacy/commands/db/reset/await-storage-ready.ts`), bucket seeding, and the +git-branch line are all native TS. Only the niche **`--experimental`** schema-files +path with no resolved version still delegates to the Go binary, and only for the +**remote** target — the local target's `--experimental` path is fully native (see +"Notes"). + +**Known, deliberate scope boundary** (not fixed by this port): `db schema declarative` +(the smart-target path) and `db schema sync` both still spawn `db reset --local` +through the Go binary's own real `reset.Run` command — a completely different, +unrelated seam (`LegacyDeclarativeSeam.execInherit`), not the one this document +describes. Those two call sites are unaffected by this port; making them call the +native `legacyDbReset` handler in-process instead is a larger, separate refactor, +tracked as a known follow-up rather than done here. ## Files Read -| Path | Format | When | -| ------------------------------------------------------ | ---------- | --------------------------------------------------------------------------------------------------------------------- | -| `/supabase/migrations/` | directory | to validate `--version` / resolve `--last`, and to load migrations | -| `/supabase/config.toml` | TOML | always, parsed up front before any destructive work (embedded defaults when absent); re-read for local bucket seeding | -| `/.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 | +| Path | Format | When | +| ----------------------------------------------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------- | +| `/supabase/migrations/` | directory | to validate `--version` / resolve `--last`, and to load migrations | +| `/supabase/config.toml` | TOML | always, parsed up front before any destructive work (embedded defaults when absent); re-read for local bucket seeding | +| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | always, resolved before the local prelude (config values, bootstrap config) | +| `/.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 | +| `/supabase/roles.sql` | SQL | local PG15 path only, via the reused `legacyStartSetupLocalDatabase` pipeline — missing file tolerated | +| `~/.docker/config.json` | JSON | via the `docker`/`podman` CLI itself, for registry auth — never read directly by this process | ## Files Written @@ -28,21 +46,25 @@ primitives run behind the hidden Go `db __db-bootstrap` seam. Only the niche | `~/.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. +On the local path, the native recreate additionally recreates the +`supabase_db_` container/volume (PG15) or the `postgres`/`_supabase` +databases in place (PG14), and applies the initial schema (`SetupLocalDatabase` +equivalent, PG15) or `InitSchema14`/`ApplyApiPrivileges` (PG14); 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. +| Command | When | Purpose | +| ----------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------- | +| `docker container inspect supabase_db_` | local path | `AssertSupabaseDbIsRunning` probe (Podman fallback) | +| `docker container rm -f supabase_db_` / `docker volume rm -f ` | local path, PG15 | remove the existing container/volume before recreating (Podman fallback) | +| `docker network create` / `docker volume create` / `docker create` / `docker start` | local path, PG15 | recreate the Postgres container (same primitives `db start` uses) | +| `docker run --rm ` | local path, PG15, per enabled service | the one-shot `initSchema15` migrate jobs (`legacyStartSetupLocalDatabase`) | +| `docker restart ` | local path, PG14 | `RestartDatabase` — pg_cron must restart after `pg_terminate_backend` | +| `docker restart ` | local path, both PG14 and PG15 | concurrent satellite-container restart, not-found tolerated per service | +| `docker container inspect ` + `docker exec kong reload` | local path, both PG14 and PG15 | reload Kong so it re-resolves the restarted containers' addresses (issue #6016) | +| `docker container inspect supabase_storage_` | local path | storage-health gate before bucket seeding | +| `supabase-go db reset --linked\|--db-url … [--no-seed]` | `--experimental` remote, no version | the un-ported experimental schema-files apply path (telemetry disabled) | ## Database Mutations @@ -55,18 +77,38 @@ The seam subprocesses run with `SUPABASE_TELEMETRY_DISABLED=1`, stderr inherited | 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, then reloads Kong -(`kong reload`, skipped when the gateway is absent or stopped) so its nginx +### Local path (native, in TS) + +**PG15+:** the container/volume are removed and recreated (see "Subprocesses"), then +the reused `legacyStartSetupLocalDatabase` pipeline runs the initial schema (as +one-shot Docker jobs, not SQL over a session), `ApplyApiPrivileges`, a vault upsert, +a `roles.sql` seed, and `MigrateAndSeed` (migrations `≤ --version`, seed unless +`--no-seed`) — over a fresh host-facing Postgres connection. + +**PG14:** connects as `supabase_admin` to `template1` and disconnects other clients +(`ALTER DATABASE ... ALLOW_CONNECTIONS false` ×2, `pg_terminate_backend`, then polls +`pg_replication_slots` on a 1-second backoff up to 10 times — a failure here is +swallowed unless it's a PgError whose code isn't `3D000`/`invalid_catalog_name`), then +runs four unwrapped statements: `DROP`/`CREATE DATABASE postgres`, `DROP`/`CREATE +DATABASE _supabase`. Reconnects as `supabase_admin` to `postgres` for the schema SQL +(`InitSchema14`, no `globals.sql` — deliberately different from `db start`'s own PG14 +path) + `ApplyApiPrivileges`. After the container itself is restarted (see below), +reconnects as `postgres`/`postgres` for `MigrateAndSeed` (migrations `≤ --version`, +seed unless `--no-seed`). + +**Both branches** then restart the storage/auth/realtime/pooler containers +concurrently (per-service "not found" tolerated, no health wait afterward — "those +services may be excluded from starting"), then reload Kong (`docker exec kong +reload`; skipped, not failed, when the gateway is absent or stopped) so its nginx re-resolves the restarted containers' addresses — otherwise routes to a moved -container keep returning 502 after the reset succeeds (issue #6016). Bucket -objects are then seeded over the Storage gateway (reusing the `seed buckets` -local path); the in-place reload keeps Kong serving throughout, so this never -races a restarting gateway. +container keep returning 502 after the reset succeeds (issue #6016). **A Kong reload +failure fails the WHOLE command** (unlike `functions serve`'s best-effort reload), +with an actionable `Suggestion:` line (`docker restart ` / `docker logs `). +Bucket objects are then seeded over the Storage gateway (reusing the `seed buckets` +local path), gated on a native storage-health check: absent (any inspect error, not +just "not found") skips buckets without failing; present-but-unhealthy waits up to a +**hardcoded 30 seconds** (independent of `db.health_timeout`) and, on timeout, **fails +the whole reset** (not just "skip buckets"). ## API Routes @@ -76,33 +118,36 @@ races a restarting gateway. ## Environment Variables -| 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 | +| 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 remote experimental schema-files path to Go; on the local path, applies `db.migrations.schema_paths` files instead of `migrations/*.sql` (native) | no (also `--experimental`) | +| `SUPABASE_PROJECT_ID` | overrides the local container id (`utils.DbId`) | no | +| `SUPABASE_DB_PORT` / `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | local-path container-recreate config overrides, same as `db start` | no | +| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the recreated container/network onto an existing Docker network | no | ## Exit Codes -| 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 | -| child's exact code\* | local: container recreate / storage health-gate failure (seam), or `--experimental`/`--linked` delegate (proxy) child exit | - -\* The `db __db-bootstrap` seam and the `--experimental` remote delegate both -propagate the spawned `supabase-go` child's real exit code (e.g. `130` after a -Ctrl-C mid-recreate) instead of collapsing every failure to `1` — in every -`--output-format` (CLI-1879). +| 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/volume remove, network/volume/container create, health-check timeout, PG14 SQL, satellite-restart, or Kong-reload failure | +| child's exact code\* | `--experimental`/`--linked` remote delegate (proxy) child exit | + +\* The `--experimental` remote delegate propagates the spawned `supabase-go` child's +real exit code (e.g. `130` after a Ctrl-C) instead of collapsing every failure to `1` +— in every `--output-format` (CLI-1879). The local path has no Go child at all +anymore (CLI-1955) — every local failure is a native, typed TS error. ## Output @@ -111,10 +156,10 @@ drop/migrate/seed progress (`Applying migration …`, `Seeding data from …`). 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). +The local path prints `Resetting local database…` to **stderr**, then +`Recreating database...` (PG15) or nothing extra (PG14, until the restart step) / +`Restarting containers...` progress, and finally `Finished supabase db reset on +branch .` (`supabase db reset` and `` in Aqua). ### `--output-format text` (Go CLI compatible) @@ -139,15 +184,35 @@ path has no confirmation prompt. - **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. + local path it feeds `legacyResolveResetSeedConfig`, applied on top of the loaded + `[db.seed]` config inside the recreate's own `MigrateAndSeed` step (same override + logic on both PG14 and PG15). - `--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`). + it is applied the same way as `--no-seed` above; 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. +- The local target's `--experimental` schema-files path (no resolved version, no + pg-delta) is fully native: it was never actually delegated even before this port + (the removed seam forwarded `--experimental` straight through to its own Go child), + and `legacyMigrateAndSeed` (reused by both PG14 and PG15) already implements Go's + `apply.MigrateAndSeed` declarative-schema-files branch. +- **Accepted, documented divergence**: the best-effort pg-delta migrations-catalog + cache write (`pgcache.TryCacheMigrationsCatalog`, reachable from the PG15 recreate + via `SetupLocalDatabase`) is not ported — same accepted gap as `db start`. This is a + performance-only gap (the next pg-delta-enabled `db push`/`db schema declarative` + re-extracts the catalog itself instead of reusing a freshly-primed cache), not a + correctness or observable-output one (the write is silent on success, warning-only + on failure in Go). Porting it would require wiring `legacyEdgeRuntimeScriptLayer` + + `legacyPgDeltaSslProbeLayer` into `db reset`'s runtime purely for this optional, + feature-flagged step — left as an explicit follow-up rather than silently dropped. +- `encrypted:` vault secrets are skipped on the remote path. +- **Known, deliberate scope boundary**: `db schema declarative`/`db schema sync` still + invoke `db reset --local` via the Go binary's own real `reset.Run` command (a + different seam, `LegacyDeclarativeSeam.execInherit`) — untouched by this port. A + follow-up would need `legacyDbReset`'s core extracted into an in-process-callable + function (it currently reads `CliArgs` directly and owns its own telemetry/ + linked-project-cache finalizers), materially larger in scope than this change. diff --git a/apps/cli/src/legacy/commands/db/reset/await-storage-ready.ts b/apps/cli/src/legacy/commands/db/reset/await-storage-ready.ts new file mode 100644 index 0000000000..93dfdcdffd --- /dev/null +++ b/apps/cli/src/legacy/commands/db/reset/await-storage-ready.ts @@ -0,0 +1,62 @@ +/** + * Port of Go's `AwaitStorageReady` (`apps/cli-go/internal/db/reset/reset.go:115-126`) — + * the storage-health gate local `db reset` runs before seeding buckets. Two things the + * seam this replaces got subtly wrong, corrected here: + * + * 1. `resp, err := utils.Docker.ContainerInspect(ctx, utils.StorageId); if err != nil { + * return false, nil }` — ANY inspect error (not just "not found") maps to "absent" + * (`false`), matching Go exactly (`errdefs.IsNotFound` is never checked on this + * particular path). + * 2. `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 } }` — a container that EXISTS but is unhealthy (or has no + * healthcheck at all) triggers a real 30-SECOND wait, hardcoded independent of + * `db.health_timeout`; if that wait times out, the failure propagates and FAILS THE + * WHOLE RESET (not just "skip buckets") — dumping the storage container's logs to + * stderr on the way out, via `legacyWaitForHealthyServices`'s own existing behavior. + * + * Lives here (not `legacy/shared/db-bootstrap/`) since `db reset`'s own handler is its + * only caller — the bucket-seeding health gate has no equivalent in `db start`/`supabase + * start` at all (CLI-1955 review follow-up). + */ + +import { Effect, Result } from "effect"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import { legacyInspectContainerState } from "../../../shared/legacy-docker-lifecycle.ts"; +import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; +import { + legacyWaitForHealthyServices, + type LegacyHealthCheckTimeoutError, +} from "../../../shared/containers/health-check.ts"; + +type Spawner = ChildProcessSpawner["Service"]; + +/** Go's hardcoded `30*time.Second` (`reset.go:121`) — independent of `db.health_timeout`. */ +const LEGACY_AWAIT_STORAGE_READY_TIMEOUT_SECONDS = 30; + +/** + * Resolves `true` when the storage container exists (so the caller should run the + * ported bucket-seeding core) and `false` when it does not (any inspect error) — + * matching Go, which silently skips buckets when storage is absent. Fails with + * {@link LegacyHealthCheckTimeoutError} when storage exists but never becomes healthy + * within 30 seconds — this is NOT swallowed into `false`, matching Go's own + * `return false, err` propagating the wait's error to the caller, which fails the + * entire reset. + */ +export function legacyAwaitStorageReady( + spawner: Spawner, + projectId: string, +): Effect.Effect { + const storageId = legacyServiceContainerName("storage", projectId); + return Effect.gen(function* () { + const inspected = yield* legacyInspectContainerState(spawner, storageId).pipe(Effect.result); + if (Result.isFailure(inspected)) return false; + if (inspected.success.health === "healthy") return true; + yield* legacyWaitForHealthyServices(spawner, [storageId], { + timeoutSeconds: LEGACY_AWAIT_STORAGE_READY_TIMEOUT_SECONDS, + }); + return true; + }); +} diff --git a/apps/cli/src/legacy/commands/db/reset/await-storage-ready.unit.test.ts b/apps/cli/src/legacy/commands/db/reset/await-storage-ready.unit.test.ts new file mode 100644 index 0000000000..9dd2ccd6de --- /dev/null +++ b/apps/cli/src/legacy/commands/db/reset/await-storage-ready.unit.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Deferred, Effect, Exit, Fiber, Layer, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as TestClock from "effect/testing/TestClock"; + +import { LegacyHealthCheckTimeoutError } from "../../../shared/containers/health-check.ts"; +import { legacyAwaitStorageReady } from "./await-storage-ready.ts"; + +const unusedHttpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("HttpClient should not be called for a plain container check")), +); + +function mockSpawner( + handler: (args: ReadonlyArray) => { exitCode: number; stdout?: string; stderr?: string }, +) { + const spawned: Array> = []; + + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push(args); + const result = handler(args); + + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(result.exitCode)); + + const encoder = new TextEncoder(); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.fromIterable( + result.stdout !== undefined ? [encoder.encode(result.stdout)] : [], + ), + stderr: Stream.fromIterable( + result.stderr !== undefined ? [encoder.encode(result.stderr)] : [], + ), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + return { + spawner, + get spawned() { + return spawned; + }, + }; +} + +const HEALTHY_STATE = '{"Running":true,"Status":"running","Health":{"Status":"healthy"}}'; +const STARTING_STATE = '{"Running":true,"Status":"running","Health":{"Status":"starting"}}'; + +describe("legacyAwaitStorageReady", () => { + it.live("resolves true immediately when storage already reports healthy", () => { + const mock = mockSpawner(() => ({ exitCode: 0, stdout: HEALTHY_STATE })); + return legacyAwaitStorageReady(mock.spawner, "proj").pipe( + Effect.provide(unusedHttpClientLayer), + Effect.map((ready) => { + expect(ready).toBe(true); + // No `docker logs`/extra polling round needed — just the one inspect. + expect(mock.spawned).toHaveLength(1); + }), + ); + }); + + it.live('resolves false on ANY inspect error — not just a confirmed "not found"', () => { + const mock = mockSpawner(() => ({ + exitCode: 1, + stderr: "Cannot connect to the Docker daemon\n", + })); + return legacyAwaitStorageReady(mock.spawner, "proj").pipe( + Effect.provide(unusedHttpClientLayer), + Effect.map((ready) => { + expect(ready).toBe(false); + }), + ); + }); + + it.live('resolves false when storage genuinely does not exist ("No such container")', () => { + const mock = mockSpawner(() => ({ + exitCode: 1, + stderr: "Error: No such container: supabase_storage_proj\n", + })); + return legacyAwaitStorageReady(mock.spawner, "proj").pipe( + Effect.provide(unusedHttpClientLayer), + Effect.map((ready) => { + expect(ready).toBe(false); + }), + ); + }); + + it.effect( + "waits up to the hardcoded 30s for an unhealthy-but-present container, then succeeds", + () => + Effect.gen(function* () { + let calls = 0; + const mock = mockSpawner((args) => { + if (args[0] === "container" && args[1] === "inspect") { + calls++; + return { exitCode: 0, stdout: calls === 1 ? STARTING_STATE : HEALTHY_STATE }; + } + return { exitCode: 0 }; + }); + + const fiber = yield* legacyAwaitStorageReady(mock.spawner, "proj").pipe( + Effect.provide(unusedHttpClientLayer), + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("1 seconds"); + const exit = yield* Fiber.await(fiber); + + expect(Exit.isSuccess(exit)).toBe(true); + if (Exit.isSuccess(exit)) expect(exit.value).toBe(true); + }), + ); + + it.effect( + "FAILS THE WHOLE RESET (not just 'skip buckets') when storage never becomes healthy within 30s", + () => + Effect.gen(function* () { + const mock = mockSpawner((args) => { + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: STARTING_STATE }; + } + return { exitCode: 0 }; + }); + + const fiber = yield* legacyAwaitStorageReady(mock.spawner, "proj").pipe( + Effect.provide(unusedHttpClientLayer), + Effect.forkChild({ startImmediately: true }), + ); + // Go's hardcoded 30-second wait (`start.WaitForHealthyService(ctx, 30*time.Second, + // utils.StorageId)`, reset.go:121) — 30 retries after the initial attempt. + for (let i = 0; i < 30; i++) { + yield* TestClock.adjust("1 seconds"); + } + const exit = yield* Fiber.await(fiber); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(LegacyHealthCheckTimeoutError); + } + }), + ); + + it.effect( + "is still retrying after 29 seconds, but fails once the 30th second is exhausted — pins Go's hardcoded 30s constant", + () => + Effect.gen(function* () { + const mock = mockSpawner((args) => { + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: STARTING_STATE }; + } + return { exitCode: 0 }; + }); + + const fiber = yield* legacyAwaitStorageReady(mock.spawner, "proj").pipe( + Effect.provide(unusedHttpClientLayer), + Effect.forkChild({ startImmediately: true }), + ); + + for (let i = 0; i < 29; i++) { + yield* TestClock.adjust("1 seconds"); + } + // Not yet exhausted — 29 retries is one short of the hardcoded 30-second cap. If this + // constant were ever accidentally shortened (e.g. to 3s), the fiber would already be + // done here, failing this assertion instead of silently passing. + expect(fiber.pollUnsafe()).toBeUndefined(); + + // The 30th second crosses the boundary. + yield* TestClock.adjust("1 seconds"); + const exit = yield* Fiber.await(fiber); + expect(Exit.isFailure(exit)).toBe(true); + }), + ); +}); 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 e493e2280f..dfa9f1ef96 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts @@ -3,7 +3,10 @@ import { ChildProcessSpawner } from "effect/unstable/process"; 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 { + LegacyNetworkIdFlag, + LegacyDnsResolverFlag, +} from "../../../../shared/legacy/global-flags.ts"; import { legacyResolveExperimentalWithProjectEnv, legacyResolveYesWithProjectEnv, @@ -11,14 +14,19 @@ import { import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import { CONTEXT_CANCELED_MESSAGE } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { legacyAqua, legacyYellow } from "../../../shared/legacy-colors.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { legacyBuildLocalDbContainerInputs } from "../../../shared/db-bootstrap/local-container-inputs.ts"; +import { legacyAwaitStorageReady } from "./await-storage-ready.ts"; +import { legacyResolveResetSeedConfig } from "../../../shared/db-bootstrap/db-setup.ts"; +import { legacyIsLocalDbRunning } from "../../../shared/db-bootstrap/local-db-running.ts"; +import { legacyRecreateLocalDatabase } from "../../../shared/db-bootstrap/recreate-local-database.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import { legacyCheckDbToml, legacyLoadProjectEnv, - legacyResolveSeedSqlPath, } from "../../../shared/legacy-db-config.toml-read.ts"; import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; import { legacyApplyMigrations } from "../../../shared/legacy-migration-apply.ts"; @@ -31,8 +39,6 @@ import { 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 { legacyIsLocalDbRunning } from "../../../shared/db-bootstrap/local-db-running.ts"; import { legacyListLocalMigrations } from "../shared/legacy-pgdelta.cache.ts"; import { legacyGetPendingSeeds, legacySeedData } from "../shared/legacy-seed-ops.ts"; import { legacyPathMatch } from "../../../shared/legacy-path-match.ts"; @@ -97,24 +103,32 @@ const buildResetArgs = ( * `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). + * (`--linked` / a remote `--db-url`) is native. The local path's container-recreate + * primitives are ALSO native now (`legacyRecreateLocalDatabase`/`legacyAwaitStorageReady`, + * `legacy/shared/db-bootstrap/`) — the hidden `db __db-bootstrap` Go seam this used to + * delegate to (CLI-1325 Stage 3's documented interim) is gone (CLI-1955). Only the + * REMOTE target's niche `--experimental` schema-files path with NO resolved version + * still delegates to the Go binary (`shouldDelegateExperimental`) — the LOCAL target + * never delegated this at all (the removed seam forwarded `--experimental` straight + * through to its own Go child), and stays fully native on this path too: + * `legacyMigrateAndSeed` (reused by both the PG14 and PG15 recreate branches) already + * implements Go's `apply.MigrateAndSeed` experimental-schema-files branch. */ 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 spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeInfo = yield* RuntimeInfo; const cliArgs = yield* CliArgs; const dnsResolver = yield* LegacyDnsResolverFlag; + const networkIdFlag = yield* LegacyNetworkIdFlag; const workdir = cliConfig.workdir; const migrationsDir = path.join(workdir, "supabase", "migrations"); @@ -294,10 +308,8 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega 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`. + // Local target → native local reset (CLI-1955: the hidden Go `db __db-bootstrap` + // seam is gone). Mirrors `internal/db/reset/reset.go:57-77`. if (cfg.isLocal) { // Go's `flags.LoadConfig` (root `PersistentPreRunE` → the local target's // per-connType `LoadConfig`, `internal/utils/flags/db_url.go:77-80`) runs full @@ -331,16 +343,60 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega } // resetDatabase: "Resetting local database…" then recreate + migrate + seed. yield* output.raw(`Resetting local database${toLogMessage(resolvedVersion)}\n`, "stderr"); - yield* seam.recreateDatabase({ + + // Build the SAME prelude `db start`'s own handler builds (config values + + // `legacyResolveDbBootstrapConfig`) — Go's `resetDatabase15`/`resetDatabase14` + // recreate the `db` container with byte-identical inputs to `StartDatabase`'s own. + const inputs = yield* legacyBuildLocalDbContainerInputs( + spawner, + workdir, + networkIdFlag, + runtimeInfo.platform, + ); + const { + context: { projectId, hostname }, + values, + bootstrapConfig, + networkId, + containerOpts, + dbContainerId, + postgresSpecBase, + resolvePostgresImage, + setup, + } = inputs; + + yield* legacyRecreateLocalDatabase(spawner, { + fs, + path, + workdir, + projectId, + networkId, + hostname, + dbContainerId, + dbPort: values.dbPort, + containerOpts, + // `db reset` has no `fromBackup` concept at all, so `postgresSpecBase` — the + // exact same fields `db start` splices its own `fromBackup` on top of — is + // already this composition's WHOLE `postgresSpec`. + postgresSpec: postgresSpecBase, + resolvePostgresImage, + dbHealthTimeoutSeconds: bootstrapConfig.dbHealthTimeoutSeconds, version: resolvedVersion, - noSeed: flags.noSeed, - sqlPaths: flags.sqlPaths, + seedFlags: { noSeed: flags.noSeed, sqlPaths: flags.sqlPaths }, + // `db reset` resolves `--experimental` EARLIER than this prelude (it gates the + // remote-target Go-delegation decision too, reached before `cfg.isLocal` is even + // known) via the Go-parity nested-env walk (`legacyResolveExperimentalWithProjectEnv` + // over `projectEnv`, above) — override the prelude's OWN `setup.experimental` (resolved + // from its `@supabase/config`-backed context instead) with that earlier value, to + // preserve this pre-existing divergence exactly. See `legacyBuildLocalDbContainerInputs`'s + // own header. + setup: { ...setup, experimental }, }); // 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(); + const storageReady = yield* legacyAwaitStorageReady(spawner, projectId); if (storageReady) { // Go's `buckets.Run(ctx, "", false, fsys)` — non-interactive: overwrite/prune // confirmations take their defaults instead of blocking on input. @@ -460,19 +516,23 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega // `--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). - const overrideSeed = flags.sqlPaths.length > 0; - // `--sql-paths` force-enables seeding (Go's applyDbResetSeedFlags); otherwise - // honor `db.seed.enabled` (already `SUPABASE_DB_SEED_ENABLED`-resolved by the reader). - const seedEnabled = overrideSeed || (toml.seed.enabled && !flags.noSeed); - if (seedEnabled) { - // `[db.seed].sql_paths` is already Go-config-resolved (supabase/-joined) by the - // reader; the `--sql-paths` override is resolved here the same way Go's - // `resolveSeedSqlPaths` does, so both feed the glob identical paths. - const seedPaths = overrideSeed - ? flags.sqlPaths.map((p) => legacyResolveSeedSqlPath(path, p)) - : toml.seed.sqlPaths; - const seeds = yield* legacyGetPendingSeeds(session, fs, path, seedPaths, workdir); + // exclusive (validated above). Same single home as the local path's identical + // override (`legacyResolveResetSeedConfig`, `db-setup.ts`) — one implementation + // of Go's `applyDbResetSeedFlags` for both targets, per "Hoist Before You + // Duplicate" (`apps/cli/CLAUDE.md`). + const resolvedSeed = legacyResolveResetSeedConfig( + toml.seed, + { noSeed: flags.noSeed, sqlPaths: flags.sqlPaths }, + path, + ); + if (resolvedSeed.enabled) { + const seeds = yield* legacyGetPendingSeeds( + session, + fs, + path, + resolvedSeed.sqlPaths, + workdir, + ); yield* legacySeedData(session, fs, workdir, path, seeds, applyError); } // Go's best-effort pgcache catalog warning is not ported (no output impact). 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 79b1bc935d..46b551738b 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 @@ -10,6 +10,7 @@ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { mockOutput, + mockProcessControl, mockRuntimeInfo, mockStdin, mockTty, @@ -29,11 +30,13 @@ import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyDnsResolverFlag, LegacyExperimentalFlag, + LegacyNetworkIdFlag, LegacyYesFlag, } from "../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import { LegacyGoChildExitError } from "../../../../shared/legacy/legacy-go-child-exit.error.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; +import { legacyDockerRunLayer } from "../../../shared/legacy-docker-run.layer.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import type { LegacyDbConfigFlags, @@ -45,13 +48,14 @@ 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"; 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 COUNT_REPLICATION_SLOTS = + "SELECT COUNT(*) FROM pg_replication_slots WHERE database IN ('postgres', '_supabase')"; const CONN: LegacyPgConnInput = { host: "db.example.supabase.co", @@ -117,9 +121,28 @@ function mockResolver(opts: { }; } -function mockConnection(opts: { remoteSeeds?: Readonly> }) { +/** + * A single `LegacyDbConnection` mock shared by BOTH the remote path (tracks + * `execs`/`queries` for the drop-schema/migrate/seed assertions) and the native + * local recreate path (the PG14 branch's `session.exec`/`.query` calls) — + * `legacyDbReset` composes exactly one `LegacyDbConnection` layer, so tests must + * not register two competing ones (the second would silently shadow the first + * in `Layer.mergeAll`). + */ +function mockConnection( + opts: { + remoteSeeds?: Readonly>; + /** Sequence of `pg_replication_slots` counts returned on successive polls (defaults to `[0]` — drains immediately). */ + replicationSlotCounts?: ReadonlyArray; + /** Makes the `pg_replication_slots` COUNT query itself fail (permanent, non-retryable). */ + replicationSlotQueryFails?: boolean; + /** Fails one exact statement with the given SQLSTATE `code` (or no code, for a non-PgError failure). */ + failStatement?: { readonly sql: string; readonly code?: string; readonly message: string }; + } = {}, +) { const execs: Array = []; const queries: Array<{ sql: string; params?: ReadonlyArray }> = []; + let replicationCallIndex = 0; const layer = Layer.succeed(LegacyDbConnection, { connect: () => Effect.succeed({ @@ -127,8 +150,17 @@ function mockConnection(opts: { remoteSeeds?: Readonly> } copyToCsv: () => Effect.succeed(new Uint8Array()), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), exec: (sql: string): Effect.Effect => - Effect.sync(() => { + Effect.suspend((): Effect.Effect => { execs.push(sql); + if (opts.failStatement !== undefined && sql === opts.failStatement.sql) { + return Effect.fail( + new LegacyDbExecError({ + message: opts.failStatement.message, + code: opts.failStatement.code, + }), + ); + } + return Effect.void; }), query: ( sql: string, @@ -143,6 +175,15 @@ function mockConnection(opts: { remoteSeeds?: Readonly> } ); } if (sql === LIST_MIGRATIONS) return Effect.succeed([]); + if (sql === COUNT_REPLICATION_SLOTS) { + if (opts.replicationSlotQueryFails === true) { + return Effect.fail(new LegacyDbExecError({ message: "connection reset" })); + } + const counts = opts.replicationSlotCounts ?? [0]; + const count = counts[Math.min(replicationCallIndex, counts.length - 1)] ?? 0; + replicationCallIndex++; + return Effect.succeed([{ count: String(count) }]); + } return Effect.succeed([]); }, ), @@ -160,73 +201,78 @@ function mockConnection(opts: { remoteSeeds?: Readonly> } } /** - * Stateful mock of the container-bootstrap seam. `storageReady` drives the - * bucket-seed gate. Records the recreate args so tests can assert version / - * `--no-seed` propagation. `awaitStorageReadyExitCode`, when set, fails - * `awaitStorageReady` with a `LegacyGoChildExitError` carrying that code — - * simulating the seam's real `captureStdout` bootstrap-child path exiting - * non-zero (CLI-1879). `AssertSupabaseDbIsRunning` no longer lives on this seam — - * see `mockRunningCheckSpawner` below (CLI-1954 hoisted it to - * `legacyIsLocalDbRunning`, a native `docker container inspect`). + * `execCaptureExitCode`, when set, makes `execCapture` fail with a + * `LegacyGoChildExitError` carrying that code instead of succeeding — simulating + * a delegated Go child exiting non-zero under a machine-output mode (CLI-1879). */ -function mockBootstrapSeam(opts: { storageReady?: boolean; awaitStorageReadyExitCode?: number }) { - const recreateCalls: Array<{ - version: string; - noSeed: boolean; - sqlPaths: ReadonlyArray; - }> = []; - let storageChecked = false; - const layer = Layer.succeed(LegacyDbBootstrapSeam, { - recreateDatabase: (args: { - version: string; - noSeed: boolean; - sqlPaths: ReadonlyArray; - }) => +function mockProxy(opts: { execCaptureExitCode?: number } = {}) { + const calls: Array<{ args: ReadonlyArray; env?: Record }> = []; + const layer = Layer.succeed(LegacyGoProxy, { + exec: (args, execOpts) => Effect.sync(() => { - recreateCalls.push(args); + calls.push({ args, env: execOpts?.env }); }), - awaitStorageReady: () => + execCapture: (args, execOpts) => Effect.sync(() => { - storageChecked = true; + calls.push({ args, env: execOpts?.env }); }).pipe( Effect.flatMap(() => - opts.awaitStorageReadyExitCode !== undefined + opts.execCaptureExitCode !== undefined ? Effect.fail( new LegacyGoChildExitError({ - exitCode: opts.awaitStorageReadyExitCode, - message: `failed to bootstrap the local database: exit ${opts.awaitStorageReadyExitCode}`, + exitCode: opts.execCaptureExitCode, + message: `supabase-go exited with code ${opts.execCaptureExitCode}`, }), ) - : Effect.succeed(opts.storageReady ?? false), + : Effect.succeed(""), ), ), }); return { layer, - get recreateCalls() { - return recreateCalls; - }, - get storageChecked() { - return storageChecked; + get calls() { + return calls; }, }; } -/** - * Mock `ChildProcessSpawner` backing `legacyIsLocalDbRunning`'s `docker container - * inspect` — the local reset path's only real subprocess call (the recreate / - * storage-health primitives stay behind the mocked seam above). `running` (default - * `true`, matching the seam-hosted mock's own former default) drives - * `AssertSupabaseDbIsRunning`: a healthy inspect when `true`, a "no such container" - * failure when `false`. - */ -function mockRunningCheckSpawner(opts: { running?: boolean } = {}) { - const running = opts.running ?? true; +// --------------------------------------------------------------------------- +// Native local-reset harness — mirrors `db/start/start.integration.test.ts`'s own +// `mockContainerCliSpawner`/`defaultRoute`/`fakeDbSession`, adapted for reset's +// container-REMOVE-then-recreate flow (rather than start's volume-existence probe) +// and its post-recreate satellite-restart + Kong-reload step. +// --------------------------------------------------------------------------- + +const PROJECT_ID = "test"; +const DB_ID = `supabase_db_${PROJECT_ID}`; +const KONG_ID = `supabase_kong_${PROJECT_ID}`; +const STORAGE_ID = `supabase_storage_${PROJECT_ID}`; + +const HEALTHY_STATE = '{"Running":true,"Status":"running","Health":{"Status":"healthy"}}'; +const STARTING_STATE = '{"Running":true,"Status":"running","Health":{"Status":"starting"}}'; +const STOPPED_STATE = '{"Running":false,"Status":"exited"}'; + +interface SpawnRecord { + readonly args: ReadonlyArray; +} + +type RouteResult = { + readonly exitCode?: number; + readonly stdout?: ReadonlyArray; + readonly stderr?: ReadonlyArray; +}; + +function mockContainerCliSpawner(route: (args: ReadonlyArray) => RouteResult) { + const spawned: Array = []; const encoder = new TextEncoder(); + const layer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => Effect.gen(function* () { + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push({ args }); + if (command._tag !== "StandardCommand") { return yield* Effect.fail( PlatformError.systemError({ @@ -237,13 +283,16 @@ function mockRunningCheckSpawner(opts: { running?: boolean } = {}) { }), ); } - const stderrLines = running ? [] : ["Error: No such container: supabase_db_test"]; + + const result = route(args); + const stdoutBytes = (result.stdout ?? []).map((line) => encoder.encode(`${line}\n`)); + const stderrBytes = (result.stderr ?? []).map((line) => encoder.encode(`${line}\n`)); return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(7000), - stdout: Stream.empty, - stderr: Stream.fromIterable(stderrLines.map((line) => encoder.encode(`${line}\n`))), + pid: ChildProcessSpawner.ProcessId(6000 + spawned.length), + stdout: Stream.fromIterable(stdoutBytes), + stderr: Stream.fromIterable(stderrBytes), all: Stream.empty, - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(running ? 0 : 1)), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode ?? 0)), isRunning: Effect.succeed(false), stdin: Sink.drain, kill: () => Effect.void, @@ -254,55 +303,117 @@ function mockRunningCheckSpawner(opts: { running?: boolean } = {}) { }), ), ); - return { layer }; -} - -// 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 }))), - ), -); -/** - * `execCaptureExitCode`, when set, makes `execCapture` fail with a - * `LegacyGoChildExitError` carrying that code instead of succeeding — simulating - * a delegated Go child exiting non-zero under a machine-output mode (CLI-1879). - */ -function mockProxy(opts: { execCaptureExitCode?: number } = {}) { - const calls: Array<{ args: ReadonlyArray; env?: Record }> = []; - const layer = Layer.succeed(LegacyGoProxy, { - exec: (args, execOpts) => - Effect.sync(() => { - calls.push({ args, env: execOpts?.env }); - }), - execCapture: (args, execOpts) => - Effect.sync(() => { - calls.push({ args, env: execOpts?.env }); - }).pipe( - Effect.flatMap(() => - opts.execCaptureExitCode !== undefined - ? Effect.fail( - new LegacyGoChildExitError({ - exitCode: opts.execCaptureExitCode, - message: `supabase-go exited with code ${opts.execCaptureExitCode}`, - }), - ) - : Effect.succeed(""), - ), - ), - }); return { layer, - get calls() { - return calls; + get spawned() { + return spawned; }, }; } +function containerNameFromCreateArgs(args: ReadonlyArray): string { + const nameIndex = args.indexOf("--name"); + return nameIndex !== -1 ? (args[nameIndex + 1] ?? "unknown") : "unknown"; +} + +function fakeContainerId(name: string): string { + return [...name] + .map((char) => (char.codePointAt(0) ?? 0).toString(16).padStart(2, "0")) + .join("") + .padEnd(64, "0") + .slice(0, 64); +} + +const createArgs = (spawned: ReadonlyArray): ReadonlyArray | undefined => + spawned.find((s) => s.args[0] === "create")?.args; + +// `docker container rm -f ` / `docker volume rm -f ` — the target is +// argv[3] (after the `-f` flag at argv[2]), not argv[2] itself. +const removedContainers = (spawned: ReadonlyArray): ReadonlyArray => + spawned + .filter((s) => s.args[0] === "container" && s.args[1] === "rm") + .map((s) => s.args[3] ?? ""); + +const removedVolumes = (spawned: ReadonlyArray): ReadonlyArray => + spawned.filter((s) => s.args[0] === "volume" && s.args[1] === "rm").map((s) => s.args[3] ?? ""); + +const restartedContainers = (spawned: ReadonlyArray): ReadonlyArray => + spawned.filter((s) => s.args[0] === "restart").map((s) => s.args[1] ?? ""); + +const kongReloadCalls = (spawned: ReadonlyArray): ReadonlyArray => + spawned.filter((s) => s.args[0] === "exec" && s.args[1] === KONG_ID); + +/** The three PG15+ one-shot migrate jobs (`legacyStartSetupLocalDatabase`'s `LegacyDockerRun` calls). */ +const dbSetupJobCalls = (spawned: ReadonlyArray): ReadonlyArray => + spawned.filter((s) => s.args[0] === "run" && s.args[1] === "--rm"); + +interface DefaultRouteOpts { + readonly running?: boolean; + readonly neverHealthy?: boolean; + readonly kongMissing?: boolean; + readonly kongNotRunning?: boolean; + readonly kongReloadFails?: boolean; + readonly storageMissing?: boolean; + readonly storageUnhealthy?: boolean; + readonly restartFails?: ReadonlyArray; +} + +function defaultLocalResetRoute(opts: DefaultRouteOpts = {}) { + return (args: ReadonlyArray): RouteResult => { + if (args[0] === "image" && args[1] === "inspect") return { exitCode: 0 }; + if (args[0] === "context" && args[1] === "inspect") return { exitCode: 1 }; + if (args[0] === "container" && args[1] === "rm") return { exitCode: 0 }; + if (args[0] === "volume" && args[1] === "rm") return { exitCode: 0 }; + if (args[0] === "network" && args[1] === "create") return { exitCode: 0 }; + if (args[0] === "volume" && args[1] === "create") return { exitCode: 0 }; + if (args[0] === "create") { + const name = containerNameFromCreateArgs(args); + return { stdout: [fakeContainerId(name)] }; + } + if (args[0] === "start") return { exitCode: 0 }; + if (args[0] === "restart") { + const id = args[1] ?? ""; + if (opts.restartFails?.includes(id) === true) { + return { exitCode: 1, stderr: [`Error: failed to restart ${id}`] }; + } + return { exitCode: 0 }; + } + if (args[0] === "exec" && args[1] === KONG_ID) { + return opts.kongReloadFails === true + ? { exitCode: 1, stderr: ["reload failed"] } + : { exitCode: 0 }; + } + if (args[0] === "container" && args[1] === "inspect") { + const id = args[2] ?? ""; + if (id === KONG_ID) { + if (opts.kongMissing === true) + return { exitCode: 1, stderr: [`Error: No such container: ${id}`] }; + return { stdout: [opts.kongNotRunning === true ? STOPPED_STATE : HEALTHY_STATE] }; + } + if (id === STORAGE_ID) { + if (opts.storageMissing === true) + return { exitCode: 1, stderr: [`Error: No such container: ${id}`] }; + return { stdout: [opts.storageUnhealthy === true ? STARTING_STATE : HEALTHY_STATE] }; + } + if (opts.running === false) + return { exitCode: 1, stderr: [`Error: No such container: ${id}`] }; + if (opts.neverHealthy === true) return { stdout: [STARTING_STATE] }; + return { stdout: [HEALTHY_STATE] }; + } + if (args[0] === "logs") return { exitCode: 0 }; + if (args[0] === "ps") return { stdout: [] }; + return { exitCode: 0 }; + }; +} + +const alwaysReadyHttpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))), + ), +); + function setup( workdir: string, opts: { @@ -318,10 +429,13 @@ function setup( yes?: boolean; omitRef?: boolean; resolveFails?: boolean; - running?: boolean; - storageReady?: boolean; - awaitStorageReadyExitCode?: number; execCaptureExitCode?: number; + // Local-reset-only knobs. + route?: (args: ReadonlyArray) => RouteResult; + routeOpts?: DefaultRouteOpts; + replicationSlotCounts?: ReadonlyArray; + replicationSlotQueryFails?: boolean; + failStatement?: { readonly sql: string; readonly code?: string; readonly message: string }; }, ) { if (opts.toml !== undefined) { @@ -337,11 +451,6 @@ function setup( const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm }); const conn = mockConnection(opts); const proxy = mockProxy({ execCaptureExitCode: opts.execCaptureExitCode }); - const seam = mockBootstrapSeam({ - storageReady: opts.storageReady, - awaitStorageReadyExitCode: opts.awaitStorageReadyExitCode, - }); - const runningCheck = mockRunningCheckSpawner({ running: opts.running }); const telemetry = mockLegacyTelemetryStateTracked(); const linkedCache = mockLegacyLinkedProjectCacheTracked(); // The local-reset bucket-seed core statically requires the (lazy) Management-API @@ -353,17 +462,25 @@ function setup( omitRef: opts.omitRef, resolveFails: opts.resolveFails, }); + const route = opts.route ?? defaultLocalResetRoute(opts.routeOpts); + const child = mockContainerCliSpawner(route); const layer = Layer.mergeAll( out.layer, conn.layer, proxy.layer, - seam.layer, resolver.layer, mockLegacyCliConfig({ workdir }), BunServices.layer, - runningCheck.layer, - mockRuntimeInfo(), + child.layer, + mockRuntimeInfo({ platform: "linux" }), + mockProcessControl().layer, + alwaysReadyHttpClientLayer, + legacyDockerRunLayer.pipe( + Layer.provide(child.layer), + Layer.provide(mockProcessControl().layer), + ), + Layer.succeed(LegacyNetworkIdFlag, Option.none()), // The remote-reset confirmation is answered through mockOutput's // `promptConfirmResponses` (the TTY/clack path), so mark stdin a TTY. Stdin is // only referenced by legacyPromptYesNo's non-TTY branch (unreached here) but must @@ -379,7 +496,6 @@ function setup( loadProjectRef: () => Effect.succeed(opts.ref ?? LEGACY_VALID_REF), promptProjectRef: () => Effect.succeed(opts.ref ?? LEGACY_VALID_REF), }), - mockStorageHttp, Layer.succeed(LegacyPlatformApiFactory, { make: LegacyPlatformApi.pipe(Effect.provide(platformApi.layer)), }), @@ -390,1098 +506,1374 @@ function setup( telemetry.layer, linkedCache.layer, ); - return { layer, out, conn, proxy, seam, telemetry, linkedCache, resolver }; + return { layer, out, conn, proxy, telemetry, linkedCache, resolver, child }; } const migrationFile = (version: string, body = "create table t ();") => ({ [`supabase/migrations/${version}_test.sql`]: body, }); +const PG14_TOML = 'project_id = "test"\n[db]\nmajor_version = 14\n'; +const FAST_HEALTH_TOML = '[db]\nhealth_timeout = "1s"\n'; + describe("legacy db reset", () => { 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 "); + describe("local reset — PG15+", () => { + it.live("recreates the container, waits healthy, and runs the setup pipeline", () => { + const { layer, out, child } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting local database..."); + expect(out.stderrText).toContain("Recreating database...\n"); + expect(removedContainers(child.spawned)).toContain(DB_ID); + expect(removedVolumes(child.spawned)).toContain(DB_ID); + expect(createArgs(child.spawned)).not.toBeUndefined(); + // Default config: realtime, storage, and auth are all enabled (PG >= 15 default). + expect(dbSetupJobCalls(child.spawned)).toHaveLength(3); + expect(out.stderrText).toContain("Restarting containers...\n"); + // Satellite restarts (storage/auth/realtime/pooler), then Kong reload. + expect(restartedContainers(child.spawned)).toEqual( + expect.arrayContaining([ + "supabase_storage_test", + "supabase_auth_test", + "supabase_realtime_test", + "supabase_pooler_test", + ]), + ); + expect(kongReloadCalls(child.spawned)).toHaveLength(1); + 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( + "passes the resolved --version through to the setup pipeline's seed/migrate step", + () => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000", "create table version_one_marker ();"), + ...migrationFile("20240202000000", "create table version_two_marker ();"), + }, + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + local: true, + version: Option.some("20240101000000"), + }).pipe(Effect.provide(layer)); + // The migration up to (and including) the resolved version IS re-applied through + // the recreated database's own session (positive assertion — proves MigrateAndSeed + // actually ran, not just that the cutoff excluded something)... + expect(conn.execs.some((sql) => sql.includes("create table version_one_marker ()"))).toBe( + true, + ); + // ...but the second migration must not be applied at all. + expect(conn.execs.some((sql) => sql.includes("create table version_two_marker ()"))).toBe( + false, + ); + }); + }, + ); - it.live("proceeds with a local reset when no config file is present", () => { - // Go's `Config.Load` tolerates a missing `config.toml`: `Eject` defaults an empty - // `project_id` to the cwd basename (`pkg/config/config.go:563-570`), so `Validate` - // never sees an empty required field and the CLI proceeds — exactly the mechanism - // the cli-e2e parity suite relies on when it runs `db reset --local` from a project - // with no config.toml. A missing config must not become a hard failure here. - const { layer, seam } = setup(tmp.current, { - args: ["db", "reset"], - isLocal: true, - running: true, - }); - return Effect.gen(function* () { - yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(seam.recreateCalls).toHaveLength(1); + it.live("reapplies migrations and seeds after a default local reset (PG15)", () => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000", "create table pg15_marker ();"), + "supabase/seed.sql": "insert into pg15_seed_marker values (1);", + }, + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(conn.execs.some((sql) => sql.includes("create table pg15_marker ()"))).toBe(true); + expect( + conn.execs.some((sql) => sql.includes("insert into pg15_seed_marker values (1)")), + ).toBe(true); + }); }); - }); - it.live("fails a local reset before the destructive recreate on a malformed config.toml", () => { - // Go's `flags.LoadConfig` (the local target's `LoadConfig`, `db_url.go:77-80`) runs - // full config validation before `reset.Run` reaches `AssertSupabaseDbIsRunning` / - // `resetDatabase` (`internal/db/reset/reset.go:57-61`). A broken config.toml must - // abort before the local database is ever recreated. - const { layer, seam } = setup(tmp.current, { - toml: 'project_id = "unterminated\n', - args: ["db", "reset"], - isLocal: true, - running: true, - }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to load config"); - } - expect(seam.recreateCalls).toHaveLength(0); + it.live("skips seeding with --no-seed on a local reset", () => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, local: true, noSeed: true }).pipe( + Effect.provide(layer), + ); + expect(conn.execs.some((sql) => sql.includes("insert into t values (1)"))).toBe(false); + }); }); - }); - it.live( - "fails a local reset on a malformed config.toml even when the database is not running", - () => { - // Pins Go's exact ordering: `flags.LoadConfig` runs in the root `PersistentPreRunE`, - // strictly before `reset.Run` ever calls `AssertSupabaseDbIsRunning` - // (`internal/db/reset/reset.go:57`). So a broken config must surface as a config - // error even when the local database is ALSO not running — the config check must - // win the race, not the "is not running" check. - const { layer, seam } = setup(tmp.current, { - toml: 'project_id = "unterminated\n', - args: ["db", "reset"], + it.live("seeds from --sql-paths overriding config on a local reset", () => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nenabled = false\n', + files: { "supabase/custom-seed.sql": "insert into t values (2);" }, + args: ["db", "reset", "--local"], isLocal: true, - running: false, }); return Effect.gen(function* () { - const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const cause = JSON.stringify(exit.cause); - expect(cause).toContain("failed to load config"); - expect(cause).not.toContain("is not running."); - } - expect(seam.recreateCalls).toHaveLength(0); + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + local: true, + sqlPaths: ["custom-seed.sql"], + }).pipe(Effect.provide(layer)); + expect(conn.execs.some((sql) => sql.includes("insert into t values (2)"))).toBe(true); }); - }, - ); - - it.live("fails a local reset before the destructive recreate on an undecryptable secret", () => { - // Regression: Go's `flags.LoadConfig` decrypts every `encrypted:` secret before - // `reset.Run` recreates the local database, so an undecryptable secret must abort - // before the destructive recreate, not surface later (or never) during bucket - // seeding. - const { layer, seam } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.vault]\nmy_secret = "encrypted:anything"\n', - args: ["db", "reset"], - isLocal: true, - running: true, - }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - // Assert on the stable "failed to parse config:" prefix rather than the exact - // decrypt-failure tail, which depends on whether an ambient `DOTENV_PRIVATE_KEY*` - // is set (missing key vs. a base64/decrypt failure) — either way, the config - // load must fail before the destructive recreate. - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to parse config:"); - } - expect(seam.recreateCalls).toHaveLength(0); }); - }); - it.live("fails a local reset before the destructive recreate on an empty project_id", () => { - // Go's `config.Validate` rejects an explicit `project_id = ""` (a present override - // that resolved to empty, unlike an absent field) before the local recreate. - const { layer, seam } = setup(tmp.current, { - toml: 'project_id = ""\n', - args: ["db", "reset"], - isLocal: true, - running: true, - }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( - "Missing required field in config: project_id", - ); - } - expect(seam.recreateCalls).toHaveLength(0); - }); - }); + it.live( + "fails a local reset when the database is not running, before any recreate work", + () => { + const { layer, child } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + routeOpts: { 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(child.spawned.some((s) => s.args[0] === "container" && s.args[1] === "rm")).toBe( + false, + ); + }); + }, + ); - 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( + "fails a local reset before the destructive recreate on a malformed config.toml", + () => { + const { layer, child } = setup(tmp.current, { + toml: 'project_id = "unterminated\n', + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to load config"); + } + expect(child.spawned.some((s) => s.args[0] === "container" && s.args[1] === "rm")).toBe( + false, + ); + }); + }, + ); - it.live("fails a local reset before the destructive recreate on an unparseable boolean", () => { - // `SEED_ENABLED=maybe` cannot be resolved by Go's `strconv.ParseBool`, so - // `flags.LoadConfig` aborts on this config before `reset.Run` ever reaches - // `AssertSupabaseDbIsRunning`/`resetDatabase`. Previously this surfaced only much - // later (if at all) via the bucket-seeding core's own reload, AFTER the local - // database had already been recreated — this must now abort up front instead, - // via the pre-recreate `legacyCheckDbToml` gate. - const previous = process.env["SEED_ENABLED"]; - process.env["SEED_ENABLED"] = "maybe"; - const { layer, seam } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.seed]\nenabled = "env(SEED_ENABLED)"\n', - args: ["db", "reset"], - isLocal: true, - running: true, - storageReady: true, + it.live("seeds buckets after a local reset when storage is ready", () => { + const { layer, child } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: 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( + child.spawned.some( + (s) => s.args[0] === "container" && s.args[1] === "inspect" && s.args[2] === STORAGE_ID, + ), + ).toBe(true); + }); }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("invalid db.seed.enabled"); - } - expect(seam.recreateCalls).toHaveLength(0); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SEED_ENABLED"]; - else process.env["SEED_ENABLED"] = previous; - }), - ), - ); - }); - it.live( - "finishes a local reset when bucket seeding can't see an env(VAR) value the pre-recreate gate saw", - () => { - // `legacyCheckDbToml` (the pre-recreate gate) resolves `env(VAR)` via - // `legacyLoadProjectEnv`, which mirrors Go's full nested-env walk and sees - // `supabase/.env.development` — a real, Go-valid env source - // (`pkg/config/config.go:1220-1257`; `godotenv.Load` calls `os.Setenv`, so this - // is genuinely ambient env by the time Go itself resolves `env(VAR)`, - // `config.go:1260-1261`). The post-recreate bucket-seed reload instead goes - // through `@supabase/config`'s `loadProjectEnvironment`, which only ever reads - // `supabase/.env`/`.env.local` + ambient env (`packages/config/src/project.ts: - // 209-245) — it can't see `.env.development` at all. So this Go-valid config - // passes the gate and the real recreate, then can't be re-resolved by the - // reload; the reset must still finish (warn-and-skip), not hard-fail after the - // local database has already been dropped and rebuilt. - const { layer, out, seam } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.seed]\nenabled = "env(SEED_ENABLED)"\n', - files: { "supabase/.env.development": "SEED_ENABLED=true\n" }, - args: ["db", "reset"], + it.live("skips bucket seeding when storage is absent (any inspect error)", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], isLocal: true, - running: true, - storageReady: true, + routeOpts: { storageMissing: true }, }); return Effect.gen(function* () { yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(seam.recreateCalls).toHaveLength(1); - expect(out.stderrText).toContain("skipped seeding storage buckets"); expect(out.stderrText).toContain("Finished "); }); - }, - ); - - it.live("uses the detected git branch in the Finished line", () => { - const { layer, out } = setup(tmp.current, { - toml: 'project_id = "test"\n', - 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, + 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", "--local"], + isLocal: true, + }); + 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)); + 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; + }), + ), ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - // Config now loads through the Go-parity reader (`legacyCheckDbToml`), so a malformed - // config aborts with Go's `failed to load config` message, same as the other db - // commands (diff/dump/pull/migration). - expect(JSON.stringify(exit.cause)).toContain("failed to load config"); - } }); - }); - it.live("loads a Go-style env() boolean in config for a remote reset", () => { - // Regression: `enabled = "env(VAR)"` must load via Go's env-expansion + ParseBool - // (`legacyCheckDbToml`) instead of the strict @supabase/config loader rejecting it. - const previous = process.env["MIGRATIONS_ENABLED"]; - process.env["MIGRATIONS_ENABLED"] = "true"; - const { layer, out } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.migrations]\nenabled = "env(MIGRATIONS_ENABLED)"\n', - files: migrationFile("20240101000000"), - confirm: [true], + it.live("emits a json result for a local reset", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: 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"); + }); }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["MIGRATIONS_ENABLED"]; - else process.env["MIGRATIONS_ENABLED"] = previous; - }), - ), - ); - }); - 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("still flushes telemetry when the recreate itself fails", () => { + const { layer, telemetry } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + route: (args) => { + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 1, stderr: ["Error: permission denied"] }; + } + return defaultLocalResetRoute()(args); + }, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to remove container"); + } + expect(telemetry.flushed).toBe(true); + }); }); }); - 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* () { - const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); + describe("local reset — Kong reload", () => { + it.live("fails the whole command with the exact suggestion when Kong reload fails", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + routeOpts: { kongReloadFails: true }, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) as { message: string; suggestion?: string }; + // Byte-matches Go's `DockerExecOnceWithStream` fixed error text (`docker.go:646-648`), + // not the raw exit code. + expect(error.message).toContain("failed to reload kong: error executing command"); + expect(error.suggestion).toContain( + "Local services restarted, but API routes may return 502", + ); + expect(error.suggestion).toContain(`docker restart ${KONG_ID}`); + } + }); }); - }); - it.live("rejects --version together with --last", () => { - 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"), - 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("skips the reload without failing when Kong is excluded from the stack", () => { + const { layer, out, child } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + routeOpts: { kongMissing: true }, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Finished "); + expect(kongReloadCalls(child.spawned)).toHaveLength(0); + }); }); - }); - 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)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe( - "LegacyDbResetInvalidVersionError", - ); - // Go's reset.Run returns the bare repair.ErrInvalidVersion (reset.go:35-36) — - // no `failed to parse :` wrapper (that belongs to `migration repair`). - expect(Option.isSome(failure) && failure.value.message).toBe("invalid version number"); - } + it.live("skips the reload without failing when Kong is present but stopped", () => { + const { layer, out, child } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + routeOpts: { kongNotRunning: true }, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Finished "); + expect(kongReloadCalls(child.spawned)).toHaveLength(0); + }); }); - }); - 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("fails the command when a satellite restart fails", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + routeOpts: { restartFails: ["supabase_storage_test"] }, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to restart supabase_storage_test"); + } + }); }); }); - it.live("rejects an out-of-int64-range --version", () => { - // Go's `strconv.Atoi` == `ParseInt(s, 10, 0)`, which rejects magnitudes outside the - // int64 range even though the text is all digits. `INTEGER_PATTERN` alone would have - // accepted this and fallen through to the glob check instead. - 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("99999999999999999999"), - }).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe( - "LegacyDbResetInvalidVersionError", - ); - expect(Option.isSome(failure) && failure.value.message).toBe("invalid version number"); - } - }); - }); + describe("local reset — PG14", () => { + it.live( + "recreates via the four-statement DROP/CREATE sequence, then initDatabase + RestartDatabase", + () => { + const { layer, out, child, conn } = setup(tmp.current, { + toml: PG14_TOML, + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + // recreateDatabase: no container/volume removal at all on this branch. + expect(removedContainers(child.spawned)).toHaveLength(0); + expect( + conn.execs.some((sql) => sql === "DROP DATABASE IF EXISTS postgres WITH (FORCE)"), + ).toBe(true); + expect( + conn.execs.some((sql) => sql === "CREATE DATABASE postgres WITH OWNER postgres"), + ).toBe(true); + expect( + conn.execs.some((sql) => sql === "DROP DATABASE IF EXISTS _supabase WITH (FORCE)"), + ).toBe(true); + expect( + conn.execs.some((sql) => sql === "CREATE DATABASE _supabase WITH OWNER postgres"), + ).toBe(true); + // initDatabase: schema SQL execs directly over the session — no PG15+ one-shot jobs. + expect(dbSetupJobCalls(child.spawned)).toHaveLength(0); + expect(conn.execs.length).toBeGreaterThan(4); + // RestartDatabase: "Restarting containers..." then a real `docker restart` of `db`, + // THEN the satellite restarts + Kong reload (RestartDatabase-then-restartServices). + expect(out.stderrText).toContain("Restarting containers...\n"); + const dbRestartIndex = child.spawned.findIndex( + (s) => s.args[0] === "restart" && s.args[1] === DB_ID, + ); + const kongReloadIndex = child.spawned.findIndex( + (s) => s.args[0] === "exec" && s.args[1] === KONG_ID, + ); + expect(dbRestartIndex).toBeGreaterThanOrEqual(0); + expect(kongReloadIndex).toBeGreaterThan(dbRestartIndex); + }); + }, + ); - it.live("treats an empty --version like no version at all", () => { - // Go's `len(version) > 0` guard (reset.go:34) skips validation entirely for an empty - // --version, so it must fall through to a full reset rather than glob-checking "" or - // rejecting it as an invalid version. - const { layer, out, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n', - confirm: [true], - }); - return Effect.gen(function* () { - yield* legacyDbReset({ - ...DEFAULT_FLAGS, - linked: true, - version: Option.some(""), - }).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("swallows a disconnect-clients failure when the code is invalid_catalog_name", () => { + const { layer, conn } = setup(tmp.current, { + toml: PG14_TOML, + args: ["db", "reset", "--local"], + isLocal: true, + failStatement: { + sql: "ALTER DATABASE postgres ALLOW_CONNECTIONS false", + code: "3D000", + message: 'database "postgres" does not exist', + }, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + // The reset still completes: the swallowed failure does not abort the recreate. + expect( + conn.execs.some((sql) => sql === "CREATE DATABASE postgres WITH OWNER postgres"), + ).toBe(true); + }); }); - }); - it.live("returns context canceled when the reset prompt is declined", () => { - const { layer, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n', - confirm: [false], + it.live("surfaces a disconnect-clients failure for any other error code", () => { + const { layer } = setup(tmp.current, { + toml: PG14_TOML, + args: ["db", "reset", "--local"], + isLocal: true, + failStatement: { + sql: "ALTER DATABASE postgres ALLOW_CONNECTIONS false", + code: "42501", + message: "permission denied", + }, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to disconnect clients"); + } + }); }); - 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("swallows a disconnect-clients failure that is not a PgError at all", () => { + // A non-PgError failure (network blip) is swallowed too — only a genuine PgError + // whose code differs from 3D000 surfaces. + const { layer, conn } = setup(tmp.current, { + toml: PG14_TOML, + args: ["db", "reset", "--local"], + isLocal: true, + failStatement: { + sql: "ALTER DATABASE postgres ALLOW_CONNECTIONS false", + message: "connection reset by peer", + }, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + // Swallowed: no PgError code at all -> the reset still completes. + expect( + conn.execs.some((sql) => sql === "CREATE DATABASE postgres WITH OWNER postgres"), + ).toBe(true); + }); }); - }); - 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);", + it.live( + "swallows a disconnect-clients failure carrying a node system errno, not a real SQLSTATE", + () => { + // `legacyToExecError`'s fallback (`legacy-db-connection.sql-pg.layer.ts`) sets `code` + // from `legacyExtractSqlState`, which returns ANY string `code` found in the cause + // chain — including a bare node system errno like `ECONNRESET`/`ETIMEDOUT`, which is + // NOT a Postgres SQLSTATE. Go's `errors.As(err, &pgErr)` never matches a socket error, + // so Go swallows this too — the discriminator must check `legacyIsSqlState(code)` + // before comparing against `3D000`, not just `code !== undefined`. + const { layer, conn } = setup(tmp.current, { + toml: PG14_TOML, + args: ["db", "reset", "--local"], + isLocal: true, + failStatement: { + sql: "ALTER DATABASE postgres ALLOW_CONNECTIONS false", + code: "ECONNRESET", + message: "socket hang up", + }, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + // Swallowed: a node errno is not a SQLSTATE -> the reset still completes. + expect( + conn.execs.some((sql) => sql === "CREATE DATABASE postgres WITH OWNER postgres"), + ).toBe(true); + }); }, - 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("fails a remote reset before dropping schemas on an undecryptable secret", () => { - // Regression: the old point-of-use vault decryption ran AFTER `legacyDropUserSchemas`, - // so an undecryptable `encrypted:` secret dropped the schemas before failing. Go runs - // `flags.LoadConfig` (which decrypts every secret) before ResetAll, so the reset must - // abort before any destructive work — matched here by `legacyCheckDbToml` at load time. - const { layer, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.vault]\nmy_secret = "encrypted:anything"\n', - confirm: [true], - }); - 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 config: missing private key"); - } - // Config load failed before ResetAll → schemas were never dropped. - expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); - }); - }); + it.live( + "retries the replication-slot drain on a constant 1-second backoff", + () => { + const { layer, conn } = setup(tmp.current, { + toml: PG14_TOML, + args: ["db", "reset", "--local"], + isLocal: true, + replicationSlotCounts: [2, 1, 0], + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + const countCalls = conn.queries.filter((q) => q.sql === COUNT_REPLICATION_SLOTS); + expect(countCalls).toHaveLength(3); + }); + }, + 10_000, + ); - it.live("fails a remote reset before dropping schemas on an empty project_id", () => { - // Go's config.Validate rejects an explicit `project_id = ""` before the reset prompt, so - // the native remote reset must abort before `legacyDropUserSchemas`. - const { layer, conn } = setup(tmp.current, { - toml: 'project_id = ""\n', - confirm: [true], + it.live("fails permanently (no retry) when counting replication slots itself fails", () => { + const { layer, conn } = setup(tmp.current, { + toml: PG14_TOML, + args: ["db", "reset", "--local"], + isLocal: true, + replicationSlotQueryFails: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to count replication slots"); + } + // A single attempt — the permanent failure never retries. + const countCalls = conn.queries.filter((q) => q.sql === COUNT_REPLICATION_SLOTS); + expect(countCalls).toHaveLength(1); + }); }); - 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( - "Missing required field in config: project_id", + + it.live( + "exhausts all 10 retries and fails when replication slots never drain", + () => { + const { layer } = setup(tmp.current, { + toml: PG14_TOML, + args: ["db", "reset", "--local"], + isLocal: true, + replicationSlotCounts: [1], + }); + 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("replication slots still active"); + } + }); + }, + 20_000, + ); + + it.live("passes --no-seed and the resolved version to the final MigrateAndSeed step", () => { + const { layer, conn } = setup(tmp.current, { + toml: PG14_TOML, + files: { "supabase/seed.sql": "insert into t values (9);" }, + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, local: true, noSeed: true }).pipe( + Effect.provide(layer), ); - } - expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); + expect(conn.execs.some((sql) => sql.includes("insert into t values (9)"))).toBe(false); + }); }); - }); - it.live("auto-confirms a remote reset via SUPABASE_YES set only in the project .env", () => { - // Go's loadNestedEnv sets project-.env keys before the reset prompt reads viper YES, so - // a `SUPABASE_YES` in supabase/.env auto-confirms the destructive prompt (default false). - const { layer, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: { "supabase/.env": "SUPABASE_YES=true\n" }, - // Deliberately no `confirm` responses — the prompt must be auto-confirmed. - }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + it.live("reapplies migrations and seeds after a default local reset (PG14)", () => { + // Positive assertion: proves the final MigrateAndSeed step actually runs and + // re-applies the user's migrations/seed — this step is currently deletable with + // every OTHER PG14 assertion (DROP/CREATE statements, restart ordering, + // disconnect/replication-slot behavior) staying green. + const { layer, conn } = setup(tmp.current, { + toml: PG14_TOML, + files: { + ...migrationFile("20240101000000", "create table pg14_marker ();"), + "supabase/seed.sql": "insert into pg14_seed_marker values (1);", + }, + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(conn.execs.some((sql) => sql.includes("create table pg14_marker ()"))).toBe(true); + expect( + conn.execs.some((sql) => sql.includes("insert into pg14_seed_marker values (1)")), + ).toBe(true); + }); }); - }); - it.live("still caches the linked ref when DB-config resolution fails", () => { - // Go's Execute() runs ensureProjectGroupsCached after ExecuteC returns even on - // error (root.go:171-181), and ParseDatabaseConfig sets ProjectRef via - // LoadProjectRef BEFORE the fallible temp-role/connection step — so a failed - // linked resolve must not skip the post-run linked-project cache write. - const { layer, linkedCache } = setup(tmp.current, { - toml: 'project_id = "test"\n', - resolveFails: true, - }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( - Effect.provide(layer), - Effect.exit, - ); - expect(Exit.isFailure(exit)).toBe(true); - expect(linkedCache.cached).toBe(true); - expect(linkedCache.cachedRef).toBe(LEGACY_VALID_REF); - }); - }); + it.live( + "passes the resolved --version cutoff through to the final MigrateAndSeed step (PG14)", + () => { + const { layer, conn } = setup(tmp.current, { + toml: PG14_TOML, + files: { + ...migrationFile("20240101000000", "create table version_one_marker ();"), + ...migrationFile("20240202000000", "create table version_two_marker ();"), + }, + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + local: true, + version: Option.some("20240101000000"), + }).pipe(Effect.provide(layer)); + // Positive: the migration up to (and including) the resolved version IS re-applied. + expect(conn.execs.some((sql) => sql.includes("create table version_one_marker ()"))).toBe( + true, + ); + // The second migration must not be applied at all. + expect(conn.execs.some((sql) => sql.includes("create table version_two_marker ()"))).toBe( + false, + ); + }); + }, + ); - 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"), + it.live( + "does NOT run globals.sql on the PG14 reset path (deliberately different from db start's PG14 path)", + () => { + const { layer, conn } = setup(tmp.current, { + toml: PG14_TOML, + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + // Go's reset.go `initDatabase` calls the EXPORTED `InitSchema14` directly — unlike + // `db start`'s own PG14 path, which execs globals.sql first. A fingerprint unique + // to `LEGACY_START_DB_GLOBALS_SQL` (see `templates/db-globals.sql.ts`) must never + // appear in this reset's execs. + expect(conn.execs.some((sql) => sql.includes("CREATE ROLE anon"))).toBe(false); + }); }, - confirm: [true], - }); - return Effect.gen(function* () { - yield* legacyDbReset({ - ...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("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"); + describe("local reset — health timeouts", () => { + it.live("a container health-check timeout fails the whole recreate", () => { + const { layer } = setup(tmp.current, { + toml: `project_id = "test"\n${FAST_HEALTH_TOML}`, + args: ["db", "reset", "--local"], + isLocal: true, + routeOpts: { neverHealthy: true }, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }); }); }); - 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], + describe("remote reset", () => { + 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)) { + // Config now loads through the Go-parity reader (`legacyCheckDbToml`), so a malformed + // config aborts with Go's `failed to load config` message, same as the other db + // commands (diff/dump/pull/migration). + expect(JSON.stringify(exit.cause)).toContain("failed to load config"); + } + }); }); - 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), + + it.live("loads a Go-style env() boolean in config for a remote reset", () => { + // Regression: `enabled = "env(VAR)"` must load via Go's env-expansion + ParseBool + // (`legacyCheckDbToml`) instead of the strict @supabase/config loader rejecting it. + const previous = process.env["MIGRATIONS_ENABLED"]; + process.env["MIGRATIONS_ENABLED"] = "true"; + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nenabled = "env(MIGRATIONS_ENABLED)"\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["MIGRATIONS_ENABLED"]; + else process.env["MIGRATIONS_ENABLED"] = previous; + }), + ), ); - 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("rejects mutually exclusive target flags", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--linked", "--local"], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }); }); - }); - it.live("delegates an experimental remote reset to the Go binary", () => { - const { layer, proxy } = setup(tmp.current, { - toml: 'project_id = "test"\n', - experimental: true, + it.live("rejects --version together with --last", () => { + 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"), + 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]"); + }); }); - 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", "--yes=false"]); - expect(proxy.calls[0]!.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); + + 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)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value._tag).toBe( + "LegacyDbResetInvalidVersionError", + ); + // Go's reset.Run returns the bare repair.ErrInvalidVersion (reset.go:35-36) — + // no `failed to parse :` wrapper (that belongs to `migration repair`). + expect(Option.isSome(failure) && failure.value.message).toBe("invalid version number"); + } + }); }); - }); - it.live("does not resolve a linked DB connection before delegating an experimental reset", () => { - const { layer, proxy, resolver } = setup(tmp.current, { - toml: 'project_id = "test"\n', - experimental: true, + 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", + ); + } + }); }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(proxy.calls).toHaveLength(1); - // The delegated Go child re-runs its own connection resolution (including - // minting/verifying the temp login role) once it starts — the TS wrapper - // must not do that same Management-API work first only to discard it (CLI-1879). - expect(resolver.calls).toBe(0); + + it.live("rejects an out-of-int64-range --version", () => { + // Go's `strconv.Atoi` == `ParseInt(s, 10, 0)`, which rejects magnitudes outside the + // int64 range even though the text is all digits. `INTEGER_PATTERN` alone would have + // accepted this and fallen through to the glob check instead. + 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("99999999999999999999"), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value._tag).toBe( + "LegacyDbResetInvalidVersionError", + ); + expect(Option.isSome(failure) && failure.value.message).toBe("invalid version number"); + } + }); }); - }); - it.live("still caches the linked ref when delegating an experimental reset", () => { - // `linkedRefForCache` is pre-loaded via `LegacyProjectRefResolver.loadProjectRef` - // separately from `resolver.resolve()`, specifically so the post-run - // linked-project-cache finalizer still fires on this path even though - // `resolve()` itself is skipped entirely (CLI-1879). - const { layer, linkedCache } = setup(tmp.current, { - toml: 'project_id = "test"\n', - experimental: true, - ref: LEGACY_VALID_REF, + it.live("treats an empty --version like no version at all", () => { + // Go's `len(version) > 0` guard (reset.go:34) skips validation entirely for an empty + // --version, so it must fall through to a full reset rather than glob-checking "" or + // rejecting it as an invalid version. + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some(""), + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting remote database..."); + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + }); }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(linkedCache.cached).toBe(true); - expect(linkedCache.cachedRef).toBe(LEGACY_VALID_REF); + + 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( - "surfaces a delegated experimental-reset child failure as a LegacyGoChildExitError under json output", - () => { - const { layer } = setup(tmp.current, { + 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', - experimental: true, - format: "json", - execCaptureExitCode: 3, + 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("fails a remote reset before dropping schemas on an undecryptable secret", () => { + // Regression: the old point-of-use vault decryption ran AFTER `legacyDropUserSchemas`, + // so an undecryptable `encrypted:` secret dropped the schemas before failing. Go runs + // `flags.LoadConfig` (which decrypts every secret) before ResetAll, so the reset must + // abort before any destructive work — matched here by `legacyCheckDbToml` at load time. + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.vault]\nmy_secret = "encrypted:anything"\n', + confirm: [true], }); return Effect.gen(function* () { - // Under json/stream-json, the delegated path uses `execCapture` (non-text - // branch of `delegateExperimentalReset`) — this must flow through the normal - // Effect failure channel (reachable by `withJsonErrorHandling` at the - // command-wiring layer) instead of an immediate `ProcessControl.exit()` that a - // handler-level test could never observe (CLI-1879). const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( Effect.provide(layer), Effect.exit, ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const error = Cause.squash(exit.cause); - expect(error).toBeInstanceOf(LegacyGoChildExitError); - expect((error as LegacyGoChildExitError).exitCode).toBe(3); + expect(JSON.stringify(exit.cause)).toContain( + "failed to parse config: missing private key", + ); } + // Config load failed before ResetAll → schemas were never dropped. + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); }); - }, - ); + }); - it.live( - "propagates the storage-ready check's exact exit code and still flushes telemetry on a local reset", - () => { - // The bootstrap seam's `awaitStorageReady` (the `captureStdout` bootstrap-child - // path) failing non-zero must reach the handler as the exact `LegacyGoChildExitError` - // it fails with, and the handler's own `Effect.ensuring(telemetryState.flush)` - // finalizer must still run despite the typed failure (CLI-1879). - const { layer, telemetry } = setup(tmp.current, { - toml: 'project_id = "test"\n', - args: ["db", "reset"], - isLocal: true, - running: true, - awaitStorageReadyExitCode: 4, + it.live("fails a remote reset before dropping schemas on an empty project_id", () => { + // Go's config.Validate rejects an explicit `project_id = ""` before the reset prompt, so + // the native remote reset must abort before `legacyDropUserSchemas`. + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = ""\n', + confirm: [true], }); return Effect.gen(function* () { - const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const error = Cause.squash(exit.cause); - expect(error).toBeInstanceOf(LegacyGoChildExitError); - expect((error as LegacyGoChildExitError).exitCode).toBe(4); + expect(JSON.stringify(exit.cause)).toContain( + "Missing required field in config: project_id", + ); } - expect(telemetry.flushed).toBe(true); + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); }); - }, - ); + }); - it.live("forwards the linked selector to the delegate even for --linked=false", () => { - // Cobra `Changed` semantics: `--linked=false` still selects the linked/remote target in - // the parent, so the delegated argv must carry `--linked` — otherwise the Go child falls - // back to its local default and resets the wrong database. - const { layer, proxy } = setup(tmp.current, { - toml: 'project_id = "test"\n', - experimental: true, - args: ["db", "reset", "--linked=false"], + it.live("auto-confirms a remote reset via SUPABASE_YES set only in the project .env", () => { + // Go's loadNestedEnv sets project-.env keys before the reset prompt reads viper YES, so + // a `SUPABASE_YES` in supabase/.env auto-confirms the destructive prompt (default false). + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/.env": "SUPABASE_YES=true\n" }, + // Deliberately no `confirm` responses — the prompt must be auto-confirmed. + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + }); }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: false }).pipe(Effect.provide(layer)); - expect(proxy.calls).toHaveLength(1); - expect(proxy.calls[0]!.args).toEqual(["db", "reset", "--linked", "--yes=false"]); + + it.live("still caches the linked ref when DB-config resolution fails", () => { + // Go's Execute() runs ensureProjectGroupsCached after ExecuteC returns even on + // error (root.go:171-181), and ParseDatabaseConfig sets ProjectRef via + // LoadProjectRef BEFORE the fallible temp-role/connection step — so a failed + // linked resolve must not skip the post-run linked-project cache write. + const { layer, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + resolveFails: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(LEGACY_VALID_REF); + }); }); - }); - it.live("forwards --yes=false to the delegate even when SUPABASE_YES is set", () => { - // Explicit `--yes=false` beats `AutomaticEnv` in Go; the delegated child must receive the - // bound false flag so an inherited `SUPABASE_YES=true` doesn't auto-confirm the reset and - // drop the remote schemas the user tried to protect. - const previous = process.env["SUPABASE_YES"]; - process.env["SUPABASE_YES"] = "true"; - const { layer, proxy } = setup(tmp.current, { - toml: 'project_id = "test"\n', - experimental: true, - args: ["db", "reset", "--linked", "--yes=false"], + 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({ + ...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("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"); + }); }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(proxy.calls[0]!.args).toContain("--yes=false"); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_YES"]; - else process.env["SUPABASE_YES"] = previous; - }), - ), - ); - }); - it.live("forwards --yes=true to the delegate when --yes is set", () => { - const { layer, proxy } = setup(tmp.current, { - toml: 'project_id = "test"\n', - experimental: true, - args: ["db", "reset", "--linked", "--yes"], - yes: true, + 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: -"); + }); }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(proxy.calls[0]!.args).toContain("--yes=true"); + + 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( - "takes the experimental delegate path via SUPABASE_EXPERIMENTAL in the project .env", - () => { - // Go loads nested env before reset.Run reads viper EXPERIMENTAL, so the versionless remote - // reset delegates to the Go binary rather than replaying migrations natively. - const previous = process.env["SUPABASE_EXPERIMENTAL"]; - delete process.env["SUPABASE_EXPERIMENTAL"]; - const { layer, proxy, conn } = setup(tmp.current, { + it.live("delegates an experimental remote reset to the Go binary", () => { + const { layer, proxy } = setup(tmp.current, { toml: 'project_id = "test"\n', - files: { "supabase/.env": "SUPABASE_EXPERIMENTAL=true\n" }, - // No experimental flag / shell env — only the project .env sets it. + experimental: true, }); return Effect.gen(function* () { yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); expect(proxy.calls).toHaveLength(1); - // Delegated, so the native remote path never dropped schemas. - expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); + expect(proxy.calls[0]!.args).toEqual(["db", "reset", "--linked", "--yes=false"]); + expect(proxy.calls[0]!.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); + }); + }); + + it.live( + "does not resolve a linked DB connection before delegating an experimental reset", + () => { + const { layer, proxy, resolver } = 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); + // The delegated Go child re-runs its own connection resolution (including + // minting/verifying the temp login role) once it starts — the TS wrapper + // must not do that same Management-API work first only to discard it (CLI-1879). + expect(resolver.calls).toBe(0); + }); + }, + ); + + it.live("still caches the linked ref when delegating an experimental reset", () => { + // `linkedRefForCache` is pre-loaded via `LegacyProjectRefResolver.loadProjectRef` + // separately from `resolver.resolve()`, specifically so the post-run + // linked-project-cache finalizer still fires on this path even though + // `resolve()` itself is skipped entirely (CLI-1879). + const { layer, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + ref: LEGACY_VALID_REF, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(LEGACY_VALID_REF); + }); + }); + + it.live( + "surfaces a delegated experimental-reset child failure as a LegacyGoChildExitError under json output", + () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + format: "json", + execCaptureExitCode: 3, + }); + return Effect.gen(function* () { + // Under json/stream-json, the delegated path uses `execCapture` (non-text + // branch of `delegateExperimentalReset`) — this must flow through the normal + // Effect failure channel (reachable by `withJsonErrorHandling` at the + // command-wiring layer) instead of an immediate `ProcessControl.exit()` that a + // handler-level test could never observe (CLI-1879). + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toBeInstanceOf(LegacyGoChildExitError); + expect((error as LegacyGoChildExitError).exitCode).toBe(3); + } + }); + }, + ); + + it.live("forwards the linked selector to the delegate even for --linked=false", () => { + // Cobra `Changed` semantics: `--linked=false` still selects the linked/remote target in + // the parent, so the delegated argv must carry `--linked` — otherwise the Go child falls + // back to its local default and resets the wrong database. + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + args: ["db", "reset", "--linked=false"], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: false }).pipe(Effect.provide(layer)); + expect(proxy.calls).toHaveLength(1); + expect(proxy.calls[0]!.args).toEqual(["db", "reset", "--linked", "--yes=false"]); + }); + }); + + it.live("forwards --yes=false to the delegate even when SUPABASE_YES is set", () => { + // Explicit `--yes=false` beats `AutomaticEnv` in Go; the delegated child must receive the + // bound false flag so an inherited `SUPABASE_YES=true` doesn't auto-confirm the reset and + // drop the remote schemas the user tried to protect. + const previous = process.env["SUPABASE_YES"]; + process.env["SUPABASE_YES"] = "true"; + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + args: ["db", "reset", "--linked", "--yes=false"], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(proxy.calls[0]!.args).toContain("--yes=false"); }).pipe( Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_EXPERIMENTAL"]; - else process.env["SUPABASE_EXPERIMENTAL"] = previous; + if (previous === undefined) delete process.env["SUPABASE_YES"]; + else process.env["SUPABASE_YES"] = previous; }), ), ); - }, - ); - - it.live("attaches the Go seed-flag conflict suggestion to --no-seed + --sql-paths", () => { - const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset({ - ...DEFAULT_FLAGS, - 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"); - // Go's validateDbResetSeedFlags CmdSuggestion, rendered as a Suggestion: line. - expect(JSON.stringify(exit.cause)).toContain("Use either"); - } }); - }); - it.live("forwards --db-url and --no-seed on an experimental remote db-url reset", () => { - const { layer, proxy, resolver } = 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({ - ...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", - "--yes=false", - ]); - // Unlike the `connType === "linked"` branch above, a `--db-url` target still - // resolves a connection before delegating — the pre-delegation skip (CLI-1879) - // is scoped to the linked branch only, not "never call resolve when delegating". - expect(resolver.calls).toBe(1); + it.live("forwards --yes=true to the delegate when --yes is set", () => { + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + args: ["db", "reset", "--linked", "--yes"], + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(proxy.calls[0]!.args).toContain("--yes=true"); + }); }); - }); - 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({ - ...DEFAULT_FLAGS, - local: true, - noSeed: true, - last: Option.some(1), - }).pipe(Effect.provide(layer)); - expect(seam.recreateCalls).toEqual([ - { version: "20240101000000", noSeed: true, sqlPaths: [] }, - ]); - }); - }); + it.live( + "takes the experimental delegate path via SUPABASE_EXPERIMENTAL in the project .env", + () => { + // Go loads nested env before reset.Run reads viper EXPERIMENTAL, so the versionless remote + // reset delegates to the Go binary rather than replaying migrations natively. + const previous = process.env["SUPABASE_EXPERIMENTAL"]; + delete process.env["SUPABASE_EXPERIMENTAL"]; + const { layer, proxy, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/.env": "SUPABASE_EXPERIMENTAL=true\n" }, + // No experimental flag / shell env — only the project .env sets it. + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(proxy.calls).toHaveLength(1); + // Delegated, so the native remote path never dropped schemas. + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_EXPERIMENTAL"]; + else process.env["SUPABASE_EXPERIMENTAL"] = previous; + }), + ), + ); + }, + ); - 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({ - ...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("attaches the Go seed-flag conflict suggestion to --no-seed + --sql-paths", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + 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"); + // Go's validateDbResetSeedFlags CmdSuggestion, rendered as a Suggestion: line. + expect(JSON.stringify(exit.cause)).toContain("Use either"); + } + }); }); - }); - 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("forwards --db-url and --no-seed on an experimental remote db-url reset", () => { + const { layer, proxy, resolver } = 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({ + ...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", + "--yes=false", + ]); + // Unlike the `connType === "linked"` branch above, a `--db-url` target still + // resolves a connection before delegating — the pre-delegation skip (CLI-1879) + // is scoped to the linked branch only, not "never call resolve when delegating". + expect(resolver.calls).toBe(1); + }); }); - }); - 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("recreates to a specific --version on a local db-url reset", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + args: ["db", "reset", "--db-url", "postgresql://localhost:54322/postgres"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...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(conn.execs.some((sql) => sql.includes("insert into"))).toBe(false); + }); }); - }); - 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("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("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("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("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("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("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("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("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: [""], - }).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.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 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("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("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("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: [""], + }).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.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("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("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("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("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, + 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..."); + }); }); - 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", - "--yes=false", - ]); + + 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", + "--yes=false", + ]); + }); }); }); }); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.layers.ts b/apps/cli/src/legacy/commands/db/reset/reset.layers.ts index 7cf648f3fe..e51c2aa09e 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.layers.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.layers.ts @@ -9,18 +9,23 @@ import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer. 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 { legacyDockerRunLayer } from "../../../shared/legacy-docker-run.layer.ts"; import { stdinLayer } from "../../../../shared/runtime/stdin.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. + * (used to delegate the remaining `--experimental` reset path) is ambient from the + * root. `legacyDockerRunLayer` backs the native local recreate's PG15+ one-shot + * migrate jobs (`legacyStartSetupLocalDatabase`, reused via + * `legacyRecreateLocalDatabase`) — same reasoning as `db start`'s own + * `start.layers.ts`. `LegacyCliConfig`/`ChildProcessSpawner`/`FileSystem`/`Path`/ + * `RuntimeInfo` are ambient from the root runtime (`shared/cli/run.ts`). */ const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); @@ -74,7 +79,7 @@ export const legacyDbResetRuntimeLayer = Layer.mergeAll( // `console.ReadLine`); without it a CI/piped remote `db reset` that reaches the // confirmation prompt fails with a missing-service defect instead of the default. stdinLayer, - // Container-recreate / storage-health primitives for the native local reset. - legacyDbBootstrapSeamLayer.pipe(Layer.provide(cliConfig)), + // Backs the native local recreate's PG15+ one-shot migrate jobs. + legacyDockerRunLayer, commandRuntimeLayer(["db", "reset"]), ); 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 deleted file mode 100644 index 1eac829601..0000000000 --- a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Data } from "effect"; - -/** - * Driving the bundled Go binary's hidden `db __db-bootstrap` seam failed — the - * container-lifecycle primitives that back native `db reset --local` (recreate the - * local Postgres container, apply the initial schema, the storage health gate) are - * not yet ported to TypeScript. Wraps 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. `db start` no longer composes this seam at all (CLI-1954): its own - * already-running check is {@link LegacyLocalDbRunningError} from - * `legacy/shared/db-bootstrap/local-db-running.ts`. - */ -export class LegacyDbBootstrapError extends Data.TaggedError("LegacyDbBootstrapError")<{ - readonly message: string; - /** - * Optional actionable hint rendered as a separate "Suggestion:" line, mirroring - * Go's `utils.CmdSuggestion` — set to the Docker-install hint when the container - * runtime's daemon is unreachable (`AssertServiceIsRunning`, `misc.go:148-154`). - */ - readonly suggestion?: 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 deleted file mode 100644 index 3060be9e64..0000000000 --- a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { Effect, Layer, Option, 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 { LegacyGoChildExitError } from "../../../../shared/legacy/legacy-go-child-exit.error.ts"; -import { ProcessControl } from "../../../../shared/runtime/process-control.service.ts"; -import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.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 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) { - // `LegacyGoChildExitError` (not `seamFailure`/`processControl.exit`) so the - // handler's finalizers — `Effect.ensuring(telemetryState.flush)` + the legacy - // command instrumentation — still run (an immediate `process.exit` would skip - // them), AND the child's exact exit code (e.g. 130 after Ctrl-C cleanup) reaches - // `runCli`'s `processControl.exit()` instead of collapsing to a generic 1. The - // child's detailed failure is already on the inherited stderr, so `runCli` - // special-cases this error class to suppress its own normally-would-print - // generic stderr line — Go itself never prints a second line here. CLI-1879. - return yield* Effect.fail( - new LegacyGoChildExitError({ - exitCode, - message: `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) { - // See the `!captureStdout` branch above for why `LegacyGoChildExitError` - // replaces `seamFailure` here — same exact-code + finalizer + no-duplicate-line - // reasoning (CLI-1879). - return yield* Effect.fail( - new LegacyGoChildExitError({ - exitCode, - message: `failed to bootstrap the local database: exit ${exitCode}`, - }), - ); - } - return decodeChunks(chunks); - }), - ); - - return LegacyDbBootstrapSeam.of({ - 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 deleted file mode 100644 index 3f5a08dcee..0000000000 --- a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Context, type Effect } from "effect"; - -import type { LegacyGoChildExitError } from "../../../../shared/legacy/legacy-go-child-exit.error.ts"; -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 reset --local` still needs - * but that are not ported to TypeScript: the database container recreate flow 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. - * - * `db start`'s own container bootstrap (`start.StartDatabase`) was removed from - * this seam by CLI-1954 — it is now a fully native TS implementation - * (`commands/db/start/start.handler.ts`), reusing `commands/start/`'s already-ported - * container-bootstrap primitives instead of shelling out to the Go binary. The - * local-stack "is running?" probe (`legacyIsLocalDbRunning`) was already a native - * TS implementation before CLI-1954 — that same change also hoisted it out of this - * seam into `legacy/shared/db-bootstrap/local-db-running.ts`, since it never shelled - * out to Go and is shared by both `db start` and `db reset`. - * - * 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 { - /** - * The PG14/PG15 container-recreate half of local `db reset` - * (`reset.RecreateLocalDatabase`): recreate the db container/volume, init schema, - * migrate + seed up to `version`, restart the satellite containers - * (storage/auth/realtime/pooler), and reload Kong so its nginx re-resolves - * the restarted containers' addresses — otherwise routes to a container that - * moved keep returning 502 after the reset succeeds (issue #6016). 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< - boolean, - LegacyDbBootstrapError | LegacyGoChildExitError - >; -} - -export class LegacyDbBootstrapSeam extends Context.Service< - LegacyDbBootstrapSeam, - LegacyDbBootstrapSeamShape ->()("supabase/legacy/DbBootstrapSeam") {} 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 6a52434c89..5c453dabb4 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 @@ -505,8 +505,8 @@ export const legacyDeclarativeSeamLayer = Layer.effect( }), ); -// Intentionally NOT `LegacyGoChildExitError` (contrast `legacy-db-bootstrap.seam.layer.ts`, -// fixed under CLI-1879): this seam's failure is a TS-authored domain summary over noisy +// Intentionally NOT `LegacyGoChildExitError` (contrast the now-removed `db __db-bootstrap` +// seam, fixed under CLI-1879): this seam's failure is a TS-authored domain summary over noisy // docker/pgdelta child stderr, not a passthrough of a real Go-CLI child the user invoked // directly — Go itself wraps every shadow-DB failure into a generic error that `cmd/root.go`'s // `recoverAndExit` exits `1` for, so propagating THIS child's exact exit code would itself 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 4a3c753cc0..7d2ed58261 100644 --- a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md @@ -2,19 +2,18 @@ Fully native TypeScript port of `apps/cli-go/internal/db/start/start.go`'s `Run` + `StartDatabase` (CLI-1954 removed the last Go delegation — the hidden `db __db-bootstrap ---mode start` case no longer exists; that command still exists for `db reset --local`'s -`--mode recreate`/`--mode await-storage`, see the "Notes" section). This is `db start`, -**not** the top-level `supabase start`: no status table, no `cli_stack_started` event, no -`Finished` line, no `--exclude`, no `--ignore-health-check`. +--mode start` case no longer exists; CLI-1955 removed the REST of that hidden command too +— see `db reset --local`'s own `SIDE_EFFECTS.md`). This is `db start`, **not** the +top-level `supabase start`: no status table, no `cli_stack_started` event, no `Finished` +line, no `--exclude`, no `--ignore-health-check`. The handler validates config, checks whether the local Postgres container is already running (`legacyIsLocalDbRunning` — a native `docker container inspect`, hoisted to `legacy/shared/db-bootstrap/local-db-running.ts` and shared with `db reset --local`'s -own running-check; `db start` composes no `LegacyDbBootstrapSeam` at all anymore — that -seam still exists only for `db reset --local`'s own, still-Go-delegated -`recreateDatabase`/`awaitStorageReady` methods, see CLI-1955), and otherwise natively -brings up the container itself, reusing `legacy/shared/db-bootstrap/`'s container-bootstrap -primitives (the same ones `supabase start` uses for its own Postgres bring-up): +own running-check), and otherwise natively brings up the container itself, reusing +`legacy/shared/db-bootstrap/`'s container-bootstrap primitives (the same ones `supabase +start` uses for its own Postgres bring-up, and `db reset --local`'s own recreate +composition reuses too — see that command's `SIDE_EFFECTS.md`): 1. Ensure the Docker network exists (`--network-id` override or `supabase_network_`). 2. Probe whether the Postgres data volume (`supabase_db_`) already exists — @@ -59,20 +58,20 @@ on any `StartDatabase` failure. ## Files Read -| Path | Format | When | -| ----------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always — parsed up front; a malformed config aborts before any container work | -| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | always | -| `auth.signing_keys_path` file | JSON | when configured | -| `` (from `--from-backup`) | binary | when `--from-backup` is set — read by Postgres's own entrypoint inside the container, not by this process | -| `/supabase/.temp/storage-migration` | text | always — linked-project Storage migration pin (`DB_MIGRATIONS_FREEZE_AT`); absent/unreadable resolves to "" | -| `/supabase/.temp/postgres-version` | text | when `db.major_version > 14` — linked-project Postgres version pin | -| `/supabase/.temp/{gotrue,rest,storage,realtime,studio,pgmeta,logflare,pooler}-version` | text | always read; only the `gotrue`/`storage`/`realtime` pins are actually consulted (the fresh-volume setup jobs' images) | -| `/supabase/roles.sql` | SQL | on a fresh volume with no `--from-backup` — the "Seeding globals..." message always prints first; a missing file is tolerated | -| `/supabase/migrations/*.sql`, `supabase/seed.sql` | SQL | on a fresh volume with no `--from-backup`, via the standard migration-apply + seed pipeline | -| `/supabase/` (files/directories/globs) | SQL | on a fresh volume with no `--from-backup`, INSTEAD of `migrations/*.sql`, when `--experimental`/`SUPABASE_EXPERIMENTAL` is set and `[experimental.pgdelta] enabled` is false | -| `/supabase/.branches/_current_branch` | text | always, existence check before writing (see "Files Written") | -| `~/.docker/config.json` | JSON | via the `docker`/`podman` CLI itself, for registry auth — never read directly by this process | +| Path | Format | When | +| ----------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always — parsed up front; a malformed config aborts before any container work | +| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | always | +| `auth.signing_keys_path` file | JSON | when configured | +| `` (from `--from-backup`) | binary | when `--from-backup` is set — read by Postgres's own entrypoint inside the container, not by this process | +| `/supabase/.temp/storage-migration` | text | always — linked-project Storage migration pin (`DB_MIGRATIONS_FREEZE_AT`); absent/unreadable resolves to "" | +| `/supabase/.temp/postgres-version` | text | when `db.major_version > 14` — linked-project Postgres version pin | +| `/supabase/.temp/{gotrue,rest,storage,realtime,studio,pgmeta,logflare,pooler}-version` | text | always read; only the `gotrue`/`storage`/`realtime` pins are actually consulted (the fresh-volume setup jobs' images) | +| `/supabase/roles.sql` | SQL | on a fresh volume with no `--from-backup` — the "Seeding globals..." message always prints first; a missing file is tolerated | +| `/supabase/migrations/*.sql`, `supabase/seed.sql` | SQL | on a fresh volume with no `--from-backup`, via the standard migration-apply + seed pipeline | +| `/supabase/` (files/directories/globs) | SQL | on a fresh volume with no `--from-backup`, INSTEAD of `migrations/*.sql`, when `--experimental`/`SUPABASE_EXPERIMENTAL` is set and `[experimental.pgdelta] enabled` is false | +| `/supabase/.branches/_current_branch` | text | always, existence check before writing (see "Files Written") | +| `~/.docker/config.json` | JSON | via the `docker`/`podman` CLI itself, for registry auth — never read directly by this process | ## Files Written @@ -110,22 +109,22 @@ native container command in this codebase — never `supabase-go`. ## Environment Variables -| Variable | Purpose | Required? | -| -------------------------------------------------------------- | ------------------------------------------------------------- | --------- | -| `SUPABASE_PROJECT_ID` | overrides the local container id (`utils.DbId`) | no | -| `SUPABASE_DB_PORT` | overrides `db.port` (the published host port) | no | -| `SUPABASE_DB_MAJOR_VERSION` | overrides `db.major_version` (image selection, schema branch) | no | -| `SUPABASE_DB_HEALTH_TIMEOUT` | overrides `db.health_timeout` | no | -| `SUPABASE_DB_SETTINGS_*` | overrides individual `[db.settings]` fields | no | -| `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` | overrides `experimental.orioledb_version` (image + env) | no | -| `SUPABASE_EXPERIMENTAL_S3_{HOST,REGION,ACCESS_KEY,SECRET_KEY}` | OrioleDB S3 env overrides | no | -| `SUPABASE_REALTIME_ENABLED` | gates the fresh-volume realtime migrate job | no | -| `SUPABASE_REALTIME_IP_VERSION` / `_MAX_HEADER_LENGTH` | realtime migrate job env overrides | no | -| `SUPABASE_STORAGE_ENABLED` | gates the fresh-volume storage migrate job | no | -| `SUPABASE_STORAGE_FILE_SIZE_LIMIT` | storage migrate job env override | no | -| `SUPABASE_AUTH_ENABLED` | gates the fresh-volume auth migrate job | no | -| `SUPABASE_AUTH_EXTERNAL_URL` / `SUPABASE_AUTH_SITE_URL` | auth migrate job env overrides | no | -| `SUPABASE_AUTH_JWT_EXPIRY` | Postgres's `JWT_EXP` env / signing | no | +| Variable | Purpose | Required? | +| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_PROJECT_ID` | overrides the local container id (`utils.DbId`) | no | +| `SUPABASE_DB_PORT` | overrides `db.port` (the published host port) | no | +| `SUPABASE_DB_MAJOR_VERSION` | overrides `db.major_version` (image selection, schema branch) | no | +| `SUPABASE_DB_HEALTH_TIMEOUT` | overrides `db.health_timeout` | no | +| `SUPABASE_DB_SETTINGS_*` | overrides individual `[db.settings]` fields | no | +| `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` | overrides `experimental.orioledb_version` (image + env) | no | +| `SUPABASE_EXPERIMENTAL_S3_{HOST,REGION,ACCESS_KEY,SECRET_KEY}` | OrioleDB S3 env overrides | no | +| `SUPABASE_REALTIME_ENABLED` | gates the fresh-volume realtime migrate job | no | +| `SUPABASE_REALTIME_IP_VERSION` / `_MAX_HEADER_LENGTH` | realtime migrate job env overrides | no | +| `SUPABASE_STORAGE_ENABLED` | gates the fresh-volume storage migrate job | no | +| `SUPABASE_STORAGE_FILE_SIZE_LIMIT` | storage migrate job env override | no | +| `SUPABASE_AUTH_ENABLED` | gates the fresh-volume auth migrate job | no | +| `SUPABASE_AUTH_EXTERNAL_URL` / `SUPABASE_AUTH_SITE_URL` | auth migrate job env overrides | no | +| `SUPABASE_AUTH_JWT_EXPIRY` | Postgres's `JWT_EXP` env / signing | no | | `SUPABASE_EXPERIMENTAL` (or `--experimental`) | fresh volume + no pg-delta: applies `db.migrations.schema_paths` files instead of `migrations/*.sql` | no | `--network-id` (a global CLI flag, not an environment variable — `shared/legacy/global-flags.ts`) @@ -171,6 +170,7 @@ Same result object as the terminal `result` event; progress on stderr. `db.health_timeout`. - No `cli_stack_started` telemetry — that event belongs to `supabase start`, not `db start`. The only event is the standard `cli_command_executed`. -- `db reset --local` (a different command) still delegates its container-recreate flow to - the bundled Go binary's hidden `db __db-bootstrap --mode recreate` seam — that is - CLI-1955's scope, not this one. +- `db reset --local` (a different command) is ALSO fully native now (CLI-1955) — it + reuses this same `legacy/shared/db-bootstrap/` primitive set, but through its own + composition (`legacy/shared/db-bootstrap/recreate-local-database.ts`), not through + `legacyStartDatabase`/this command's own handler — see that command's `SIDE_EFFECTS.md`. 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 2c095544a1..a33d10c1e1 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -3,33 +3,15 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { Output } from "../../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; -import { - LegacyNetworkIdFlag, - legacyResolveExperimentalWithProjectEnv, -} from "../../../../shared/legacy/global-flags.ts"; +import { LegacyNetworkIdFlag } from "../../../../shared/legacy/global-flags.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { legacyIsBitbucketPipeline } from "../../../shared/legacy-bitbucket-pipeline.ts"; import { legacyCheckDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; -import { LegacyDbConfigLoadError } from "../../../shared/legacy-db-config.errors.ts"; -import { - legacyCliProjectFilterValue, - localDbContainerId, - localNetworkId, -} from "../../../shared/legacy-docker-ids.ts"; -import { - legacyResolveAuthExternalUrl, - legacyResolveDbSettingsEnvOverrides, - legacyResolveLocalConfigValues, - legacyResolveLocalJwks, -} from "../../../shared/legacy-local-config-values.ts"; -import { legacyLoadLocalProjectContext } from "../../../shared/legacy-local-project-context.ts"; -import { legacyResolveDbBootstrapConfig } from "../../../shared/db-bootstrap/bootstrap-config.ts"; -import { legacyEnsureImagesCached } from "../../../shared/db-bootstrap/image-prepull.ts"; +import { legacyCliProjectFilterValue } from "../../../shared/legacy-docker-ids.ts"; +import { legacyBuildLocalDbContainerInputs } from "../../../shared/db-bootstrap/local-container-inputs.ts"; import { legacyIsLocalDbRunning } from "../../../shared/db-bootstrap/local-db-running.ts"; import { legacyRollbackStart } from "../../../shared/db-bootstrap/rollback.ts"; import { legacyStartDatabase } from "../../../shared/db-bootstrap/start-database.ts"; -import type { LegacyStartContainerOpts } from "../../../shared/db-bootstrap/container-lifecycle.ts"; import type { LegacyDbStartFlags } from "./start.command.ts"; /** @@ -117,60 +99,27 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega // resolver) plus the shared `legacyResolveDbBootstrapConfig` derivation `supabase // start` also uses — deliberately narrower than `supabase start`'s own prelude: no // `--exclude`, no image pre-pull for any other service, no JWT/JWKS/image resolution - // beyond what Postgres and its own fresh-volume setup jobs need. - const context = yield* legacyLoadLocalProjectContext( + // beyond what Postgres and its own fresh-volume setup jobs need. Shared with `db reset`'s + // own identical prelude — see `legacyBuildLocalDbContainerInputs`'s own header for why + // `fromBackup`/rollback tracking stay here instead of moving into it. + const inputs = yield* legacyBuildLocalDbContainerInputs( + spawner, cliConfig.workdir, - (message) => new LegacyDbConfigLoadError({ message }), + networkIdFlag, + runtimeInfo.platform, ); - const { config, projectEnvValues, loaded, hostname, projectId } = context; - // Go's `viper.GetBool("EXPERIMENTAL")` (`internal/migration/apply/apply.go:19`), read deep - // inside `legacyStartDatabase`'s fresh-volume setup pipeline — resolved here (project `.env` - // aware, like `db reset`'s identical gate) so it can be threaded straight through. - const experimental = yield* legacyResolveExperimentalWithProjectEnv(projectEnvValues); - - const values = yield* Effect.try({ - try: () => - legacyResolveLocalConfigValues( - config, - hostname, - cliConfig.workdir, - projectEnvValues, - loaded?.document, - ), - catch: (cause) => - new LegacyDbConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); - - const bootstrapConfig = yield* legacyResolveDbBootstrapConfig( - fs, - path, - { config, projectEnvValues, workdir: cliConfig.workdir }, - (message) => new LegacyDbConfigLoadError({ message }), - ); - - // Go's `DockerStart` forces every container's network mode (and the network it creates) - // to `--network-id` when set, ahead of the generated `supabase_network_` fallback - // (`docker.go:379-383`). - const networkId = Option.isSome(networkIdFlag) - ? networkIdFlag.value - : localNetworkId(projectId); - // Go's `DockerStart` unconditionally appends the Linux-only - // `host.docker.internal:host-gateway` extra host for every container it starts - // (`docker_linux.go`; empty on darwin/windows, where Docker Desktop already resolves that - // hostname). - const extraHosts = - runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; - const isBitbucketPipeline = legacyIsBitbucketPipeline(); - const startOpts: LegacyStartContainerOpts = { - projectId, - isBitbucketPipeline, - workdir: cliConfig.workdir, - extraHosts, - }; + const { + context: { projectId, hostname }, + values, + bootstrapConfig, + networkId, + containerOpts, + dbContainerId, + postgresSpecBase, + resolvePostgresImage, + setup, + } = inputs; - const dbContainerId = localDbContainerId(projectId); const filterValue = legacyCliProjectFilterValue(projectId); // Go's `utils.NoBackupVolume` package var — assigned by `legacyStartDatabase`'s own @@ -194,95 +143,25 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega hostname, dbContainerId, dbPort: values.dbPort, - containerOpts: startOpts, - postgresSpec: { - db: { - ...config.db, - port: values.dbPort, - major_version: bootstrapConfig.majorVersion, - settings: legacyResolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues), - }, - experimental: { - ...config.experimental, - orioledb_version: bootstrapConfig.orioledbVersion, - s3_host: bootstrapConfig.s3Host, - s3_region: bootstrapConfig.s3Region, - s3_access_key: bootstrapConfig.s3AccessKey, - s3_secret_key: bootstrapConfig.s3SecretKey, - }, - jwtSecret: values.jwtSecret, - jwtExpiry: values.authJwtExpiry, - projectId, - networkId, - configImage: bootstrapConfig.postgresImage, - rootKey: values.rootKey, - fromBackup, - }, + containerOpts, + // `fromBackup` (if set) drives BOTH the restore-entrypoint variant and + // `legacyStartDatabase`'s own backup-volume-exists guard — `db reset` has no + // `fromBackup` concept at all, so `postgresSpecBase` omits it. + postgresSpec: { ...postgresSpecBase, fromBackup }, // Go's `db start` never pre-pulls any OTHER service's image (it has no // `ensureImagesCached`-equivalent pre-pull pass at all — `internal/start/start.go`'s own // pre-pull is top-level-`start`-only) — only the `db` container's own image, resolved // lazily, right where Go's `DockerStart` would resolve it internally // (`DockerResolveImageIfNotCached`, `internal/utils/docker.go:363-365`). - resolvePostgresImage: legacyEnsureImagesCached( - spawner, - [bootstrapConfig.postgresImage], - projectEnvValues, - ).pipe( - Effect.map( - (resolved) => - resolved.get(bootstrapConfig.postgresImage) ?? bootstrapConfig.postgresImage, - ), - ), + resolvePostgresImage, dbHealthTimeoutSeconds: bootstrapConfig.dbHealthTimeoutSeconds, - setup: { - majorVersion: bootstrapConfig.majorVersion, - experimental, - config: { - ...config, - realtime: { - ...config.realtime, - enabled: bootstrapConfig.realtimeEnabledForSetup, - ip_version: bootstrapConfig.realtimeIpVersion, - max_header_length: bootstrapConfig.realtimeMaxHeaderLength, - }, - storage: { - ...config.storage, - enabled: bootstrapConfig.storageEnabledForSetup, - file_size_limit: bootstrapConfig.storageFileSizeLimit, - }, - auth: { - ...config.auth, - enabled: bootstrapConfig.authEnabledForSetup, - }, - }, - dbUrl: values.dbUrl, - jwtSecret: values.jwtSecret, - // Go's `initSchema15`'s realtime job resolves JWKS itself, LOCALLY, gated on - // `Realtime.Enabled` (`internal/db/start/start.go:337-341`) — unlike `supabase - // start`'s OWN unconditional, up-front `ResolveJWKS` call (which also feeds the - // long-running Realtime/GoTrue/PostgREST containers `db start` never creates). - // `legacyStartDatabase` only evaluates this Effect when reached AND - // `realtimeEnabledForSetup` — see its own header for why this is lazy. - jwks: Effect.tryPromise({ - try: () => - legacyResolveLocalJwks(config, cliConfig.workdir, values.jwtSecret, projectEnvValues), - catch: (cause) => - new LegacyDbConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }), - apiUrl: values.apiUrl, - authExternalUrl: legacyResolveAuthExternalUrl(loaded?.document, projectEnvValues), - siteUrl: values.authSiteUrl, - anonKey: values.anonKey, - serviceRoleKey: values.serviceRoleKey, - storageTargetMigration: bootstrapConfig.storageTargetMigration, - realtimeEnabledForSetup: bootstrapConfig.realtimeEnabledForSetup, - storageEnabledForSetup: bootstrapConfig.storageEnabledForSetup, - authEnabledForSetup: bootstrapConfig.authEnabledForSetup, - serviceVersionOverrides: bootstrapConfig.serviceVersionOverrides, - projectEnvValues, - }, + // Go's `initSchema15`'s realtime job resolves JWKS itself, LOCALLY, gated on + // `Realtime.Enabled` (`internal/db/start/start.go:337-341`) — unlike `supabase + // start`'s OWN unconditional, up-front `ResolveJWKS` call (which also feeds the + // long-running Realtime/GoTrue/PostgREST containers `db start` never creates). + // `legacyStartDatabase` only evaluates this Effect when reached AND + // `realtimeEnabledForSetup` — see its own header for why this is lazy. + setup, onFreshVolumeResolved: (resolved) => { isFreshVolume = resolved; }, 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 index 6c6a6c3a62..f1e13fb53c 100644 --- a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts @@ -544,7 +544,7 @@ describe("legacy db start", () => { return Effect.gen(function* () { // The log dump (`legacyWaitForHealthyServices`'s own unconditional behavior on timeout, // teed straight to the real process stderr, not the mocked `Output` service) still runs — - // exercised by every other health-timeout test via the shared `../../../shared/db-bootstrap/health-check.ts` suite; + // exercised by every other health-timeout test via the shared `../../../shared/containers/health-check.ts` suite; // this test only asserts the command-level outcome that's specific to `--from-backup`. yield* legacyDbStart(flags("/abs/host/backup.sql")).pipe(Effect.provide(layer)); expect(rollbackWasAttempted(child.spawned)).toBe(false); diff --git a/apps/cli/src/legacy/commands/db/start/start.layers.ts b/apps/cli/src/legacy/commands/db/start/start.layers.ts index 8185bcc681..44524a7fba 100644 --- a/apps/cli/src/legacy/commands/db/start/start.layers.ts +++ b/apps/cli/src/legacy/commands/db/start/start.layers.ts @@ -13,12 +13,12 @@ import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-s * `FileSystem`/`Path` are ambient from the root runtime (`shared/cli/run.ts`), matching * `supabase start`'s own layer composition (`start.command.ts`). * - * No `LegacyDbBootstrapSeam` composition — `db start` no longer calls into the `db - * __db-bootstrap` Go seam at all after CLI-1954: `legacyIsLocalDbRunning` (the - * already-running check) and `legacyStartDatabase` (the container bring-up itself) are - * both native TS, hoisted to `legacy/shared/db-bootstrap/`. `db reset --local` still - * composes `legacyDbBootstrapSeamLayer` for its own container-recreate + storage-health - * primitives (`reset.layers.ts`). + * No `LegacyDbBootstrapSeam` composition — that hidden `db __db-bootstrap` Go seam no + * longer exists at all (CLI-1954 removed its `start` dispatch, CLI-1955 removed the + * rest): `legacyIsLocalDbRunning` (the already-running check) and `legacyStartDatabase` + * (the container bring-up itself) are both native TS, hoisted to + * `legacy/shared/db-bootstrap/`. `db reset --local` is ALSO fully native now, via its + * own composition over the same primitives (`reset.layers.ts`). * * `legacyDockerRunLayer`/`legacyDbConnectionLayer`/`legacyHttpClientLayer` back the native * container bootstrap itself (`start.handler.ts`): the fresh-volume `SetupLocalDatabase`- diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md index 8cef17a6b9..429c91a18d 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -92,7 +92,7 @@ command (Go's `return seedErr` instead of the downgraded `return err`). | GCP JWT credentials file | JSON | when `analytics.backend = "bigquery"` | | `/supabase/roles.sql` | SQL | on a fresh volume (custom-roles seed) — the "Seeding globals..." message always prints first; the file itself is only read if it exists, tolerating a missing file | | `/supabase/migrations/*.sql`, `supabase/seed.sql` | SQL | on a fresh volume, via the standard migration-apply + seed pipeline | -| `/supabase/` (files/directories/globs) | SQL | on a fresh volume, INSTEAD of `migrations/*.sql`, when `--experimental`/`SUPABASE_EXPERIMENTAL` is set and `[experimental.pgdelta] enabled` is false | +| `/supabase/` (files/directories/globs) | SQL | on a fresh volume, INSTEAD of `migrations/*.sql`, when `--experimental`/`SUPABASE_EXPERIMENTAL` is set and `[experimental.pgdelta] enabled` is false | | `/supabase/.branches/_current_branch` | text | on every start, existence check before writing (see "Files Written") | | `/supabase/functions/**` | — | when Edge Runtime starts, and independently when Studio starts (function discovery/config resolution + Docker bind mounts, regardless of whether Edge Runtime itself is enabled) | | `/supabase/.temp/storage-migration` | text | always — linked-project Storage migration pin (`DB_MIGRATIONS_FREEZE_AT`), written by `supabase link`; absent/unreadable resolves to no pin | @@ -161,16 +161,16 @@ not implemented. ## Environment Variables -| Variable | Purpose | Required? | -| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_*` (any dotted config field) | Generic Viper-style `AutomaticEnv` override of any `config.toml` field (e.g. `SUPABASE_AUTH_ENABLED`, `SUPABASE_API_PORT`) | no | -| `SUPABASE_EXPERIMENTAL` (or `--experimental`) | Fresh volume + no pg-delta: applies `db.migrations.schema_paths` files instead of `migrations/*.sql` (see "Fresh-volume DB setup" above) | no | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | Overrides the image registry used to resolve every service's image | no | -| `SUPABASE_PROJECT_ID` | Overrides the resolved local project id (env → config.toml → workdir basename) | no | -| `SUPABASE_WORKDIR` | Resolves `LegacyCliConfig.workdir` | no | -| `BITBUCKET_CLONE_DIR` | When non-empty, drops named volumes and `--security-opt` from every container create | no | -| `DOCKER_HOST` | Read to discover the Docker daemon's own address, then re-derived and set on Vector's container env so it can reach the host's Docker socket for log collection | no | -| `KONG_NGINX_WORKER_PROCESSES` | Read (ambient shell or project dotenv) into Kong's own container env (defaults to `"1"` when unset) | no | +| Variable | Purpose | Required? | +| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_*` (any dotted config field) | Generic Viper-style `AutomaticEnv` override of any `config.toml` field (e.g. `SUPABASE_AUTH_ENABLED`, `SUPABASE_API_PORT`) | no | +| `SUPABASE_EXPERIMENTAL` (or `--experimental`) | Fresh volume + no pg-delta: applies `db.migrations.schema_paths` files instead of `migrations/*.sql` (see "Fresh-volume DB setup" above) | no | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | Overrides the image registry used to resolve every service's image | no | +| `SUPABASE_PROJECT_ID` | Overrides the resolved local project id (env → config.toml → workdir basename) | no | +| `SUPABASE_WORKDIR` | Resolves `LegacyCliConfig.workdir` | no | +| `BITBUCKET_CLONE_DIR` | When non-empty, drops named volumes and `--security-opt` from every container create | no | +| `DOCKER_HOST` | Read to discover the Docker daemon's own address, then re-derived and set on Vector's container env so it can reach the host's Docker socket for log collection | no | +| `KONG_NGINX_WORKER_PROCESSES` | Read (ambient shell or project dotenv) into Kong's own container env (defaults to `"1"` when unset) | no | `docker`/`podman` must be resolvable on `PATH` — same fallback behavior as `stop`/`status`. diff --git a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts index 36fe45afbf..6e3d26b8f7 100644 --- a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts +++ b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts @@ -13,8 +13,8 @@ * ``` * * Unlike its 12 siblings in this directory, this module does NOT build a - * `LegacyStartContainerSpec` for `legacyStartContainer` - * (`../../../shared/db-bootstrap/container-lifecycle.ts`) to create+start uniformly. That + * `LegacyStartContainerSpec` for `legacyCreateContainer` + * (`../../../shared/containers/container-lifecycle.ts`) to create+start uniformly. That * unification (`docker create`/`docker start`, `-e KEY`-only env with values * supplied via the spawned process's own environment) was evaluated against * what `shared/functions/serve.ts`'s `startEdgeRuntimeContainer` actually @@ -40,7 +40,7 @@ * exactly as `functions serve` already spawns it (see that module's own doc * comment), and exposes {@link legacyStartEdgeRuntimeContainer} as a direct * bring-up `Effect` for `start.handler.ts` to call from its own bring-up loop - * — NOT a spec for `legacyStartContainer` to create. `start.handler.ts`'s + * — NOT a spec for `legacyCreateContainer` to create. `start.handler.ts`'s * wiring must special-case Edge Runtime's bring-up call, the same way it * already special-cases Postgres's (also called directly, not through the * generic `buildSpecForService` switch, since it needs its own health-wait @@ -138,21 +138,21 @@ export interface LegacyEdgeRuntimeBringUpInput { * `startEdgeRuntimeContainer` (already ported for `functions serve`) with * `start`'s own already-resolved config/secrets in place of that command's * independent config-loading pipeline. `start.handler.ts`'s bring-up loop - * should call this directly (NOT `legacyStartContainer`) for the Edge Runtime + * should call this directly (NOT `legacyCreateContainer`) for the Edge Runtime * entry in its service list, gated the same way as every other service on * `config.edge_runtime.enabled && !isContainerExcluded(...)`. * * Resolves to the same `StartedRuntime` shape `functions serve` itself * gets back. `containerId` is what the caller adds to its post-bring-up * health-wait list (pairing it with an `edgeRuntime` gateway on - * `LegacyWaitForHealthyServicesOptions`, `../../../shared/db-bootstrap/health-check.ts` — the same + * `LegacyWaitForHealthyServicesOptions`, `../../../shared/containers/health-check.ts` — the same * shape as the existing `postgrest` gateway). `watchSpecs` is * `functions serve`-only file-watch plumbing and can be ignored here. * * `cleanup` (removing the temp env-file/multiline-env-script/serve-main- * template files this call writes to the host) is intentionally left to the * caller, and the caller must NOT invoke it on a successful bring-up. Unlike - * every other `start` service (`legacyStartContainer`'s `restartPolicy: + * every other `start` service (`legacyCreateContainer`'s `restartPolicy: * "unless-stopped"`), Go's own Edge Runtime bring-up (`serve.ServeFunctions`, * `internal/functions/serve/serve.go:218-241`) sets NO Docker restart policy * at all — its lifecycle is deliberately reconciled at the CLI level @@ -162,7 +162,7 @@ export interface LegacyEdgeRuntimeBringUpInput { * still exist for as long as the container itself can be reattached to * (e.g. a plain `docker start` by the user, or discovery by a later CLI * invocation) — the same reasoning `legacyStageStartSecretFiles` - * (`../../../shared/db-bootstrap/container-lifecycle.ts`) already applies to every other service's + * (`../../../shared/containers/container-lifecycle.ts`) already applies to every other service's * staged secret files. `startEdgeRuntimeContainer` (`shared/functions/ * serve.ts`) already runs `cleanup` internally on any failed or interrupted * bring-up (`Effect.onError`, covering the whole staging-write-through- diff --git a/apps/cli/src/legacy/commands/start/services/gotrue.service.ts b/apps/cli/src/legacy/commands/start/services/gotrue.service.ts index 12832ca852..00be416397 100644 --- a/apps/cli/src/legacy/commands/start/services/gotrue.service.ts +++ b/apps/cli/src/legacy/commands/start/services/gotrue.service.ts @@ -60,7 +60,7 @@ import { } from "../../../shared/legacy-go-duration.ts"; import { LEGACY_DEFAULT_SIGNING_KEY } from "../../../shared/legacy-go-jwt.ts"; import type { LegacyResolvedAuthEmail } from "../../../shared/legacy-local-config-values.ts"; -import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import type { LegacyStartContainerSpec } from "../../../shared/containers/docker-create-args.ts"; import { legacyStartInternalDbPassword, legacyStartInternalDbUrl, diff --git a/apps/cli/src/legacy/commands/start/services/imgproxy.service.ts b/apps/cli/src/legacy/commands/start/services/imgproxy.service.ts index f3d0fac655..e2ef4b0a34 100644 --- a/apps/cli/src/legacy/commands/start/services/imgproxy.service.ts +++ b/apps/cli/src/legacy/commands/start/services/imgproxy.service.ts @@ -17,7 +17,7 @@ */ import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; -import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import type { LegacyStartContainerSpec } from "../../../shared/containers/docker-create-args.ts"; /** * Go's `Env` literal (`start.go:1065-1075`) — entirely static, no diff --git a/apps/cli/src/legacy/commands/start/services/kong.service.ts b/apps/cli/src/legacy/commands/start/services/kong.service.ts index e4b0dd8c92..890793ecf6 100644 --- a/apps/cli/src/legacy/commands/start/services/kong.service.ts +++ b/apps/cli/src/legacy/commands/start/services/kong.service.ts @@ -56,7 +56,7 @@ import * as nodePath from "node:path"; -import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import type { LegacyStartContainerSpec } from "../../../shared/containers/docker-create-args.ts"; import { legacyEnvOrDefault } from "../lib/legacy-env-or-default.ts"; import { legacyRenderStartKongYml } from "../lib/template-render.ts"; import { LEGACY_START_CUSTOM_NGINX_TEMPLATE } from "../templates/custom_nginx.template.ts"; diff --git a/apps/cli/src/legacy/commands/start/services/logflare.service.ts b/apps/cli/src/legacy/commands/start/services/logflare.service.ts index 43bb835740..4cd048633d 100644 --- a/apps/cli/src/legacy/commands/start/services/logflare.service.ts +++ b/apps/cli/src/legacy/commands/start/services/logflare.service.ts @@ -19,7 +19,7 @@ import { join } from "node:path"; import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; -import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import type { LegacyStartContainerSpec } from "../../../shared/containers/docker-create-args.ts"; /** `utils.LogflareAliases[0]` (`apps/cli-go/internal/utils/config.go:47`) — also this service's `containerSuffix` in `LEGACY_SERVICE_CATALOG`. */ const LEGACY_LOGFLARE_CONTAINER_SUFFIX = "analytics"; diff --git a/apps/cli/src/legacy/commands/start/services/mailpit.service.ts b/apps/cli/src/legacy/commands/start/services/mailpit.service.ts index 3ffd8b156f..7c6321a567 100644 --- a/apps/cli/src/legacy/commands/start/services/mailpit.service.ts +++ b/apps/cli/src/legacy/commands/start/services/mailpit.service.ts @@ -11,7 +11,7 @@ */ import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; -import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import type { LegacyStartContainerSpec } from "../../../shared/containers/docker-create-args.ts"; /** * `utils.InbucketAliases[0]` (`apps/cli-go/internal/utils/config.go:39`) — also diff --git a/apps/cli/src/legacy/commands/start/services/pg-meta.service.ts b/apps/cli/src/legacy/commands/start/services/pg-meta.service.ts index f5ee9cfe37..10f65bb8c4 100644 --- a/apps/cli/src/legacy/commands/start/services/pg-meta.service.ts +++ b/apps/cli/src/legacy/commands/start/services/pg-meta.service.ts @@ -16,7 +16,7 @@ * {@link legacyBuildPgMetaContainerSpec} is the only exported entry point. */ -import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import type { LegacyStartContainerSpec } from "../../../shared/containers/docker-create-args.ts"; /** Go's hardcoded pg-meta listen port (`start.go:1117`, `PG_META_PORT=8080`) — never configurable. */ const PG_META_PORT = 8080; diff --git a/apps/cli/src/legacy/commands/start/services/postgrest.service.ts b/apps/cli/src/legacy/commands/start/services/postgrest.service.ts index 843192b117..f1fdfb3438 100644 --- a/apps/cli/src/legacy/commands/start/services/postgrest.service.ts +++ b/apps/cli/src/legacy/commands/start/services/postgrest.service.ts @@ -13,7 +13,7 @@ * `Healthcheck:` entry — confirmed by reading the struct literal itself, not * just the comment. PostgREST readiness is instead checked at runtime via an * HTTP HEAD through the local Kong gateway - * (`legacyCheckHttpReady`/`LEGACY_POSTGREST_READY_PATH`, `../../../shared/db-bootstrap/health-check.ts`, + * (`legacyCheckHttpReady`/`LEGACY_POSTGREST_READY_PATH`, `../../../shared/containers/health-check.ts`, * itself porting `status.go:159-229`'s "PostgREST does not support native * health checks" branch) — this builder correctly omits `healthcheck` so * `legacyBuildStartContainerCreateArgs` never emits a `--health-*` flag for @@ -24,7 +24,7 @@ import type { ProjectConfig } from "@supabase/config"; import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; -import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import type { LegacyStartContainerSpec } from "../../../shared/containers/docker-create-args.ts"; import { legacyStartInternalDbPassword, legacyStartInternalDbUrl, diff --git a/apps/cli/src/legacy/commands/start/services/realtime.service.ts b/apps/cli/src/legacy/commands/start/services/realtime.service.ts index 9ec19afa59..9f6f61fa21 100644 --- a/apps/cli/src/legacy/commands/start/services/realtime.service.ts +++ b/apps/cli/src/legacy/commands/start/services/realtime.service.ts @@ -18,7 +18,7 @@ import { LEGACY_REALTIME_TENANT_ID, legacyBuildRealtimeEnv, } from "../../../shared/db-bootstrap/realtime-env.ts"; -import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import type { LegacyStartContainerSpec } from "../../../shared/containers/docker-create-args.ts"; import { legacyStartInternalDbPassword } from "../../../shared/db-bootstrap/internal-db-connection.ts"; export interface LegacyRealtimeContainerSpecInput { diff --git a/apps/cli/src/legacy/commands/start/services/storage.service.ts b/apps/cli/src/legacy/commands/start/services/storage.service.ts index 3cd416049d..677b6eb437 100644 --- a/apps/cli/src/legacy/commands/start/services/storage.service.ts +++ b/apps/cli/src/legacy/commands/start/services/storage.service.ts @@ -44,7 +44,7 @@ import type { ProjectConfig } from "@supabase/config"; import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; import { ramInBytes } from "../../../shared/legacy-size-units.ts"; -import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import type { LegacyStartContainerSpec } from "../../../shared/containers/docker-create-args.ts"; import { legacyEnvOrDefault } from "../lib/legacy-env-or-default.ts"; import { legacyStartInternalDbUrl, diff --git a/apps/cli/src/legacy/commands/start/services/studio.service.ts b/apps/cli/src/legacy/commands/start/services/studio.service.ts index 7e63938a30..beee36a88c 100644 --- a/apps/cli/src/legacy/commands/start/services/studio.service.ts +++ b/apps/cli/src/legacy/commands/start/services/studio.service.ts @@ -29,7 +29,7 @@ import { join } from "node:path"; import { legacyToDockerPath } from "../../../shared/legacy-docker-path.ts"; -import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import type { LegacyStartContainerSpec } from "../../../shared/containers/docker-create-args.ts"; /** Container-internal port Studio listens on — Go's hardcoded `3000/tcp` (`start.go:1166,1174`). */ const STUDIO_CONTAINER_PORT = 3000; diff --git a/apps/cli/src/legacy/commands/start/services/supavisor.service.ts b/apps/cli/src/legacy/commands/start/services/supavisor.service.ts index 3da730f4e5..2898b3f205 100644 --- a/apps/cli/src/legacy/commands/start/services/supavisor.service.ts +++ b/apps/cli/src/legacy/commands/start/services/supavisor.service.ts @@ -39,7 +39,7 @@ */ import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; -import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import type { LegacyStartContainerSpec } from "../../../shared/containers/docker-create-args.ts"; import { legacyRenderStartPoolerExs, type LegacyStartPoolerExsFields, diff --git a/apps/cli/src/legacy/commands/start/services/vector.service.ts b/apps/cli/src/legacy/commands/start/services/vector.service.ts index 2664d2e469..8caf0cefa5 100644 --- a/apps/cli/src/legacy/commands/start/services/vector.service.ts +++ b/apps/cli/src/legacy/commands/start/services/vector.service.ts @@ -36,7 +36,7 @@ import { Effect, Stream } from "effect"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; -import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import type { LegacyStartContainerSpec } from "../../../shared/containers/docker-create-args.ts"; import { legacyRenderStartVectorYaml } from "../lib/template-render.ts"; type Spawner = ChildProcessSpawner["Service"]; diff --git a/apps/cli/src/legacy/commands/start/start.gates.ts b/apps/cli/src/legacy/commands/start/start.gates.ts index c12af51a9b..5fafe29351 100644 --- a/apps/cli/src/legacy/commands/start/start.gates.ts +++ b/apps/cli/src/legacy/commands/start/start.gates.ts @@ -5,7 +5,7 @@ import type { LocalServiceVersionName, LocalServiceVersionOverrides, } from "../../../shared/services/services.shared.ts"; -import { legacyResolvePinnedImage } from "../../shared/db-bootstrap/pinned-image.ts"; +import { legacyResolvePinnedImage } from "../../shared/containers/pinned-image.ts"; import { legacyEnvOverrideBool } from "../../shared/legacy-local-config-values.ts"; import { LEGACY_START_SERVICES } from "./start.services.ts"; diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index a845227551..2cd3c6a22c 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -133,15 +133,15 @@ import { legacyResolveDbBootstrapConfig } from "../../shared/db-bootstrap/bootst import { legacyStartDatabase } from "../../shared/db-bootstrap/start-database.ts"; import { LEGACY_START_SERVICES } from "./start.services.ts"; import { - legacyStartContainer, - type LegacyStartContainerOpts, -} from "../../shared/db-bootstrap/container-lifecycle.ts"; -import { legacyEnsureImagesCached } from "../../shared/db-bootstrap/image-prepull.ts"; + legacyCreateContainer, + type LegacyContainerOpts, +} from "../../shared/containers/container-lifecycle.ts"; +import { legacyEnsureImagesCached } from "../../shared/containers/image-prepull.ts"; import { legacyWaitForHealthyServices, type LegacyHealthCheckPostgrestGateway, type LegacyHealthCheckTimeoutError, -} from "../../shared/db-bootstrap/health-check.ts"; +} from "../../shared/containers/health-check.ts"; import { legacyStartInternalDbPassword, LEGACY_START_INTERNAL_DB_NAME, @@ -198,8 +198,8 @@ function asRecord(value: unknown): Record | undefined { /** * Docker's/Podman's "container doesn't exist" stderr shapes for `container inspect`: "No such * container" or "No such object" depending on daemon version/CLI path — the same pair already - * handled in `shared/functions/serve.ts`/`legacy-db-bootstrap.seam.layer.ts`/`legacy-pgdelta.seam. - * layer.ts`. + * handled in `shared/functions/serve.ts`/`legacy/shared/db-bootstrap/local-db-running.ts`/ + * `legacy-pgdelta.seam.layer.ts`. */ function isContainerNotFoundMessage(message: string): boolean { return ( @@ -627,7 +627,7 @@ function buildKongEmailTemplateMounts( /** * What `--ignore-health-check` prints when it downgrades a health-check timeout - * to a warning. That decision belongs to this caller, not `../../shared/db-bootstrap/health-check.ts` + * to a warning. That decision belongs to this caller, not `../../shared/containers/health-check.ts` * (which only implements the polling contract), and it writes straight to * stderr — bypassing the `Output.fail` renderer that would otherwise append the * error's `suggestion` for it. @@ -1146,7 +1146,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // (`legacy-edge-runtime-script.layer.ts`). const extraHosts = runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; - const startOpts: LegacyStartContainerOpts = { + const startOpts: LegacyContainerOpts = { projectId, isBitbucketPipeline, workdir: cliConfig.workdir, @@ -2005,7 +2005,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta const runtime: StartedRuntime = yield* legacyStartEdgeRuntimeContainer(edgeRuntimeInput); // Deliberately NOT calling `runtime.cleanup` here — see // `edge-runtime.service.ts`'s header for why. Unlike every other - // service built here (`legacyStartContainer`'s `restartPolicy: + // service built here (`legacyCreateContainer`'s `restartPolicy: // "unless-stopped"`), Go's own Edge Runtime bring-up sets no Docker // restart policy at all, so this container's `docker run` matches // that — but its bind-mounted host temp files must still exist for @@ -2049,7 +2049,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta ), ), ); - yield* legacyStartContainer(spawner, spec, startOpts); + yield* legacyCreateContainer(spawner, spec, startOpts); if (excludeFromHealthWatch !== true) { started.set(spec.containerName, spec.image); } diff --git a/apps/cli/src/legacy/commands/start/start.integration.test.ts b/apps/cli/src/legacy/commands/start/start.integration.test.ts index 26cf7e2510..2d36a35812 100644 --- a/apps/cli/src/legacy/commands/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts @@ -255,7 +255,7 @@ function freshVolumeRoute( base: (args: ReadonlyArray) => RouteResult, ): (args: ReadonlyArray) => RouteResult { return (args) => { - // `legacyStartVolumeExists` now distinguishes a confirmed "not found" from + // `legacyVolumeExists` now distinguishes a confirmed "not found" from // any other inspect error (matching Go's `errdefs.IsNotFound` gate) — the // stderr text is what makes this simulate a genuinely fresh/non-existent // volume rather than an ambiguous inspect failure. @@ -2911,7 +2911,7 @@ content_path = "./templates/custom_notice.html" expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const serialized = JSON.stringify(exit.cause); - expect(serialized).toContain("LegacyStartNetworkCreateError"); + expect(serialized).toContain("LegacyNetworkCreateError"); expect(serialized).toContain("failed to create docker network"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); @@ -2936,7 +2936,7 @@ content_path = "./templates/custom_notice.html" expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const serialized = JSON.stringify(exit.cause); - expect(serialized).toContain("LegacyStartContainerCreateError"); + expect(serialized).toContain("LegacyContainerCreateError"); expect(serialized).toContain("failed to create docker container"); } expect(rollbackWasAttempted(child.spawned)).toBe(true); @@ -2962,7 +2962,7 @@ content_path = "./templates/custom_notice.html" expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const serialized = JSON.stringify(exit.cause); - expect(serialized).toContain("LegacyStartContainerStartError"); + expect(serialized).toContain("LegacyContainerStartError"); expect(serialized).toContain("port is already allocated"); expect(serialized).toContain( "Try stopping the project or container already using 0.0.0.0:54322", @@ -3205,7 +3205,7 @@ content_path = "./templates/custom_notice.html" // Node event-loop turns to settle — under a virtualized `TestClock` those // never resolve, so the forked fiber never even reaches the health-check // phase. This exercises the real 30s `serviceTimeout` bulk health-check - // wait (`../../shared/db-bootstrap/health-check.ts`'s default), hence the generous timeout. + // wait (`../../shared/containers/health-check.ts`'s default), hence the generous timeout. it.live( "exits 0 on --ignore-health-check when a non-Postgres container never turns healthy, without rolling back", () => { diff --git a/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md index 0dde5cdd17..adb3b241ac 100644 --- a/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md @@ -22,7 +22,7 @@ The `start-secrets` removal is a TS-port-only hygiene step (`legacyCleanupStartS `legacy/shared/legacy-start-secrets-cleanup.ts`) — Go never stages secrets on host disk in the first place, so it has nothing to clean up here. `start` stages plaintext Kong TLS/ `kong.yml`, Postgres pgsodium root key, Supavisor pooler tenant-script content -(`legacyStageStartSecretFiles`, `legacy/shared/db-bootstrap/container-lifecycle.ts`), and Edge Runtime's own +(`legacyStageStartSecretFiles`, `legacy/shared/containers/container-lifecycle.ts`), and Edge Runtime's own JWT/service-role-key/secret env artifacts (`shared/functions/serve.ts`'s `writeDockerEnvFile`/`writeDockerMultilineEnvScript`/`writeServeMainTemplateFile`) on host disk because this port shells out to `docker create`/`docker run` instead of using the diff --git a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts b/apps/cli/src/legacy/shared/containers/container-lifecycle.ts similarity index 85% rename from apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts rename to apps/cli/src/legacy/shared/containers/container-lifecycle.ts index 500172df58..fad0168ab2 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts +++ b/apps/cli/src/legacy/shared/containers/container-lifecycle.ts @@ -8,17 +8,22 @@ * `docker create` + `docker start`. * * Network creation (`DockerNetworkCreateIfNotExists`) is deliberately NOT part - * of this per-container function — see {@link legacyEnsureStartNetwork}'s doc + * of this per-container function — see {@link legacyEnsureNetwork}'s doc * comment for why it is hoisted to run once instead of once per container. */ import { chmod, mkdir, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { Data, Effect, Stream } from "effect"; +import { Data, Effect } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; -import { legacyDescribeContainerCliFailure, spawnContainerCli } from "../legacy-container-cli.ts"; +import { + collectText, + legacyDescribeContainerCliFailure, + runContainerCliExpectSuccess, + spawnContainerCli, +} from "../legacy-container-cli.ts"; import { legacyBindMountSpecSource, legacyIsBindMountSource, @@ -55,37 +60,31 @@ type Spawner = ChildProcessSpawner["Service"]; export const LEGACY_COMPOSE_PROJECT_LABEL = "com.docker.compose.project"; /** `docker network create --label ...`/`docker volume create --label ...` failed. */ -export class LegacyStartNetworkCreateError extends Data.TaggedError( - "LegacyStartNetworkCreateError", -)<{ +export class LegacyNetworkCreateError extends Data.TaggedError("LegacyNetworkCreateError")<{ readonly message: string; }> {} -export class LegacyStartVolumeCreateError extends Data.TaggedError("LegacyStartVolumeCreateError")<{ +export class LegacyVolumeCreateError extends Data.TaggedError("LegacyVolumeCreateError")<{ readonly message: string; }> {} /** `docker create` failed. */ -export class LegacyStartContainerCreateError extends Data.TaggedError( - "LegacyStartContainerCreateError", -)<{ +export class LegacyContainerCreateError extends Data.TaggedError("LegacyContainerCreateError")<{ readonly message: string; }> {} /** `docker start` failed — see {@link legacyPortConflictSuggestion} for the port-already-allocated case. */ -export class LegacyStartContainerStartError extends Data.TaggedError( - "LegacyStartContainerStartError", -)<{ +export class LegacyContainerStartError extends Data.TaggedError("LegacyContainerStartError")<{ readonly message: string; }> {} -/** Every failure {@link legacyStartContainer} itself can produce (network creation is separate, see {@link legacyEnsureStartNetwork}). */ -export type LegacyStartContainerError = - | LegacyStartVolumeCreateError - | LegacyStartContainerCreateError - | LegacyStartContainerStartError; +/** Every failure {@link legacyCreateContainer} itself can produce (network creation is separate, see {@link legacyEnsureNetwork}). */ +export type LegacyContainerError = + | LegacyVolumeCreateError + | LegacyContainerCreateError + | LegacyContainerStartError; -export interface LegacyStartContainerOpts { +export interface LegacyContainerOpts { /** * Go's `Config.ProjectId`, already sanitized (`legacySanitizeProjectId`) by * the caller's config-load pipeline — `DockerStart` itself performs no @@ -128,15 +127,6 @@ export interface LegacyStartContainerOpts { readonly extraHosts: ReadonlyArray; } -function collectText(stream: Stream.Stream) { - const decoder = new TextDecoder(); - return Stream.runFold( - stream, - () => "", - (text, chunk) => text + decoder.decode(chunk, { stream: true }), - ).pipe(Effect.map((text) => text + decoder.decode())); -} - /** * Extracts every named-volume source from `binds` (Go's `loader.ParseVolume` * classification loop, `docker.go:388-399`): a bind is `source:target[:mode]` @@ -213,7 +203,7 @@ function legacyPortConflictSuggestion(hostPort: string, serviceLabel: string): s * already exists), so this is a pure optimization, not a behavior change: a * `start` run's containers are exclusively created by this same code path in * one process, never interleaved with an external network deletion, so the - * network is guaranteed to still exist for every later `legacyStartContainer` + * network is guaranteed to still exist for every later `legacyCreateContainer` * call in the same run. * * Mirrors Go's own `isUserDefined(mode)` guard (`docker.go:65`, @@ -226,11 +216,11 @@ function legacyPortConflictSuggestion(hostPort: string, serviceLabel: string): s * `isUserDefinedDockerNetwork` check `shared/functions/deploy.ts` already * applies for the unrelated `functions deploy` extension-gateway network. */ -export function legacyEnsureStartNetwork( +export function legacyEnsureNetwork( spawner: Spawner, networkId: string, labels: Readonly>, -): Effect.Effect { +): Effect.Effect { if (!isUserDefinedDockerNetwork(networkId)) { return Effect.void; } @@ -249,7 +239,7 @@ export function legacyEnsureStartNetwork( }).pipe( Effect.mapError( (cause) => - new LegacyStartNetworkCreateError({ + new LegacyNetworkCreateError({ message: `failed to create docker network: ${legacyDescribeContainerCliFailure(cause)}`, }), ), @@ -259,13 +249,13 @@ export function legacyEnsureStartNetwork( { concurrency: "unbounded" }, ).pipe( Effect.mapError( - () => new LegacyStartNetworkCreateError({ message: "failed to create docker network" }), + () => new LegacyNetworkCreateError({ message: "failed to create docker network" }), ), ); if (exitCode !== 0 && !legacyIsNetworkAlreadyExistsError(stderr)) { const message = stderr.trim(); return yield* Effect.fail( - new LegacyStartNetworkCreateError({ + new LegacyNetworkCreateError({ message: message.length > 0 ? `failed to create docker network: ${message}` @@ -283,11 +273,11 @@ export function legacyEnsureStartNetwork( * "already exists" tolerance here — `VolumeCreate` is already idempotent for a * repeated name with matching options, so any non-zero exit is a real failure. */ -export function legacyEnsureStartVolume( +export function legacyEnsureVolume( spawner: Spawner, name: string, labels: Readonly>, -): Effect.Effect { +): Effect.Effect { return Effect.scoped( Effect.gen(function* () { const args = [ @@ -303,7 +293,7 @@ export function legacyEnsureStartVolume( }).pipe( Effect.mapError( (cause) => - new LegacyStartVolumeCreateError({ + new LegacyVolumeCreateError({ message: `failed to create volume: ${legacyDescribeContainerCliFailure(cause)}`, }), ), @@ -312,14 +302,12 @@ export function legacyEnsureStartVolume( [child.exitCode.pipe(Effect.map(Number)), collectText(child.stderr)], { concurrency: "unbounded" }, ).pipe( - Effect.mapError( - () => new LegacyStartVolumeCreateError({ message: "failed to create volume" }), - ), + Effect.mapError(() => new LegacyVolumeCreateError({ message: "failed to create volume" })), ); if (exitCode !== 0) { const message = stderr.trim(); return yield* Effect.fail( - new LegacyStartVolumeCreateError({ + new LegacyVolumeCreateError({ message: message.length > 0 ? `failed to create volume: ${message}` @@ -332,9 +320,7 @@ export function legacyEnsureStartVolume( } /** `docker volume inspect` failed to spawn at all (no docker/podman binary). */ -export class LegacyStartVolumeInspectError extends Data.TaggedError( - "LegacyStartVolumeInspectError", -)<{ +export class LegacyVolumeInspectError extends Data.TaggedError("LegacyVolumeInspectError")<{ readonly message: string; }> {} @@ -361,17 +347,17 @@ function isVolumeNotFoundMessage(message: string): boolean { * regression Go's own gate doesn't have. Only a spawn failure (neither * `docker` nor `podman` on `PATH`) is a real error here. * - * A separate, additional export — NOT called from {@link legacyEnsureStartVolume} + * A separate, additional export — NOT called from {@link legacyEnsureVolume} * itself, whose existing idempotent-create behavior must not change. The caller * orchestrating a `start` run checks this BEFORE creating the volume, to gate the * `SetupLocalDatabase`-equivalent pipeline and bucket seeding on "was this a * fresh volume", matching Go's exact check-before-create ordering * (`internal/db/start/start.go:165-184`). */ -export function legacyStartVolumeExists( +export function legacyVolumeExists( spawner: Spawner, name: string, -): Effect.Effect { +): Effect.Effect { return Effect.scoped( Effect.gen(function* () { const child = yield* spawnContainerCli(spawner, ["volume", "inspect", name], { @@ -381,7 +367,7 @@ export function legacyStartVolumeExists( }).pipe( Effect.mapError( (cause) => - new LegacyStartVolumeInspectError({ + new LegacyVolumeInspectError({ message: `failed to inspect volume: ${legacyDescribeContainerCliFailure(cause)}`, }), ), @@ -391,7 +377,7 @@ export function legacyStartVolumeExists( { concurrency: "unbounded" }, ).pipe( Effect.mapError( - () => new LegacyStartVolumeInspectError({ message: "failed to inspect volume" }), + () => new LegacyVolumeInspectError({ message: "failed to inspect volume" }), ), ); if (exitCode === 0) return true; @@ -400,11 +386,63 @@ export function legacyStartVolumeExists( ); } +/** `docker container rm -f ` (or `docker rm -f`) failed. */ +export class LegacyContainerRemoveError extends Data.TaggedError("LegacyContainerRemoveError")<{ + readonly message: string; +}> {} + +/** + * Port of Go's `db reset`-only `Docker.ContainerRemove(ctx, DbId, + * container.RemoveOptions{Force: true})` (`apps/cli-go/internal/db/reset/reset.go:147-149`) + * via `docker container rm -f `. Unlike most other container lookups in this codebase, + * Go does NOT tolerate a "not found" response here — a genuine remove failure is a hard + * `failed to remove container: %w` — so this propagates ANY non-zero exit without the + * usual "no such container" swallow. `-f` alone (no `-v`) matches Go's `RemoveOptions`, + * which sets `Force` but not `RemoveVolumes` — the paired named volume is removed + * separately by {@link legacyRemoveVolume}. + */ +export function legacyRemoveContainer( + spawner: Spawner, + containerId: string, +): Effect.Effect { + return runContainerCliExpectSuccess( + spawner, + ["container", "rm", "-f", containerId], + "remove container", + (message) => new LegacyContainerRemoveError({ message }), + ); +} + +/** `docker volume rm -f ` failed. */ +export class LegacyVolumeRemoveError extends Data.TaggedError("LegacyVolumeRemoveError")<{ + readonly message: string; +}> {} + +/** + * Port of Go's `db reset`-only `Docker.VolumeRemove(ctx, DbId, true)` + * (`apps/cli-go/internal/db/reset/reset.go:150-152`) via `docker volume rm -f `. + * The `force` argument makes a MISSING volume a no-op (Docker's `DELETE /volumes/{name}` + * returns 204 even when the volume doesn't exist, once `force` is set — verified against + * a real Docker daemon), so — unlike {@link legacyRemoveContainer} — no special-casing is + * needed here: any non-zero exit is a genuine failure. + */ +export function legacyRemoveVolume( + spawner: Spawner, + volumeName: string, +): Effect.Effect { + return runContainerCliExpectSuccess( + spawner, + ["volume", "rm", "-f", volumeName], + "remove volume", + (message) => new LegacyVolumeRemoveError({ message }), + ); +} + function legacyDockerCreateContainer( spawner: Spawner, args: ReadonlyArray, env: Readonly>, -): Effect.Effect { +): Effect.Effect { return Effect.scoped( Effect.gen(function* () { // `docker-create-args.ts` emits the key-only `-e KEY` form (never `-e KEY=value`) so @@ -433,7 +471,7 @@ function legacyDockerCreateContainer( }).pipe( Effect.mapError( (cause) => - new LegacyStartContainerCreateError({ + new LegacyContainerCreateError({ message: `failed to create docker container: ${legacyDescribeContainerCliFailure(cause)}`, }), ), @@ -447,14 +485,13 @@ function legacyDockerCreateContainer( { concurrency: "unbounded" }, ).pipe( Effect.mapError( - () => - new LegacyStartContainerCreateError({ message: "failed to create docker container" }), + () => new LegacyContainerCreateError({ message: "failed to create docker container" }), ), ); if (exitCode !== 0) { const message = stderr.trim(); return yield* Effect.fail( - new LegacyStartContainerCreateError({ + new LegacyContainerCreateError({ message: message.length > 0 ? `failed to create docker container: ${message}` @@ -471,7 +508,7 @@ function legacyDockerStartContainer( spawner: Spawner, containerId: string, spec: LegacyStartContainerSpec, -): Effect.Effect { +): Effect.Effect { return Effect.scoped( Effect.gen(function* () { const child = yield* spawnContainerCli(spawner, ["start", containerId], { @@ -481,7 +518,7 @@ function legacyDockerStartContainer( }).pipe( Effect.mapError( (cause) => - new LegacyStartContainerStartError({ + new LegacyContainerStartError({ message: `failed to start docker container "${spec.containerName}": ${legacyDescribeContainerCliFailure(cause)}`, }), ), @@ -492,7 +529,7 @@ function legacyDockerStartContainer( ).pipe( Effect.mapError( () => - new LegacyStartContainerStartError({ + new LegacyContainerStartError({ message: `failed to start docker container "${spec.containerName}"`, }), ), @@ -504,11 +541,11 @@ function legacyDockerStartContainer( }`; const hostPort = legacyParsePortBindError(trimmed); if (hostPort === undefined) { - return yield* Effect.fail(new LegacyStartContainerStartError({ message: base })); + return yield* Effect.fail(new LegacyContainerStartError({ message: base })); } const serviceLabel = spec.networkAliases?.[0] ?? spec.containerName; return yield* Effect.fail( - new LegacyStartContainerStartError({ + new LegacyContainerStartError({ message: `${base}${legacyPortConflictSuggestion(hostPort, serviceLabel)}`, }), ); @@ -568,7 +605,7 @@ function legacyDockerStartContainer( * before writing fresh files, on every call — so a config change that * shrinks or removes `secretFiles` between `start` invocations never leaves a * stale file behind, and no orphaned directories accumulate across restarts. - * `legacyStartContainer` never resolves this for a container while an + * `legacyCreateContainer` never resolves this for a container while an * earlier instance of that same container might still be reading from it — * see that function's doc comment. */ @@ -578,7 +615,7 @@ function legacyStageStartSecretFiles( workdir: string, ): Effect.Effect< { readonly binds: ReadonlyArray; readonly cleanup: () => Promise }, - LegacyStartContainerCreateError + LegacyContainerCreateError > { const dir = join(workdir, "supabase", ".temp", "start-secrets", containerName); return Effect.tryPromise({ @@ -613,7 +650,7 @@ function legacyStageStartSecretFiles( } }, catch: (cause) => - new LegacyStartContainerCreateError({ + new LegacyContainerCreateError({ message: `failed to create docker container: failed to stage container secret files: ${ cause instanceof Error ? cause.message : String(cause) }`, @@ -624,7 +661,7 @@ function legacyStageStartSecretFiles( /** * Port of Go's `DockerStart` (`apps/cli-go/internal/utils/docker.go:363-440`), * minus image resolution (already done by `image-prepull.ts`) and network - * creation (hoisted, see {@link legacyEnsureStartNetwork}): + * creation (hoisted, see {@link legacyEnsureNetwork}): * * 1. Merge the two project-identity labels onto `spec.labels`. * 2. Provision this container's own named volumes (skipped entirely under @@ -654,11 +691,11 @@ function legacyStageStartSecretFiles( * * Resolves to the created container's id/name on success. */ -export function legacyStartContainer( +export function legacyCreateContainer( spawner: Spawner, spec: LegacyStartContainerSpec, - opts: LegacyStartContainerOpts, -): Effect.Effect { + opts: LegacyContainerOpts, +): Effect.Effect { return Effect.gen(function* () { const labels: Record = { ...spec.labels, @@ -666,7 +703,7 @@ export function legacyStartContainer( [LEGACY_COMPOSE_PROJECT_LABEL]: opts.projectId, }; // The workdir label is stamped on the CONTAINER only, not on its named volumes below - // (`legacyEnsureStartVolume` is passed `labels`, not `containerLabels`) — a volume's own + // (`legacyEnsureVolume` is passed `labels`, not `containerLabels`) — a volume's own // name already carries the project id, and nothing ever reads a workdir label back off a // volume the way `legacyListContainerIdsAndNames` does for containers. const containerLabels: Record = { @@ -681,7 +718,7 @@ export function legacyStartContainer( if (!opts.isBitbucketPipeline) { for (const name of legacyNamedVolumeSources(labeledSpec.binds)) { - yield* legacyEnsureStartVolume(spawner, name, labels); + yield* legacyEnsureVolume(spawner, name, labels); } } diff --git a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts b/apps/cli/src/legacy/shared/containers/container-lifecycle.unit.test.ts similarity index 83% rename from apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts rename to apps/cli/src/legacy/shared/containers/container-lifecycle.unit.test.ts index cf13ccf209..db39b7aa50 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts +++ b/apps/cli/src/legacy/shared/containers/container-lifecycle.unit.test.ts @@ -17,15 +17,19 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { afterEach, beforeEach } from "vitest"; import { - LegacyStartContainerCreateError, - LegacyStartContainerStartError, - LegacyStartNetworkCreateError, - LegacyStartVolumeCreateError, - LegacyStartVolumeInspectError, - legacyEnsureStartNetwork, - legacyEnsureStartVolume, - legacyStartContainer, - legacyStartVolumeExists, + LegacyContainerRemoveError, + LegacyContainerCreateError, + LegacyContainerStartError, + LegacyNetworkCreateError, + LegacyVolumeCreateError, + LegacyVolumeInspectError, + LegacyVolumeRemoveError, + legacyEnsureNetwork, + legacyEnsureVolume, + legacyRemoveContainer, + legacyRemoveVolume, + legacyCreateContainer, + legacyVolumeExists, } from "./container-lifecycle.ts"; import type { LegacyStartContainerSpec } from "./docker-create-args.ts"; @@ -116,12 +120,12 @@ function alwaysSucceed(stdout = "container-id-123\n") { }); } -describe("legacyStartContainer", () => { +describe("legacyCreateContainer", () => { it.live( "merges project + compose labels, provisions named volumes, then creates and starts", () => { const mock = alwaysSucceed(); - return legacyStartContainer(mock.spawner, baseSpec, { + return legacyCreateContainer(mock.spawner, baseSpec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -174,7 +178,7 @@ describe("legacyStartContainer", () => { // `toEqual`, so a regression that leaked the workdir label onto volumes too would fail that // test's exact-match assertion. const mock = alwaysSucceed(); - return legacyStartContainer(mock.spawner, baseSpec, { + return legacyCreateContainer(mock.spawner, baseSpec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -201,7 +205,7 @@ describe("legacyStartContainer", () => { ...baseSpec, env: { POSTGRES_PASSWORD: "s3cret", JWT_SECRET: "super-secret-value" }, }; - return legacyStartContainer(mock.spawner, spec, { + return legacyCreateContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -233,7 +237,7 @@ describe("legacyStartContainer", () => { ...baseSpec, env: { DOCKER_HOST: "http://host.docker.internal:2375", API_KEY: "s3cret" }, }; - return legacyStartContainer(mock.spawner, spec, { + return legacyCreateContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -252,7 +256,7 @@ describe("legacyStartContainer", () => { "skips volume creation and drops the named-volume bind + security-opt under Bitbucket Pipelines", () => { const mock = alwaysSucceed(); - return legacyStartContainer(mock.spawner, baseSpec, { + return legacyCreateContainer(mock.spawner, baseSpec, { projectId: "proj", isBitbucketPipeline: true, workdir, @@ -270,12 +274,12 @@ describe("legacyStartContainer", () => { }, ); - it.live("fails with LegacyStartVolumeCreateError before ever creating the container", () => { + it.live("fails with LegacyVolumeCreateError before ever creating the container", () => { const mock = mockSpawner((args) => { if (args[0] === "volume") return { exitCode: 1, stderr: "no space left on device\n" }; return { exitCode: 0, stdout: "should-not-be-created\n" }; }); - return legacyStartContainer(mock.spawner, baseSpec, { + return legacyCreateContainer(mock.spawner, baseSpec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -283,20 +287,20 @@ describe("legacyStartContainer", () => { }).pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartVolumeCreateError); + expect(error).toBeInstanceOf(LegacyVolumeCreateError); expect(error.message).toBe("failed to create volume: no space left on device"); expect(mock.spawned.some((args) => args[0] === "create")).toBe(false); }), ); }); - it.live("fails with LegacyStartContainerCreateError on a `docker create` non-zero exit", () => { + it.live("fails with LegacyContainerCreateError on a `docker create` non-zero exit", () => { const mock = mockSpawner((args) => { if (args[0] === "create") return { exitCode: 1, stderr: "no such image\n" }; return { exitCode: 0 }; }); const spec: LegacyStartContainerSpec = { ...baseSpec, binds: [] }; - return legacyStartContainer(mock.spawner, spec, { + return legacyCreateContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -304,21 +308,21 @@ describe("legacyStartContainer", () => { }).pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartContainerCreateError); + expect(error).toBeInstanceOf(LegacyContainerCreateError); expect(error.message).toBe("failed to create docker container: no such image"); expect(mock.spawned.some((args) => args[0] === "start")).toBe(false); }), ); }); - it.live("fails with LegacyStartContainerStartError, unmodified, on a plain start failure", () => { + it.live("fails with LegacyContainerStartError, unmodified, on a plain start failure", () => { const mock = mockSpawner((args) => { if (args[0] === "create") return { exitCode: 0, stdout: "abc\n" }; if (args[0] === "start") return { exitCode: 1, stderr: "container is already stopped\n" }; return { exitCode: 0 }; }); const spec: LegacyStartContainerSpec = { ...baseSpec, binds: [] }; - return legacyStartContainer(mock.spawner, spec, { + return legacyCreateContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -326,7 +330,7 @@ describe("legacyStartContainer", () => { }).pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartContainerStartError); + expect(error).toBeInstanceOf(LegacyContainerStartError); expect(error.message).toBe( 'failed to start docker container "supabase_db_proj": container is already stopped', ); @@ -349,7 +353,7 @@ describe("legacyStartContainer", () => { return { exitCode: 0 }; }); const spec: LegacyStartContainerSpec = { ...baseSpec, binds: [] }; - return legacyStartContainer(mock.spawner, spec, { + return legacyCreateContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -357,7 +361,7 @@ describe("legacyStartContainer", () => { }).pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartContainerStartError); + expect(error).toBeInstanceOf(LegacyContainerStartError); expect(error.message).toContain('failed to start docker container "supabase_db_proj"'); expect(error.message).toContain("0.0.0.0:5432"); expect(error.message).toContain("db port in supabase/config.toml"); @@ -367,7 +371,7 @@ describe("legacyStartContainer", () => { ); }); -describe("legacyStartContainer secretFiles", () => { +describe("legacyCreateContainer secretFiles", () => { it.live( "stages a secretFile as a mode-0644 HOST file (readable by non-root container users) under a mode-0700 deterministic, per-container directory, bind-mounts it read-only at the exact containerPath, keeps the raw content out of argv, and PERSISTS the file after a successful start so a `restartPolicy: unless-stopped` container can survive a host/daemon restart (CWE-214/522)", () => { @@ -392,7 +396,7 @@ describe("legacyStartContainer secretFiles", () => { secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "super-secret-content" }], }; - return legacyStartContainer(mock.spawner, spec, { + return legacyCreateContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -454,7 +458,7 @@ describe("legacyStartContainer secretFiles", () => { // after this effect actually completes, on success, failure, or defect alike. return Effect.sync(() => process.umask(0o077)).pipe( Effect.flatMap((originalUmask) => - legacyStartContainer(mock.spawner, spec, { + legacyCreateContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -484,7 +488,7 @@ describe("legacyStartContainer secretFiles", () => { secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "fresh-content" }], }; - return legacyStartContainer(mock.spawner, spec, { + return legacyCreateContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -515,7 +519,7 @@ describe("legacyStartContainer secretFiles", () => { secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "super-secret-content" }], }; - return legacyStartContainer(mock.spawner, spec, { + return legacyCreateContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -523,7 +527,7 @@ describe("legacyStartContainer secretFiles", () => { }).pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartContainerCreateError); + expect(error).toBeInstanceOf(LegacyContainerCreateError); expect(hostPath).toBeDefined(); expect(existsSync(hostPath ?? "")).toBe(false); }), @@ -552,7 +556,7 @@ describe("legacyStartContainer secretFiles", () => { secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "super-secret-content" }], }; - return legacyStartContainer(mock.spawner, spec, { + return legacyCreateContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -560,7 +564,7 @@ describe("legacyStartContainer secretFiles", () => { }).pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartContainerStartError); + expect(error).toBeInstanceOf(LegacyContainerStartError); expect(hostPath).toBeDefined(); // The container never successfully started, so nothing depends on the file surviving. expect(existsSync(hostPath ?? "")).toBe(false); @@ -630,7 +634,7 @@ describe("legacyStartContainer secretFiles", () => { }; return Effect.gen(function* () { - const fiber = yield* legacyStartContainer(spawner, spec, { + const fiber = yield* legacyCreateContainer(spawner, spec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -646,7 +650,7 @@ describe("legacyStartContainer secretFiles", () => { ); it.live( - "maps a staging write failure to LegacyStartContainerCreateError, without ever invoking `docker create`", + "maps a staging write failure to LegacyContainerCreateError, without ever invoking `docker create`", () => { const dir = join(workdir, "supabase", ".temp", "start-secrets", baseSpec.containerName); // `dir` itself doesn't exist yet, so the self-healing `rm(dir, ...)` up front is a no-op — @@ -664,7 +668,7 @@ describe("legacyStartContainer secretFiles", () => { secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "super-secret-content" }], }; - return legacyStartContainer(mock.spawner, spec, { + return legacyCreateContainer(mock.spawner, spec, { projectId: "proj", isBitbucketPipeline: false, workdir, @@ -672,7 +676,7 @@ describe("legacyStartContainer secretFiles", () => { }).pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartContainerCreateError); + expect(error).toBeInstanceOf(LegacyContainerCreateError); expect(error.message).toMatch( /^failed to create docker container: failed to stage container secret files: /, ); @@ -686,10 +690,10 @@ describe("legacyStartContainer secretFiles", () => { ); }); -describe("legacyEnsureStartNetwork", () => { +describe("legacyEnsureNetwork", () => { it.live("creates the network with labels", () => { const mock = mockSpawner(() => ({ exitCode: 0 })); - return legacyEnsureStartNetwork(mock.spawner, "supabase_network_proj", { + return legacyEnsureNetwork(mock.spawner, "supabase_network_proj", { "com.supabase.cli.project": "proj", "com.docker.compose.project": "proj", }).pipe( @@ -715,19 +719,19 @@ describe("legacyEnsureStartNetwork", () => { stderr: "Error response from daemon: network with name supabase_network_proj already exists\n", })); - return legacyEnsureStartNetwork(mock.spawner, "supabase_network_proj", {}).pipe( + return legacyEnsureNetwork(mock.spawner, "supabase_network_proj", {}).pipe( Effect.map(() => { // Just needs to not fail — no return value to assert on. }), ); }); - it.live("fails with LegacyStartNetworkCreateError on any other failure", () => { + it.live("fails with LegacyNetworkCreateError on any other failure", () => { const mock = mockSpawner(() => ({ exitCode: 1, stderr: "permission denied\n" })); - return legacyEnsureStartNetwork(mock.spawner, "supabase_network_proj", {}).pipe( + return legacyEnsureNetwork(mock.spawner, "supabase_network_proj", {}).pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartNetworkCreateError); + expect(error).toBeInstanceOf(LegacyNetworkCreateError); expect(error.message).toBe("failed to create docker network: permission denied"); }), ); @@ -740,7 +744,7 @@ describe("legacyEnsureStartNetwork", () => { exitCode: 1, stderr: "operation is not permitted on predefined host network", })); - return legacyEnsureStartNetwork(mock.spawner, networkId, {}).pipe( + return legacyEnsureNetwork(mock.spawner, networkId, {}).pipe( Effect.map(() => { expect(mock.spawned).toEqual([]); }), @@ -749,10 +753,10 @@ describe("legacyEnsureStartNetwork", () => { ); }); -describe("legacyEnsureStartVolume", () => { +describe("legacyEnsureVolume", () => { it.live("creates the named volume with labels", () => { const mock = mockSpawner(() => ({ exitCode: 0 })); - return legacyEnsureStartVolume(mock.spawner, "supabase_db_proj", { + return legacyEnsureVolume(mock.spawner, "supabase_db_proj", { "com.supabase.cli.project": "proj", }).pipe( Effect.map(() => { @@ -769,10 +773,10 @@ describe("legacyEnsureStartVolume", () => { stderr: "a volume named supabase_db_proj already exists but was not created for the current specification\n", })); - return legacyEnsureStartVolume(mock.spawner, "supabase_db_proj", {}).pipe( + return legacyEnsureVolume(mock.spawner, "supabase_db_proj", {}).pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartVolumeCreateError); + expect(error).toBeInstanceOf(LegacyVolumeCreateError); expect(error.message).toBe( "failed to create volume: a volume named supabase_db_proj already exists but was not created for the current specification", ); @@ -781,10 +785,10 @@ describe("legacyEnsureStartVolume", () => { }); }); -describe("legacyStartVolumeExists", () => { +describe("legacyVolumeExists", () => { it.live("resolves true when `docker volume inspect` exits 0", () => { const mock = mockSpawner(() => ({ exitCode: 0, stdout: "[]\n" })); - return legacyStartVolumeExists(mock.spawner, "supabase_db_proj").pipe( + return legacyVolumeExists(mock.spawner, "supabase_db_proj").pipe( Effect.map((exists) => { expect(exists).toBe(true); expect(mock.spawned).toEqual([["volume", "inspect", "supabase_db_proj"]]); @@ -797,7 +801,7 @@ describe("legacyStartVolumeExists", () => { exitCode: 1, stderr: "Error: No such volume: supabase_db_proj\n", })); - return legacyStartVolumeExists(mock.spawner, "supabase_db_proj").pipe( + return legacyVolumeExists(mock.spawner, "supabase_db_proj").pipe( Effect.map((exists) => { expect(exists).toBe(false); }), @@ -808,7 +812,7 @@ describe("legacyStartVolumeExists", () => { "resolves true (protected, not fresh) on an ambiguous inspect failure, matching Go's IsNotFound gate", () => { const mock = mockSpawner(() => ({ exitCode: 1, stderr: "permission denied\n" })); - return legacyStartVolumeExists(mock.spawner, "supabase_db_proj").pipe( + return legacyVolumeExists(mock.spawner, "supabase_db_proj").pipe( Effect.map((exists) => { expect(exists).toBe(true); }), @@ -816,7 +820,7 @@ describe("legacyStartVolumeExists", () => { }, ); - it.live("fails with LegacyStartVolumeInspectError when no runtime can be spawned", () => { + it.live("fails with LegacyVolumeInspectError when no runtime can be spawned", () => { const spawner = ChildProcessSpawner.make(() => Effect.fail( PlatformError.systemError({ @@ -827,10 +831,80 @@ describe("legacyStartVolumeExists", () => { }), ), ); - return legacyStartVolumeExists(spawner, "supabase_db_proj").pipe( + return legacyVolumeExists(spawner, "supabase_db_proj").pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartVolumeInspectError); + expect(error).toBeInstanceOf(LegacyVolumeInspectError); + }), + ); + }); +}); + +describe("legacyRemoveContainer", () => { + it.live("spawns `docker container rm -f ` and succeeds on exit 0", () => { + const mock = mockSpawner(() => ({ exitCode: 0 })); + return legacyRemoveContainer(mock.spawner, "supabase_db_proj").pipe( + Effect.map(() => { + expect(mock.spawned).toEqual([["container", "rm", "-f", "supabase_db_proj"]]); + }), + ); + }); + + it.live( + 'fails with LegacyContainerRemoveError on ANY non-zero exit — not tolerant of "not found"', + () => { + const mock = mockSpawner(() => ({ + exitCode: 1, + stderr: "Error: No such container: supabase_db_proj\n", + })); + return legacyRemoveContainer(mock.spawner, "supabase_db_proj").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyContainerRemoveError); + expect(error.message).toContain("failed to remove container"); + expect(error.message).toContain("No such container"); + }), + ); + }, + ); + + it.live("fails with LegacyContainerRemoveError when no runtime can be spawned", () => { + const spawner = ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn ENOENT", + }), + ), + ); + return legacyRemoveContainer(spawner, "supabase_db_proj").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyContainerRemoveError); + }), + ); + }); +}); + +describe("legacyRemoveVolume", () => { + it.live("spawns `docker volume rm -f ` and succeeds on exit 0", () => { + const mock = mockSpawner(() => ({ exitCode: 0 })); + return legacyRemoveVolume(mock.spawner, "supabase_db_proj").pipe( + Effect.map(() => { + expect(mock.spawned).toEqual([["volume", "rm", "-f", "supabase_db_proj"]]); + }), + ); + }); + + it.live("fails with LegacyVolumeRemoveError on a genuine non-zero exit", () => { + const mock = mockSpawner(() => ({ exitCode: 1, stderr: "permission denied\n" })); + return legacyRemoveVolume(mock.spawner, "supabase_db_proj").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyVolumeRemoveError); + expect(error.message).toContain("failed to remove volume"); }), ); }); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts b/apps/cli/src/legacy/shared/containers/docker-create-args.ts similarity index 99% rename from apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts rename to apps/cli/src/legacy/shared/containers/docker-create-args.ts index 5e3d18fd53..de811e2551 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts +++ b/apps/cli/src/legacy/shared/containers/docker-create-args.ts @@ -47,7 +47,7 @@ * It exists purely because this module's own "shell out to `docker create`" * architecture (unlike Go's direct Engine API calls) has an argv-exposure * problem `container.Config`/`container.HostConfig` never had — see that - * field's doc comment, and `container-lifecycle.ts`'s `legacyStartContainer`, + * field's doc comment, and `container-lifecycle.ts`'s `legacyCreateContainer`, * for the mitigation. */ @@ -154,7 +154,7 @@ export interface LegacyStartContainerSpec { * * NOT consumed here: {@link legacyBuildStartContainerCreateArgs} stays * pure/no-I/O and never reads this field. `container-lifecycle.ts`'s - * `legacyStartContainer` is the sole consumer — it writes each entry's + * `legacyCreateContainer` is the sole consumer — it writes each entry's * `content` to a HOST-side temp file (mode `0644` — world-readable, so the * non-root in-container user reading it (e.g. Kong, Postgres) doesn't hit * `EACCES` once the bind mount preserves this host mode verbatim; see diff --git a/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.unit.test.ts b/apps/cli/src/legacy/shared/containers/docker-create-args.unit.test.ts similarity index 100% rename from apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.unit.test.ts rename to apps/cli/src/legacy/shared/containers/docker-create-args.unit.test.ts diff --git a/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts b/apps/cli/src/legacy/shared/containers/health-check.ts similarity index 100% rename from apps/cli/src/legacy/shared/db-bootstrap/health-check.ts rename to apps/cli/src/legacy/shared/containers/health-check.ts diff --git a/apps/cli/src/legacy/shared/db-bootstrap/health-check.unit.test.ts b/apps/cli/src/legacy/shared/containers/health-check.unit.test.ts similarity index 100% rename from apps/cli/src/legacy/shared/db-bootstrap/health-check.unit.test.ts rename to apps/cli/src/legacy/shared/containers/health-check.unit.test.ts diff --git a/apps/cli/src/legacy/shared/db-bootstrap/image-prepull.ts b/apps/cli/src/legacy/shared/containers/image-prepull.ts similarity index 100% rename from apps/cli/src/legacy/shared/db-bootstrap/image-prepull.ts rename to apps/cli/src/legacy/shared/containers/image-prepull.ts diff --git a/apps/cli/src/legacy/shared/db-bootstrap/image-prepull.unit.test.ts b/apps/cli/src/legacy/shared/containers/image-prepull.unit.test.ts similarity index 100% rename from apps/cli/src/legacy/shared/db-bootstrap/image-prepull.unit.test.ts rename to apps/cli/src/legacy/shared/containers/image-prepull.unit.test.ts diff --git a/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.ts b/apps/cli/src/legacy/shared/containers/pinned-image.ts similarity index 100% rename from apps/cli/src/legacy/shared/db-bootstrap/pinned-image.ts rename to apps/cli/src/legacy/shared/containers/pinned-image.ts diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts index aa4f21fd9c..ae9248e22d 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -3,7 +3,7 @@ * `SetupLocalDatabase` (`apps/cli-go/internal/db/start/start.go:359-381`), run once * the `db` container's healthcheck passes on a FRESH volume (Go's `NoBackupVolume` * gate, `start.go:184` — the caller decides whether to invoke this at all; see - * `legacyStartVolumeExists` in `./container-lifecycle.ts`). The single exported + * `legacyVolumeExists` in `./container-lifecycle.ts`). The single exported * entry point, {@link legacyStartSetupLocalDatabase}, runs the exact Go call chain * in order: * @@ -47,8 +47,15 @@ * rather than a caught not-found error — see the call site's own comment for why); * any other read/exec error propagates. * 5. **`apply.MigrateAndSeed`** (`start.go:368`, via the already-ported - * `legacyMigrateAndSeed`) with `version: ""` — every pending migration, matching - * `SetupLocalDatabase`'s own call in the `start` context. + * `legacyMigrateAndSeed`) with the caller-supplied {@link + * LegacyStartSetupLocalDatabaseInput.version} — `""` (every pending migration) for + * `db start`'s own call, matching `SetupLocalDatabase`'s call in the `start` + * context; `db reset`'s PG15 recreate (the function's OTHER real Go caller, + * `resetDatabase15`, `reset.go:169`) passes its own resolved reset version instead. + * {@link LegacyStartSetupLocalDatabaseInput.seedFlags} applies `db reset`'s + * `--no-seed`/`--sql-paths` overrides on top of the loaded `[db.seed]` config first + * (a no-op for `db start`, which has neither flag) — see + * {@link legacyResolveResetSeedConfig}. * * Go's `initCurrentBranch` (`start.go:233-241`, writes `supabase/.branches/ * _current_branch` = `"main"` if absent) is NOT part of this pipeline, even though @@ -56,35 +63,58 @@ * called by `StartDatabase` (the caller of `SetupLocalDatabase`) UNCONDITIONALLY, * regardless of `NoBackupVolume` (`start.go:184-189`) — unlike everything above, * which only runs on a fresh volume. `start.handler.ts` calls it directly, outside - * the `isFreshVolume` gate that wraps {@link legacyStartSetupLocalDatabase}. + * the `isFreshVolume` gate that wraps {@link legacyStartSetupLocalDatabase}; `db + * reset` never calls it at all (Go's own `resetDatabase`/`resetDatabase15` never + * call `initCurrentBranch` either). * * Go's best-effort `pgcache.TryCacheMigrationsCatalog` warning (`start.go:371-379`) - * is intentionally NOT ported — same accepted, documented divergence as - * `db/reset/reset.handler.ts`'s identical comment (no output impact either way). + * is intentionally NOT ported — same accepted divergence for `db start`'s own caller + * as before. `db reset`'s PG15 caller (the function's OTHER real Go caller, + * `resetDatabase15`) inherits the SAME gap, now on a more deliberate footing than a + * blanket "no output impact" claim: a reset is the natural cache-invalidation point + * for pg-delta's `db push`/`db schema declarative` machinery, so an unported write + * here means the next pg-delta-enabled `db push`/`declarative` run after a reset + * re-extracts the catalog itself instead of reusing a freshly-primed cache — a + * PERFORMANCE gap (one redundant catalog export), not a correctness or observable- + * output one (the write is silent on success; Go only ever warns on failure). Porting + * it would additionally require wiring `legacyEdgeRuntimeScriptLayer` + + * `legacyPgDeltaSslProbeLayer` into `db reset`'s runtime purely for this optional, + * feature-flagged (`[experimental.pgdelta] enabled`/`SUPABASE_EXPERIMENTAL_PG_DELTA`) + * cache-priming step — disproportionate for this change; left as an explicit, + * documented follow-up rather than silently dropped (see `db/reset/SIDE_EFFECTS.md`). * * This module also duplicates ONE config-load pass: `legacyCheckDbToml` is called * internally (not threaded in from the caller) to resolve `[db.vault]`, `[db.seed]`, * `db.migrations.enabled`, and the effective `api.auto_expose_new_tables` tri-state — * the same accepted duplication `db start`'s own handler (`commands/db/start/ * start.handler.ts`) already takes independently of the top-level `supabase start` - * command's own config resolution. + * command's own config resolution; `db reset`'s own caller duplicates it again for the + * same reason. */ import type { ProjectConfig } from "@supabase/config"; import { Data, Effect, type FileSystem, Option, type Path } from "effect"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import type { LocalServiceVersionOverrides } from "../../../shared/services/services.shared.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; -import type { LegacyDbSession } from "../legacy-db-connection.service.ts"; +import { LegacyDbConnection, type LegacyDbSession } from "../legacy-db-connection.service.ts"; +import type { LegacyDbConnectError } from "../legacy-db-connection.errors.ts"; import { LegacyDbConfigLoadError } from "../legacy-db-config.errors.ts"; -import { legacyCheckDbToml } from "../legacy-db-config.toml-read.ts"; +import { legacyCheckDbToml, legacyResolveSeedSqlPath } from "../legacy-db-config.toml-read.ts"; import { legacyServiceContainerName } from "../legacy-docker-ids.ts"; import { LegacyDockerRun, type LegacyDockerRunOpts } from "../legacy-docker-run.service.ts"; import { legacyMigrateAndSeed } from "../legacy-migrate-and-seed.ts"; import { LegacyMigrationApplyError, legacyExecSqlFile } from "../legacy-migration-apply.ts"; -import type { LegacyMigrationSeedError } from "../legacy-seed.ts"; +import type { LegacyMigrationSeedError, LegacySeedConfig } from "../legacy-seed.ts"; import { ramInBytes } from "../legacy-size-units.ts"; import { LegacyMigrationVaultError, legacyUpsertVaultSecrets } from "../legacy-vault.ts"; +import { + legacyEnsureImagesCached, + type LegacyImagePrepullError, +} from "../containers/image-prepull.ts"; +import { legacyResolvePinnedImage } from "../containers/pinned-image.ts"; import { LEGACY_REALTIME_TENANT_ID, legacyBuildRealtimeEnv } from "./realtime-env.ts"; import { LEGACY_START_DB_GLOBALS_SQL } from "./templates/db-globals.sql.ts"; import { LEGACY_START_DB_INITIAL_SCHEMA_13_SQL } from "./templates/db-initial-schema-13.sql.ts"; @@ -116,20 +146,20 @@ alter default privileges for role postgres in schema public * utils/docker.go:469-487,559-591` — Go discards the container's own stdout/stderr * outside `--debug`, so only the exit code is meaningful here too). */ -export class LegacyStartDbSetupError extends Data.TaggedError("LegacyStartDbSetupError")<{ +export class LegacyDbSetupError extends Data.TaggedError("LegacyDbSetupError")<{ readonly message: string; }> {} /** Every failure {@link legacyStartSetupLocalDatabase} can produce. */ export type LegacyStartSetupLocalDatabaseError = | LegacyDbConfigLoadError - | LegacyStartDbSetupError + | LegacyDbSetupError | LegacyMigrationVaultError | LegacyMigrationApplyError | LegacyMigrationSeedError; /** Already-resolved Docker images for the three PG15+ one-shot migrate jobs (`initSchema15`'s `initJobs`). */ -export interface LegacyStartDbSetupImages { +interface LegacyStartDbSetupImages { /** `utils.Config.Realtime.Image`, resolved by the caller (not part of the decoded `ProjectConfig` schema — `toml:"-"`). */ readonly realtime: string; /** `utils.Config.Storage.Image`, ditto. */ @@ -138,6 +168,58 @@ export interface LegacyStartDbSetupImages { readonly auth: string; } +type Spawner = ChildProcessSpawner["Service"]; + +/** + * Resolves the three PG15+ one-shot setup jobs' images (`initSchema15`'s + * `initRealtimeJob`/`initStorageJob`/`initAuthJob`) for {@link legacyRunFreshDbSetup}, + * the ONE place both real Go callers (`db start`'s fresh-volume branch and `db + * reset`'s PG15 recreate) reach this resolution from — see its own doc comment. + * Mirrors Go's `initSchema15`, which uses the SAME already-pin-rewritten + * `utils.Config.{Realtime,Storage,Auth}.Image` fields the long-running containers + * would use, regardless of `--exclude` — resolved via `legacyResolvePinnedImage`, not + * the raw Dockerfile default, so a linked project's version pins apply here too. + * Resolved lazily (only the images whose service is BOTH `majorVersion >= 15` AND + * enabled-for-setup), matching Go's own `ensureImagesCached` (`start.go:237-262`), + * which never pre-pulls these for either caller. Not exported outside this module — + * {@link legacyRunFreshDbSetup} is the only caller now that both real callers share it. + */ +const legacyResolveDbSetupImages = Effect.fnUntraced(function* ( + spawner: Spawner, + input: { + readonly majorVersion: number; + readonly realtimeEnabledForSetup: boolean; + readonly storageEnabledForSetup: boolean; + readonly authEnabledForSetup: boolean; + readonly serviceVersionOverrides: LocalServiceVersionOverrides; + readonly projectEnvValues: Readonly> | undefined; + }, +) { + const rawSetupJobImages = { + realtime: legacyResolvePinnedImage("realtime", "realtime", input.serviceVersionOverrides), + storage: legacyResolvePinnedImage("storage", "storage", input.serviceVersionOverrides), + auth: legacyResolvePinnedImage("gotrue", "auth", input.serviceVersionOverrides), + }; + const setupJobImagesToResolve = + input.majorVersion >= 15 + ? [ + ...(input.realtimeEnabledForSetup ? [rawSetupJobImages.realtime] : []), + ...(input.storageEnabledForSetup ? [rawSetupJobImages.storage] : []), + ...(input.authEnabledForSetup ? [rawSetupJobImages.auth] : []), + ] + : []; + const resolvedSetupJobImages = + setupJobImagesToResolve.length > 0 + ? yield* legacyEnsureImagesCached(spawner, setupJobImagesToResolve, input.projectEnvValues) + : new Map(); + const resolveSetupJobImage = (image: string) => resolvedSetupJobImages.get(image) ?? image; + return { + realtime: resolveSetupJobImage(rawSetupJobImages.realtime), + storage: resolveSetupJobImage(rawSetupJobImages.storage), + auth: resolveSetupJobImage(rawSetupJobImages.auth), + }; +}); + /** Input to {@link legacyStartSetupLocalDatabase}. */ export interface LegacyStartSetupLocalDatabaseInput { /** @@ -208,6 +290,46 @@ export interface LegacyStartSetupLocalDatabaseInput { /** Go's `utils.Config.Storage.TargetMigration` (`toml:"-"`, resolved from a version-pin file) — the caller passes `""` when absent, matching Go's zero-value default. */ readonly storageTargetMigration: string; readonly images: LegacyStartDbSetupImages; + /** + * The migration version to reapply (Go's `apply.MigrateAndSeed(ctx, version, ...)`). + * `db start`'s own caller always passes `""` (Go's `SetupLocalDatabase(ctx, "", ...)`, + * `start.go:185` — every pending migration). `db reset`'s PG15 recreate + * (`resetDatabase15`, `reset.go:169`) passes its own RESOLVED reset version instead — + * the one genuine difference between the two Go callers of this shared function. + */ + readonly version: string; + /** + * `db reset`'s `--no-seed`/`--sql-paths` overrides (Go's `applyDbResetSeedFlags`, + * `cmd/db.go:567-583`, mutating the global `utils.Config.Db.Seed` BEFORE `reset.Run` + * — read by this same `MigrateAndSeed` call on the PG15 recreate path). `db start` + * has neither flag, so its caller passes `{ noSeed: false, sqlPaths: [] }`, which + * {@link legacyResolveResetSeedConfig} reduces to the loaded `[db.seed]` config + * unchanged. + */ + readonly seedFlags: { readonly noSeed: boolean; readonly sqlPaths: ReadonlyArray }; +} + +/** + * Applies `db reset`'s `--no-seed`/`--sql-paths` overrides to an already-resolved + * `[db.seed]` config, mirroring Go's `applyDbResetSeedFlags` (`cmd/db.go:567-583`): + * `--no-seed` disables seeding outright; otherwise a non-empty `--sql-paths` + * force-enables seeding and overrides `sqlPaths` (each pattern resolved against + * `supabase/` the same way Go's own `resolveSeedSqlPaths` does); an empty + * `--sql-paths` is a no-op. The two flags are mutually exclusive (validated by the + * caller — `db/reset/reset.handler.ts`'s `validateDbResetSeedFlags` port — before + * this ever runs), matching Go's own `if noSeed { ...; return } ...` early return. + */ +export function legacyResolveResetSeedConfig( + seed: LegacySeedConfig, + override: { readonly noSeed: boolean; readonly sqlPaths: ReadonlyArray }, + path: Path.Path, +): LegacySeedConfig { + if (override.noSeed) return { ...seed, enabled: false }; + if (override.sqlPaths.length === 0) return seed; + return { + enabled: true, + sqlPaths: override.sqlPaths.map((pattern) => legacyResolveSeedSqlPath(path, pattern)), + }; } const errMessage = (e: unknown): string => @@ -234,7 +356,7 @@ const legacyExecSqlConstant = Effect.fnUntraced(function* ( yield* fs.writeFileString(filePath, sql).pipe( Effect.mapError( (error) => - new LegacyStartDbSetupError({ + new LegacyDbSetupError({ message: `failed to write ${filename}: ${errMessage(error)}`, }), ), @@ -244,15 +366,42 @@ const legacyExecSqlConstant = Effect.fnUntraced(function* ( fs, path, filePath, - (message) => new LegacyStartDbSetupError({ message }), + (message) => new LegacyDbSetupError({ message }), ); }); /** - * Port of Go's `InitSchema14` (`start.go:256-266`): execs - * {@link LEGACY_START_DB_GLOBALS_SQL} then the major-version-appropriate initial - * schema. Only reached for `majorVersion <= 14` (the caller, `legacyStartInitSchema`, - * gates on that). + * Port of Go's EXPORTED `InitSchema14` (`start.go:256-266`) — execs ONLY the + * major-version-appropriate initial-schema SQL, deliberately WITHOUT + * {@link LEGACY_START_DB_GLOBALS_SQL}. Go's own `initSchema` wrapper (the PG<=14 + * branch below, `legacyStartInitSchemaPre15`) execs globals.sql itself, immediately + * before calling `InitSchema14` — but `db reset`'s PG14 path (`reset.go:176-186` + * `initDatabase`) calls `start.InitSchema14` DIRECTLY, skipping globals.sql + * entirely. Exported so `legacy/shared/db-bootstrap/recreate-local-database.ts` + * can reproduce that exact (if surprising) Go asymmetry instead of reusing + * {@link legacyStartInitSchemaPre15}, which would run globals.sql an extra time Go + * never does on the reset path. + */ +export const legacyInitSchema14 = Effect.fnUntraced(function* ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + tmpDir: string, + majorVersion: number, +) { + const schemaSql = + majorVersion === 13 + ? LEGACY_START_DB_INITIAL_SCHEMA_13_SQL + : LEGACY_START_DB_INITIAL_SCHEMA_14_SQL; + yield* legacyExecSqlConstant(session, fs, path, tmpDir, "initial-schema.sql", schemaSql); +}); + +/** + * Port of Go's `initSchema`'s PG<=14 branch (`start.go:245-251`): execs + * {@link LEGACY_START_DB_GLOBALS_SQL} then {@link legacyInitSchema14}. Only + * reached for `majorVersion <= 14` (the caller, `legacyStartInitSchema`, gates on + * that) — used by `db start`'s fresh-volume setup ONLY; `db reset`'s PG14 path + * calls {@link legacyInitSchema14} directly instead (see its own doc comment). */ const legacyStartInitSchemaPre15 = Effect.fnUntraced(function* ( session: LegacyDbSession, @@ -269,11 +418,7 @@ const legacyStartInitSchemaPre15 = Effect.fnUntraced(function* ( "globals.sql", LEGACY_START_DB_GLOBALS_SQL, ); - const schemaSql = - majorVersion === 13 - ? LEGACY_START_DB_INITIAL_SCHEMA_13_SQL - : LEGACY_START_DB_INITIAL_SCHEMA_14_SQL; - yield* legacyExecSqlConstant(session, fs, path, tmpDir, "initial-schema.sql", schemaSql); + yield* legacyInitSchema14(session, fs, path, tmpDir, majorVersion); }); /** @@ -313,10 +458,10 @@ const legacyRunStartMigrateJob = Effect.fnUntraced(function* (opts: { }; const result = yield* docker .runCapture(runOpts) - .pipe(Effect.mapError((cause) => new LegacyStartDbSetupError({ message: cause.message }))); + .pipe(Effect.mapError((cause) => new LegacyDbSetupError({ message: cause.message }))); if (result.exitCode !== 0) { return yield* Effect.fail( - new LegacyStartDbSetupError({ message: `error running container: exit ${result.exitCode}` }), + new LegacyDbSetupError({ message: `error running container: exit ${result.exitCode}` }), ); } }); @@ -422,7 +567,7 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( // Go fails this same malformed value at TOML-decode time, before any // Docker work (`sizeInBytes.UnmarshalText`, `pkg/config/config.go:41-47`) // — this can't be replicated literally here since Postgres is already up - // by this step, but surfacing it as a typed `LegacyStartDbSetupError` so + // by this step, but surfacing it as a typed `LegacyDbSetupError` so // rollback actually runs is the achievable equivalent, matching the same // fix already applied to `resolveDbHealthTimeoutSeconds` and the // long-running Storage container's own file-size-limit parsing @@ -439,7 +584,7 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( fileSizeLimit: input.config.storage.file_size_limit, }), catch: (cause) => - new LegacyStartDbSetupError({ + new LegacyDbSetupError({ message: `invalid config for storage: ${errMessage(cause)}`, }), }); @@ -497,18 +642,26 @@ const legacyStartInitSchema = Effect.fnUntraced(function* ( * `api.auto_expose_new_tables` — `true` keeps the bundled initial-schema grants * (no-op); unset/`false` execs {@link LEGACY_START_REVOKE_API_PRIVILEGES_SQL}. Runs * regardless of PG major version (unlike `initSchema`, this always execs SQL over - * `session` directly — it is never part of the PG15+ one-shot Docker jobs). + * `session` directly — it is never part of the PG15+ one-shot Docker jobs). Exported + * (and taking `session`/`fs`/`path` directly, not the whole + * {@link LegacyStartSetupLocalDatabaseInput}) because Go's `ApplyApiPrivileges` is + * the SAME exported function `db reset`'s PG14 `initDatabase` calls + * (`reset.go:176-186`), after its own `InitSchema14` call and with none of + * `SetupDatabase`'s other steps (vault/roles.sql/MigrateAndSeed) — see + * `legacy/shared/db-bootstrap/recreate-local-database.ts`. */ -const legacyStartApplyApiPrivileges = Effect.fnUntraced(function* ( - input: LegacyStartSetupLocalDatabaseInput, +export const legacyApplyApiPrivileges = Effect.fnUntraced(function* ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, tmpDir: string, autoExposeNewTables: Option.Option, ) { if (Option.isSome(autoExposeNewTables) && autoExposeNewTables.value) return; yield* legacyExecSqlConstant( - input.session, - input.fs, - input.path, + session, + fs, + path, tmpDir, "revoke-api-privileges.sql", LEGACY_START_REVOKE_API_PRIVILEGES_SQL, @@ -535,7 +688,7 @@ export const legacyStartInitCurrentBranch = Effect.fnUntraced(function* ( const exists = yield* fs.exists(currentBranchPath).pipe( Effect.mapError( (error) => - new LegacyStartDbSetupError({ + new LegacyDbSetupError({ message: `failed init current branch: ${errMessage(error)}`, }), ), @@ -544,7 +697,7 @@ export const legacyStartInitCurrentBranch = Effect.fnUntraced(function* ( yield* fs.makeDirectory(path.dirname(currentBranchPath), { recursive: true }).pipe( Effect.mapError( (error) => - new LegacyStartDbSetupError({ + new LegacyDbSetupError({ message: `failed init current branch: ${errMessage(error)}`, }), ), @@ -552,7 +705,7 @@ export const legacyStartInitCurrentBranch = Effect.fnUntraced(function* ( yield* fs.writeFileString(currentBranchPath, "main").pipe( Effect.mapError( (error) => - new LegacyStartDbSetupError({ + new LegacyDbSetupError({ message: `failed init current branch: ${errMessage(error)}`, }), ), @@ -586,13 +739,19 @@ export const legacyStartSetupLocalDatabase = ( .pipe( Effect.mapError( (error) => - new LegacyStartDbSetupError({ + new LegacyDbSetupError({ message: `failed to create temp directory: ${errMessage(error)}`, }), ), ); yield* legacyStartInitSchema(input, tmpDir); - yield* legacyStartApplyApiPrivileges(input, tmpDir, toml.baseline.apiAutoExposeNewTables); + yield* legacyApplyApiPrivileges( + session, + fs, + path, + tmpDir, + toml.baseline.apiAutoExposeNewTables, + ); }), ); @@ -616,7 +775,7 @@ export const legacyStartSetupLocalDatabase = ( const rolesExist = yield* fs.exists(customRolesPath).pipe( Effect.mapError( (error) => - new LegacyStartDbSetupError({ + new LegacyDbSetupError({ message: `failed to check roles.sql: ${errMessage(error)}`, }), ), @@ -627,28 +786,157 @@ export const legacyStartSetupLocalDatabase = ( fs, path, customRolesPath, - (message) => new LegacyStartDbSetupError({ message }), + (message) => new LegacyDbSetupError({ message }), ); } - // apply.MigrateAndSeed(ctx, "", conn, fsys) — empty version = every pending - // migration, matching `SetupLocalDatabase`'s own call in the `start` context - // (start.go:368). `experimental`/`pgDeltaEnabled`/`schemaPaths` gate - // `legacyMigrateAndSeed`'s own declarative-schema-files branch (apply.go:19) — see its - // doc comment; `toml.pgDelta.enabled` is this module's own already-loaded config, not - // re-read from the caller. - yield* legacyMigrateAndSeed(session, fs, path, workdir, "", { + // apply.MigrateAndSeed(ctx, version, conn, fsys) — `db start`'s own caller always + // passes `version: ""` (every pending migration, matching `SetupLocalDatabase`'s + // own call in the `start` context, `start.go:185,368`); `db reset`'s PG15 recreate + // passes its own resolved reset version instead (`resetDatabase15`, `reset.go:169`) + // — see `input.version`'s own doc comment. `experimental`/`pgDeltaEnabled`/ + // `schemaPaths` gate `legacyMigrateAndSeed`'s own declarative-schema-files branch + // (apply.go:19) — see its doc comment; `toml.pgDelta.enabled` is this module's own + // already-loaded config, not re-read from the caller. `input.seedFlags` applies + // `db reset`'s own `--no-seed`/`--sql-paths` overrides on top of the loaded + // `[db.seed]` config — a no-op for `db start`, which has neither flag. + yield* legacyMigrateAndSeed(session, fs, path, workdir, input.version, { migrationsEnabled: toml.migrationsEnabled, - seed: toml.seed, + seed: legacyResolveResetSeedConfig(toml.seed, input.seedFlags, path), experimental: input.experimental, pgDeltaEnabled: toml.pgDelta.enabled, schemaPaths: input.config.db.migrations.schema_paths, }); // Go's best-effort pgcache catalog warning (`pgcache.TryCacheMigrationsCatalog`, - // start.go:371-379) is not ported (no output impact) — same accepted, documented - // divergence as `db/reset/reset.handler.ts`. + // start.go:371-379) is NOT ported here, for EITHER real Go caller of this shared + // function — `db start` (no output impact) and `db reset`'s PG15 recreate (a + // performance-only gap, not a correctness one) both inherit the same accepted, + // documented divergence — see this module's own header for the full reasoning and + // `legacy/shared/db-bootstrap/recreate-local-database.ts`'s header / + // `db/reset/SIDE_EFFECTS.md` for `db reset`'s own restatement of it. // // `initCurrentBranch` (start.go:233-241) is NOT called here — see this // module's header for why it moved to the caller instead. }); + +/** + * The `setup` shape shared by BOTH real Go callers of {@link + * legacyStartSetupLocalDatabase} — `db start`'s own fresh-volume branch + * (`start-database.ts`'s `legacyStartDatabase`) and `db reset`'s PG15 recreate + * composition (`recreate-local-database.ts`'s `legacyRecreateLocalDatabase15`) — + * everything {@link legacyStartSetupLocalDatabase} needs, minus what {@link + * legacyRunFreshDbSetup} itself already resolves/threads through (`session`, + * `images`). The two callers used to each declare an identical copy of this + * interface; hoisted here alongside {@link legacyRunFreshDbSetup} itself + * (CLI-1955 review follow-up). + */ +export interface LegacyFreshDbSetupInput { + readonly majorVersion: number; + /** Already spliced with the caller's own realtime/storage/auth enabled-for-setup + ip_version/max_header_length/file_size_limit overrides — see `bootstrap-config.ts`'s `LegacyDbBootstrapConfig`. */ + readonly config: LegacyStartSetupLocalDatabaseInput["config"]; + /** Threaded straight through to {@link LegacyStartSetupLocalDatabaseInput.experimental} — see its own doc comment. */ + readonly experimental: boolean; + readonly dbUrl: string; + readonly jwtSecret: string; + /** Lazy — evaluated only when reached AND `realtimeEnabledForSetup`. See `start-database.ts`'s header for why this is caller-supplied rather than resolved here unconditionally. */ + readonly jwks: Effect.Effect; + readonly apiUrl: string; + readonly authExternalUrl: string | undefined; + readonly siteUrl: string; + readonly anonKey: string; + readonly serviceRoleKey: string; + readonly storageTargetMigration: string; + readonly realtimeEnabledForSetup: boolean; + readonly storageEnabledForSetup: boolean; + readonly authEnabledForSetup: boolean; + readonly serviceVersionOverrides: LocalServiceVersionOverrides; + readonly projectEnvValues: Readonly> | undefined; +} + +/** + * Runs {@link legacyStartSetupLocalDatabase} against a freshly-provisioned local + * Postgres — the exact sequence BOTH real Go callers run once Postgres's own + * healthcheck passes on a fresh database (`db start`'s fresh-volume branch and + * `db reset`'s PG15 recreate, see {@link LegacyFreshDbSetupInput}'s own doc + * comment): dial the host-facing session (Go's `ConnectLocalPostgres`), resolve + * JWKS lazily (only when `realtimeEnabledForSetup` — Go's `initSchema15`-local + * `ResolveJWKS` call), resolve the three PG15+ one-shot job images via {@link + * legacyResolveDbSetupImages}, then run {@link legacyStartSetupLocalDatabase} + * itself. `version`/`seedFlags` are the one genuine difference between the two + * callers (`db start` always passes `""`/`{noSeed:false, sqlPaths:[]}`; `db + * reset` passes its own resolved reset version/flags) — threaded straight + * through by the caller, matching each one's own `LegacyStartSetupLocalDatabaseInput` + * field of the same name. + */ +export const legacyRunFreshDbSetup = ( + spawner: Spawner, + input: { + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + readonly workdir: string; + readonly projectId: string; + readonly networkId: string; + readonly hostname: string; + readonly dbPort: number; + readonly version: string; + readonly seedFlags: { readonly noSeed: boolean; readonly sqlPaths: ReadonlyArray }; + readonly setup: LegacyFreshDbSetupInput; + }, +): Effect.Effect< + void, + LegacyStartSetupLocalDatabaseError | LegacyDbConnectError | LegacyImagePrepullError | E, + Output | LegacyDbConnection | LegacyDockerRun | RuntimeInfo +> => + Effect.scoped( + Effect.gen(function* () { + const dbConnection = yield* LegacyDbConnection; + const { setup } = input; + const dbPassword = legacyStartInternalDbPassword(setup.dbUrl); + const session = yield* dbConnection.connect( + { + host: input.hostname, + port: input.dbPort, + user: "postgres", + password: dbPassword, + database: "postgres", + }, + { isLocal: true, dnsResolver: "native" }, + ); + + const jwks = setup.realtimeEnabledForSetup ? yield* setup.jwks : ""; + + const dbSetupImages = yield* legacyResolveDbSetupImages(spawner, { + majorVersion: setup.majorVersion, + realtimeEnabledForSetup: setup.realtimeEnabledForSetup, + storageEnabledForSetup: setup.storageEnabledForSetup, + authEnabledForSetup: setup.authEnabledForSetup, + serviceVersionOverrides: setup.serviceVersionOverrides, + projectEnvValues: setup.projectEnvValues, + }); + + yield* legacyStartSetupLocalDatabase({ + session, + fs: input.fs, + path: input.path, + workdir: input.workdir, + config: setup.config, + experimental: setup.experimental, + majorVersion: setup.majorVersion, + projectId: input.projectId, + networkId: input.networkId, + dbUrl: setup.dbUrl, + jwtSecret: setup.jwtSecret, + jwks, + apiUrl: setup.apiUrl, + authExternalUrl: setup.authExternalUrl, + siteUrl: setup.siteUrl, + anonKey: setup.anonKey, + serviceRoleKey: setup.serviceRoleKey, + storageTargetMigration: setup.storageTargetMigration, + images: dbSetupImages, + version: input.version, + seedFlags: input.seedFlags, + }); + }), + ); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts index a9d7ae3757..0551592691 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts @@ -12,7 +12,7 @@ import type { LegacyDbSession } from "../legacy-db-connection.service.ts"; import { LegacyDockerRun, type LegacyDockerRunOpts } from "../legacy-docker-run.service.ts"; import { LegacyDockerRunError } from "../legacy-docker-run.errors.ts"; import { - LegacyStartDbSetupError, + LegacyDbSetupError, legacyStartInitCurrentBranch, legacyStartSetupLocalDatabase, type LegacyStartSetupLocalDatabaseInput, @@ -124,6 +124,8 @@ function baseInput( storage: "public.ecr.aws/supabase/storage-api:v1.0.0", auth: "public.ecr.aws/supabase/gotrue:v2.170.0", }, + version: "", + seedFlags: { noSeed: false, sqlPaths: [] }, ...overrides, }; } @@ -368,10 +370,8 @@ describe("legacyStartSetupLocalDatabase", () => { return run(baseInput(workdir, session, { majorVersion: 15, config }), out, docker).pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartDbSetupError); - expect((error as LegacyStartDbSetupError).message).toBe( - "error running container: exit 1", - ); + expect(error).toBeInstanceOf(LegacyDbSetupError); + expect((error as LegacyDbSetupError).message).toBe("error running container: exit 1"); rmSync(workdir, { recursive: true, force: true }); }), ); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts b/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts new file mode 100644 index 0000000000..7ce6529b07 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts @@ -0,0 +1,248 @@ +/** + * The local-container-bring-up prelude BOTH `db start` (`commands/db/start/start.handler.ts`) + * and `db reset` (`commands/db/reset/reset.handler.ts`) build before calling their own + * composition (`legacyStartDatabase`/`legacyRecreateLocalDatabase`): load the local project + * context, resolve config values + the `LegacyDbBootstrapConfig` derivation, the container's + * network id/opts/id, the Postgres container-spec fields common to both callers, the lazy + * image-resolve `Effect`, and the `LegacyFreshDbSetupInput` `setup` object `legacyRunFreshDbSetup` + * needs. Hoisted here (CLI-1955 review follow-up) — the two callers used to each run an + * independently-typed ~130-line copy of this exact sequence, with no test comparing them. + * + * Deliberately does NOT include the two callers' genuinely divergent parts, which stay at each + * call site instead of being forced into this shared shape: + * - `db start`'s `fromBackup` (spliced into its OWN `postgresSpec` on top of + * {@link LegacyLocalDbContainerInputs.postgresSpecBase}) and its `isFreshVolume`/`filterValue` + * rollback tracking — `db reset` has neither concept at all (a reset never rolls back, and its + * volume is always fresh, having just been removed). + * - `db reset`'s resolved `version`/`seedFlags` (passed straight to `legacyRecreateLocalDatabase`, + * not part of this prelude) and its OWN, separately-resolved `--experimental` gate: `db reset` + * must resolve `--experimental` BEFORE this prelude ever runs (it gates the remote-target + * Go-delegation decision too, reached before `cfg.isLocal` is even known), via the Go-parity + * nested-env walk (`legacyLoadProjectEnv`/`legacyResolveExperimentalWithProjectEnv`) — a + * deliberately different mechanism than the `@supabase/config`-backed + * `context.projectEnvValues` this function resolves `experimental` from (see + * {@link LegacyLocalDbContainerInputs.experimental}'s own doc comment). `db reset`'s caller + * overrides `setup.experimental` with its own earlier-resolved value rather than using this + * function's, to preserve that pre-existing behavior exactly. + */ + +import { Effect, FileSystem, Option, Path } from "effect"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import type { GlobalFlag } from "effect/unstable/cli"; + +import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { legacyResolveExperimentalWithProjectEnv } from "../../../shared/legacy/global-flags.ts"; +import { LegacyDbConfigLoadError } from "../legacy-db-config.errors.ts"; +import { localDbContainerId, localNetworkId } from "../legacy-docker-ids.ts"; +import { legacyIsBitbucketPipeline } from "../legacy-bitbucket-pipeline.ts"; +import { + legacyResolveAuthExternalUrl, + legacyResolveDbSettingsEnvOverrides, + legacyResolveLocalConfigValues, + legacyResolveLocalJwks, + type LegacyLocalConfigValues, +} from "../legacy-local-config-values.ts"; +import { + legacyLoadLocalProjectContext, + type LegacyLocalProjectContext, +} from "../legacy-local-project-context.ts"; +import { + legacyResolveDbBootstrapConfig, + type LegacyDbBootstrapConfig, +} from "./bootstrap-config.ts"; +import type { LegacyFreshDbSetupInput } from "./db-setup.ts"; +import type { LegacyContainerOpts } from "../containers/container-lifecycle.ts"; +import { + legacyEnsureImagesCached, + type LegacyImagePrepullError, +} from "../containers/image-prepull.ts"; +import type { LegacyPostgresStartServiceInput } from "./postgres.service.ts"; + +type Spawner = ChildProcessSpawner["Service"]; + +/** Everything {@link legacyBuildLocalDbContainerInputs} resolves for its two real callers. */ +export interface LegacyLocalDbContainerInputs { + readonly context: LegacyLocalProjectContext; + readonly values: LegacyLocalConfigValues; + readonly bootstrapConfig: LegacyDbBootstrapConfig; + /** Go's `DockerStart`-forced `--network-id`, or the generated `supabase_network_` fallback. */ + readonly networkId: string; + readonly containerOpts: LegacyContainerOpts; + /** `localDbContainerId(projectId)` — also this project's volume name and the internal Docker network name. */ + readonly dbContainerId: string; + /** + * The Postgres container-spec fields common to BOTH callers — `db start` splices its own + * `fromBackup` on top; `db reset` (which has no `fromBackup` concept) passes this straight + * through as its whole `postgresSpec`. + */ + readonly postgresSpecBase: Omit; + /** Lazy — evaluated right where Go's `DockerStart` would resolve the `db` container's own image. */ + readonly resolvePostgresImage: Effect.Effect; + readonly dbHealthTimeoutSeconds: number; + /** + * `--experimental`/`SUPABASE_EXPERIMENTAL`, resolved from THIS prelude's own + * {@link LegacyLocalProjectContext.projectEnvValues} (the `@supabase/config`-backed reader) — + * matches `db start`'s own need exactly (it has no earlier use for this gate). `db reset` + * already resolves its own `experimental` earlier, from the Go-parity nested-env walk + * (`legacyLoadProjectEnv`), because it needs the gate before this prelude ever runs (to decide + * Go-delegation for the remote target too) — its caller overrides {@link + * LegacyFreshDbSetupInput.experimental} with that earlier value instead of using this field, to + * preserve that pre-existing divergence exactly. See this module's own header. + */ + readonly experimental: boolean; + readonly setup: LegacyFreshDbSetupInput; +} + +/** + * Builds {@link LegacyLocalDbContainerInputs} — see this module's header for the full call + * order and for which parts are deliberately excluded (kept at each call site instead). + */ +export const legacyBuildLocalDbContainerInputs = ( + spawner: Spawner, + workdir: string, + networkIdFlag: Option.Option, + platform: string, +): Effect.Effect< + LegacyLocalDbContainerInputs, + LegacyDbConfigLoadError, + FileSystem.FileSystem | Path.Path | GlobalFlag.Setting.Identifier<"experimental"> | CliArgs +> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const mapError = (message: string) => new LegacyDbConfigLoadError({ message }); + + const context = yield* legacyLoadLocalProjectContext(workdir, mapError); + const { config, projectEnvValues, loaded, hostname, projectId } = context; + // Go's `viper.GetBool("EXPERIMENTAL")` (`internal/migration/apply/apply.go:19`), read deep + // inside `legacyRunFreshDbSetup`'s fresh-volume setup pipeline — see this field's own doc + // comment for why `db reset`'s caller overrides it instead of using it directly. + const experimental = yield* legacyResolveExperimentalWithProjectEnv(projectEnvValues); + + const values = yield* Effect.try({ + try: () => + legacyResolveLocalConfigValues( + config, + hostname, + workdir, + projectEnvValues, + loaded?.document, + ), + catch: (cause) => mapError(cause instanceof Error ? cause.message : String(cause)), + }); + + const bootstrapConfig = yield* legacyResolveDbBootstrapConfig( + fs, + path, + { config, projectEnvValues, workdir }, + mapError, + ); + + // Go's `DockerStart` forces every container's network mode (and the network it creates) to + // `--network-id` when set, ahead of the generated `supabase_network_` fallback + // (`docker.go:379-383`). + const networkId = Option.isSome(networkIdFlag) + ? networkIdFlag.value + : localNetworkId(projectId); + // Go's `DockerStart` unconditionally appends the Linux-only `host.docker.internal:host-gateway` + // extra host for every container it starts (`docker_linux.go`; empty on darwin/windows, where + // Docker Desktop already resolves that hostname). + const extraHosts = platform === "linux" ? ["host.docker.internal:host-gateway"] : []; + const containerOpts: LegacyContainerOpts = { + projectId, + isBitbucketPipeline: legacyIsBitbucketPipeline(), + workdir, + extraHosts, + }; + const dbContainerId = localDbContainerId(projectId); + + const postgresSpecBase: Omit = { + db: { + ...config.db, + port: values.dbPort, + major_version: bootstrapConfig.majorVersion, + settings: legacyResolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues), + }, + experimental: { + ...config.experimental, + orioledb_version: bootstrapConfig.orioledbVersion, + s3_host: bootstrapConfig.s3Host, + s3_region: bootstrapConfig.s3Region, + s3_access_key: bootstrapConfig.s3AccessKey, + s3_secret_key: bootstrapConfig.s3SecretKey, + }, + jwtSecret: values.jwtSecret, + jwtExpiry: values.authJwtExpiry, + projectId, + networkId, + configImage: bootstrapConfig.postgresImage, + rootKey: values.rootKey, + }; + + const resolvePostgresImage = legacyEnsureImagesCached( + spawner, + [bootstrapConfig.postgresImage], + projectEnvValues, + ).pipe( + Effect.map( + (resolved) => resolved.get(bootstrapConfig.postgresImage) ?? bootstrapConfig.postgresImage, + ), + ); + + const setup: LegacyFreshDbSetupInput = { + majorVersion: bootstrapConfig.majorVersion, + experimental, + config: { + ...config, + realtime: { + ...config.realtime, + enabled: bootstrapConfig.realtimeEnabledForSetup, + ip_version: bootstrapConfig.realtimeIpVersion, + max_header_length: bootstrapConfig.realtimeMaxHeaderLength, + }, + storage: { + ...config.storage, + enabled: bootstrapConfig.storageEnabledForSetup, + file_size_limit: bootstrapConfig.storageFileSizeLimit, + }, + auth: { + ...config.auth, + enabled: bootstrapConfig.authEnabledForSetup, + }, + }, + dbUrl: values.dbUrl, + jwtSecret: values.jwtSecret, + // Go's `initSchema15`'s realtime job resolves JWKS itself, LOCALLY, gated on + // `Realtime.Enabled` (`internal/db/start/start.go:337-341`) — `legacyRunFreshDbSetup` only + // evaluates this Effect when reached AND `realtimeEnabledForSetup`. + jwks: Effect.tryPromise({ + try: () => legacyResolveLocalJwks(config, workdir, values.jwtSecret, projectEnvValues), + catch: (cause) => mapError(cause instanceof Error ? cause.message : String(cause)), + }), + apiUrl: values.apiUrl, + authExternalUrl: legacyResolveAuthExternalUrl(loaded?.document, projectEnvValues), + siteUrl: values.authSiteUrl, + anonKey: values.anonKey, + serviceRoleKey: values.serviceRoleKey, + storageTargetMigration: bootstrapConfig.storageTargetMigration, + realtimeEnabledForSetup: bootstrapConfig.realtimeEnabledForSetup, + storageEnabledForSetup: bootstrapConfig.storageEnabledForSetup, + authEnabledForSetup: bootstrapConfig.authEnabledForSetup, + serviceVersionOverrides: bootstrapConfig.serviceVersionOverrides, + projectEnvValues, + }; + + return { + context, + values, + bootstrapConfig, + networkId, + containerOpts, + dbContainerId, + postgresSpecBase, + resolvePostgresImage, + dbHealthTimeoutSeconds: bootstrapConfig.dbHealthTimeoutSeconds, + experimental, + setup, + }; + }); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/local-db-running.ts b/apps/cli/src/legacy/shared/db-bootstrap/local-db-running.ts index cfe3b83b92..fd0b266cfc 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/local-db-running.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/local-db-running.ts @@ -1,7 +1,7 @@ import { Data, Effect, type FileSystem, Option, type Path, Stream } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; -import { spawnContainerCli } from "../legacy-container-cli.ts"; +import { legacyIsContainerNotFoundMessage, spawnContainerCli } from "../legacy-container-cli.ts"; import { legacyReadDbToml } from "../legacy-db-config.toml-read.ts"; import { legacyResolveLocalProjectId, localDbContainerId } from "../legacy-docker-ids.ts"; import { @@ -40,11 +40,12 @@ const decodeChunks = (chunks: ReadonlyArray): string => { * error rather than silently treating the database as stopped. * * Shared by `db start` (`commands/db/start/start.handler.ts`) and `db reset` - * (`commands/db/reset/reset.handler.ts`) — hoisted out of the now-removed - * `db __db-bootstrap` Go seam by CLI-1954, since this check was already a - * native TS `docker container inspect`, not a Go subprocess call. `db reset` - * still delegates its container-recreate + storage-health-gate primitives to - * that seam (`LegacyDbBootstrapSeam`); only this probe moved. + * (`commands/db/reset/reset.handler.ts`) — hoisted out of the `db __db-bootstrap` + * Go seam by CLI-1954, since this check was already a native TS `docker container + * inspect`, not a Go subprocess call. CLI-1955 later removed the rest of that seam + * too (`db reset`'s container-recreate + storage-health-gate primitives are now + * native — `recreate-local-database.ts`/`await-storage-ready.ts`), so the seam + * itself no longer exists at all. * * `resolveDbToml` mirrors the seam's own best-effort read: the caller has * already run Go's `LoadConfig` validation before reaching this check, so here @@ -105,7 +106,7 @@ export function legacyIsLocalDbRunning( const stderr = decodeChunks(stderrChunks).trim(); // Only a missing container means "not running". Any other inspect // failure propagates, matching Go's `AssertSupabaseDbIsRunning`. - if (!stderr.includes("No such container") && !stderr.includes("No such object")) { + if (!legacyIsContainerNotFoundMessage(stderr)) { // Go's `AssertServiceIsRunning` sets `CmdSuggestion = suggestDockerInstall` // on a daemon-connection failure (`misc.go:148-154`), so a down daemon // still surfaces the actionable Docker Desktop hint, not just raw stderr. diff --git a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts index 297292f114..e32fc8d34e 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts @@ -12,7 +12,7 @@ * - `SetupLocalDatabase` (initial schema bootstrap, `start.go:184-187`) — an * explicit follow-up, not container construction. * - Actually creating/starting the container and waiting for it to become - * healthy — that's {@link legacyStartContainer} (`./container-lifecycle.ts`) + * healthy — that's {@link legacyCreateContainer} (`./container-lifecycle.ts`) * and {@link legacyWaitForHealthyServices} (`./health-check.ts`), wired * up by each caller's own handler. */ @@ -23,7 +23,7 @@ import { localDbContainerId } from "../legacy-docker-ids.ts"; import { legacyToDockerPath } from "../legacy-docker-path.ts"; import { encodeToml } from "../legacy-go-output.encoders.ts"; import { LEGACY_POSTGRES_DEFAULT_ROOT_KEY } from "../legacy-local-config-values.ts"; -import type { LegacyStartContainerSpec } from "./docker-create-args.ts"; +import type { LegacyStartContainerSpec } from "../containers/docker-create-args.ts"; import { LEGACY_START_DB_RESTORE_SH } from "./templates/db-restore.sh.ts"; import { LEGACY_START_DB_SCHEMA_SQL } from "./templates/db-schema.sql.ts"; import { LEGACY_START_DB_SUPABASE_SQL } from "./templates/db-supabase.sql.ts"; diff --git a/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts new file mode 100644 index 0000000000..e6836fdc5b --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts @@ -0,0 +1,468 @@ +/** + * `db reset --local`'s container-recreate half — a strict 1:1 port of Go's + * `resetDatabase`/`resetDatabase14`/`resetDatabase15` + * (`apps/cli-go/internal/db/reset/reset.go:81-208`). This is DELIBERATELY NOT a + * thin wrapper over {@link legacyStartDatabase} (`./start-database.ts`, the + * `StartDatabase`-equivalent `db start`/`supabase start` share) — Go's own + * `resetDatabase15` never calls `StartDatabase` either. It is a distinctly + * different composition over the SAME underlying primitives + * (`legacyEnsureNetwork`, `legacyBuildPostgresStartContainerSpec`, + * `legacyCreateContainer`, `legacyWaitForHealthyServices`, + * `legacyStartSetupLocalDatabase`), matching Go's own structure: + * + * **PG >= 15** (`resetDatabase15`, `reset.go:146-174`): + * 1. `docker container rm -f ` — NOT tolerant of "not found" (a genuine + * remove failure is a hard `failed to remove container`), unlike most other + * container lookups in this codebase. + * 2. `docker volume rm -f ` — tolerant of "not found" via the `-f` flag + * ITSELF (verified against a real Docker daemon: `force` makes a missing + * volume's removal a no-op), so no special-casing is needed here either. + * 3. `legacyEnsureNetwork` (Go's `DockerStart` always ensures the network + * exists, on every call — this is hoisted out of `legacyCreateContainer` for + * the SAME reason `start-database.ts` hoists it). + * 4. `Recreating database...\n` to stderr (NOT `Starting database...`). + * 5. Build + create + start the Postgres container (byte-identical inputs to + * `legacyStartDatabase`'s own — there is no `fromBackup` variant on this + * path, reset has no restore concept) via the same + * `legacyBuildPostgresStartContainerSpec`/`legacyCreateContainer`. + * 6. Health wait — NEVER swallowed (no `--from-backup`-equivalent gate here at + * all). + * 7. `legacyStartSetupLocalDatabase` — UNCONDITIONALLY (no fresh-volume gate: a + * reset just removed the volume, so it's always fresh) and with the + * RESOLVED reset `version`/`seedFlags` (not `""` like `db start`'s own call) + * — see `db-setup.ts`'s own header for this one genuine parameter + * difference between the shared function's two real Go callers. + * 8. `Restarting containers...\n` to stderr, then + * {@link legacyRestartServicesAndReloadKong} (`./restart-services.ts`). + * + * **PG <= 14** (`resetDatabase14`, `reset.go:128-144`): + * 1. `recreateDatabase` (`reset.go:188-208`) — connect as `supabase_admin` to + * `template1`, `DisconnectClients`, then four UNWRAPPED (no `BEGIN`/`COMMIT`) + * statements: `DROP`/`CREATE DATABASE postgres`, `DROP`/`CREATE DATABASE + * _supabase`. Go batches these via a pgconn protocol trick that has no TS + * equivalent — not needed here: none of the four can ever run inside a + * transaction anyway, so plain sequential `session.exec` calls, relying on + * Effect's own short-circuit-on-failure, reproduce Go's "stop at first + * error" behavior exactly (verified empirically against real Postgres 14/15 + * with the pinned pgconn/pgx versions — the batching trick is real, but its + * OBSERVABLE effect is identical to sequential execution for this + * particular statement set). + * 2. `initDatabase` (`reset.go:176-186`) — connect as `supabase_admin` to the + * default `postgres` database, then Go's EXPORTED `InitSchema14` (schema SQL + * ONLY, deliberately WITHOUT globals.sql — see `legacyInitSchema14`'s + * own doc comment for why this is NOT the same as `db start`'s PG<=14 path) + * + `ApplyApiPrivileges` (the exact same exported function `SetupDatabase` + * also calls). + * 3. `RestartDatabase` (`reset.go:246-257`) — `Restarting containers...\n` + * FIRST, then a REAL `docker restart` of the `db` container itself (NOT + * tolerant of "not found" — pg_cron must restart after + * `pg_terminate_backend`, Go's own comment), health wait (not swallowed), + * then {@link legacyRestartServicesAndReloadKong} — so Kong reload happens on + * the PG14 path too, after the db container restart. + * 4. Final connect as `postgres`/`postgres` → `apply.MigrateAndSeed` with the + * resolved reset `version`/`seedFlags` — the same seed-override logic PG15's + * `legacyStartSetupLocalDatabase` call applies. + * + * Deliberately absent, matching Go exactly: no volume-existence probe (a reset + * just removed the volume, so there is nothing to probe), no `NoBackupVolume`/ + * fresh-volume concept, no `fromBackup` handling at all, `initCurrentBranch` is + * NEVER called (Go's `resetDatabase`/`resetDatabase14`/`resetDatabase15` never + * call it), and no rollback on failure (Go's `cmd/db.go` only wraps `--mode + * start` in a `DockerRemoveAll` cleanup — the recreate dispatch has none). + * + * `pgcache.TryCacheMigrationsCatalog`'s best-effort write (part of Go's + * `SetupLocalDatabase`, hence reachable from the PG15 path above via + * `legacyStartSetupLocalDatabase`) is intentionally left unported here too — + * see `db-setup.ts`'s own header for the reasoning (a documented, deliberate + * follow-up, not a silent drop: `db/reset/SIDE_EFFECTS.md`). + */ + +import { Data, Effect, Result, Schedule, type FileSystem, type Path } from "effect"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import { Output } from "../../../shared/output/output.service.ts"; +import type { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { legacyIsSqlState } from "../legacy-connect-errors.ts"; +import { legacyCheckDbToml } from "../legacy-db-config.toml-read.ts"; +import { LegacyDbConnection, type LegacyDbSession } from "../legacy-db-connection.service.ts"; +import type { LegacyDbConnectError, LegacyDbExecError } from "../legacy-db-connection.errors.ts"; +import { LEGACY_CLI_PROJECT_LABEL } from "../legacy-docker-ids.ts"; +import type { LegacyDockerRun } from "../legacy-docker-run.service.ts"; +import { legacyMigrateAndSeed } from "../legacy-migrate-and-seed.ts"; +import type { LegacyMigrationApplyError } from "../legacy-migration-apply.ts"; +import type { LegacyMigrationSeedError } from "../legacy-seed.ts"; +import { + legacyEnsureNetwork, + legacyRemoveContainer, + legacyRemoveVolume, + legacyCreateContainer, + LEGACY_COMPOSE_PROJECT_LABEL, + type LegacyContainerRemoveError, + type LegacyContainerError, + type LegacyContainerOpts, + type LegacyNetworkCreateError, + type LegacyVolumeRemoveError, +} from "../containers/container-lifecycle.ts"; +import { + legacyRunFreshDbSetup, + legacyResolveResetSeedConfig, + legacyApplyApiPrivileges, + legacyInitSchema14, + LegacyDbSetupError, + type LegacyFreshDbSetupInput, + type LegacyStartSetupLocalDatabaseError, +} from "./db-setup.ts"; +import { + legacyWaitForHealthyServices, + type LegacyHealthCheckTimeoutError, +} from "../containers/health-check.ts"; +import type { LegacyImagePrepullError } from "../containers/image-prepull.ts"; +import { legacyStartInternalDbPassword } from "./internal-db-connection.ts"; +import { + legacyBuildPostgresStartContainerSpec, + type LegacyPostgresStartServiceInput, +} from "./postgres.service.ts"; +import { + legacyRestartContainer, + legacyRestartServicesAndReloadKong, + type LegacyContainerRestartError, + type LegacyKongReloadError, + type LegacyRestartServicesError, +} from "./restart-services.ts"; + +type Spawner = ChildProcessSpawner["Service"]; + +const errMessage = (e: unknown): string => + typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" + ? e.message + : String(e); + +/** + * One or more replication slots are still active (retryable — the WAL sender + * that owns the slot may still be tearing down), OR counting them failed + * outright (permanent — Go's `backoff.PermanentError`, `reset.go:236-238`: + * a query-execution failure is never retried, only "count > 0" is). Not + * exported outside this module — callers discriminate this via the + * {@link LegacyRecreateLocalDatabaseError} union's `_tag`, never by importing + * the class itself (same pattern as `legacy-docker-remove-all.ts`). + */ +class LegacyResetReplicationSlotsError extends Data.TaggedError( + "LegacyResetReplicationSlotsError", +)<{ + readonly message: string; + readonly retryable: boolean; +}> {} + +/** Every failure the PG14/PG15 `db reset` recreate composition can produce. */ +export type LegacyRecreateLocalDatabaseError = + // PG15 (`resetDatabase15`) + | LegacyNetworkCreateError + | LegacyContainerRemoveError + | LegacyVolumeRemoveError + | LegacyContainerError + | LegacyImagePrepullError + | LegacyHealthCheckTimeoutError + | LegacyStartSetupLocalDatabaseError + // PG14 (`resetDatabase14`) + | LegacyDbConnectError + | LegacyDbExecError + | LegacyResetReplicationSlotsError + | LegacyDbSetupError + | LegacyContainerRestartError + | LegacyMigrationApplyError + | LegacyMigrationSeedError + // Shared post-recreate step (both branches) + | LegacyRestartServicesError + | LegacyKongReloadError; + +export interface LegacyRecreateLocalDatabaseInput { + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + readonly workdir: string; + readonly projectId: string; + readonly networkId: string; + readonly hostname: string; + /** `localDbContainerId(projectId)` — also this composition's own volume name (Go: `utils.DbId` names both). */ + readonly dbContainerId: string; + readonly dbPort: number; + readonly containerOpts: LegacyContainerOpts; + /** Fed straight to `legacyBuildPostgresStartContainerSpec` — reset has no `fromBackup` concept at all. */ + readonly postgresSpec: Omit; + /** Lazy — evaluated right where Go's `DockerStart` would resolve it (PG15 path only). */ + readonly resolvePostgresImage: Effect.Effect; + readonly dbHealthTimeoutSeconds: number; + /** The resolved reset migration version (`""` for every pending migration). */ + readonly version: string; + /** `db reset`'s `--no-seed`/`--sql-paths` — see {@link legacyResolveResetSeedConfig}. */ + readonly seedFlags: { readonly noSeed: boolean; readonly sqlPaths: ReadonlyArray }; + /** The exact same shape `start-database.ts`'s `LegacyStartDatabaseInput.setup` uses, since Go's `resetDatabase15` calls the SAME `SetupLocalDatabase` `db start` does — hoisted to {@link LegacyFreshDbSetupInput}. */ + readonly setup: LegacyFreshDbSetupInput; +} + +/** Go's `pgerrcode.InvalidCatalogName` (`3D000`) — "database doesn't exist yet" on a first-ever reset. */ +const PG_INVALID_CATALOG_NAME = "3D000"; + +/** + * Port of Go's `DisconnectClients` (`reset.go:215-244`): disable new connections + * to `postgres`/`_supabase`, terminate existing backends, then wait for WAL + * senders to drop their replication slots (constant 1-second backoff, 10 + * retries max — Go's `NewBackoffPolicy(ctx, 10*time.Second)`). + * + * Exported ONLY so `recreate-local-database.unit.test.ts` can pin the retry + * schedule's exact 10-retry boundary against a plain mocked {@link + * LegacyDbSession} (no real filesystem/Docker I/O), using the same `TestClock` + * + `Effect.forkChild` pattern as `commands/db/reset/await-storage-ready.unit.test.ts` — + * driving the full `legacyDbReset` composite effect through a fake clock isn't + * reliable (its many REAL filesystem awaits race unpredictably against a + * virtual-time nudge issued from the outside), so this narrower, no-real-I/O + * entry point is the one actually worth pinning this way; the full retry-count + * behavior stays covered end-to-end by `reset.integration.test.ts`'s own + * `it.live` tests. Not otherwise used outside this module. + */ +export const legacyResetDisconnectClients = Effect.fnUntraced(function* (session: LegacyDbSession) { + // Must be executed separately because looping in a transaction is unsupported + // (Go's own comment, `reset.go:216-217`) — sequential, unwrapped execs, relying on + // Effect's short-circuit-on-failure to stop at the first bad statement, exactly + // like pgconn's own batch-pipeline semantics would. + const disconnectResult = yield* Effect.forEach( + [ + "ALTER DATABASE postgres ALLOW_CONNECTIONS false", + "ALTER DATABASE _supabase ALLOW_CONNECTIONS false", + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IN ('postgres', '_supabase')", + ], + (sql) => session.exec(sql), + { discard: true }, + ).pipe(Effect.result); + if (Result.isFailure(disconnectResult)) { + const failure = disconnectResult.failure; + // Go: `if errors.As(err, &pgErr) && pgErr.Code != pgerrcode.InvalidCatalogName { return wrapped }` + // — surfaced ONLY for a genuine PgError whose code isn't 3D000. `failure.code` is NOT reliably + // only-ever-set-for-a-real-ErrorResponse: the driver layer's exec-error mapping + // (`legacyToExecError`) falls back to `legacyExtractSqlState`, which can surface a bare node + // system errno (`ECONNRESET`, `ETIMEDOUT`, …) as `code` too — those are NOT SQLSTATEs, and + // `errors.As(err, &pgErr)` never matches a socket error in Go, so this must check + // `legacyIsSqlState` before treating `code` as a genuine PgError code. A non-PgError failure + // (network blip, no `code`, or a `code` that isn't a real SQLSTATE) AND a 3D000 PgError are + // BOTH silently swallowed. + if ( + failure.code !== undefined && + legacyIsSqlState(failure.code) && + failure.code !== PG_INVALID_CATALOG_NAME + ) { + return yield* Effect.fail( + new LegacyDbSetupError({ + message: `failed to disconnect clients: ${failure.message}`, + }), + ); + } + } + + const countReplicationSlots = session + .query("SELECT COUNT(*) FROM pg_replication_slots WHERE database IN ('postgres', '_supabase')") + .pipe( + Effect.mapError( + (cause) => + new LegacyResetReplicationSlotsError({ + message: `failed to count replication slots: ${cause.message}`, + retryable: false, + }), + ), + Effect.flatMap((rows) => { + const count = Number(rows[0]?.["count"] ?? 0); + return count > 0 + ? Effect.fail( + new LegacyResetReplicationSlotsError({ + message: `replication slots still active: ${count}`, + retryable: true, + }), + ) + : Effect.void; + }), + ); + yield* countReplicationSlots.pipe( + Effect.retry({ + schedule: Schedule.max([Schedule.spaced("1 seconds"), Schedule.recurs(10)]), + while: (error) => error.retryable, + }), + ); +}); + +/** + * Port of Go's `recreateDatabase` (`reset.go:188-208`): connect as + * `supabase_admin` to `template1`, disconnect clients, then four UNWRAPPED + * statements. "We are not dropping roles here because they are cluster level + * entities. Use stop && start instead." (Go's own comment.) + */ +const legacyResetRecreateDatabases = Effect.fnUntraced(function* (session: LegacyDbSession) { + yield* legacyResetDisconnectClients(session); + yield* session.exec("DROP DATABASE IF EXISTS postgres WITH (FORCE)"); + yield* session.exec("CREATE DATABASE postgres WITH OWNER postgres"); + yield* session.exec("DROP DATABASE IF EXISTS _supabase WITH (FORCE)"); + yield* session.exec("CREATE DATABASE _supabase WITH OWNER postgres"); +}); + +/** + * Port of Go's `resetDatabase15` (`reset.go:146-174`) — see this module's own + * header for the full sequence and citations. + */ +const legacyRecreateLocalDatabase15 = ( + spawner: Spawner, + input: LegacyRecreateLocalDatabaseInput, +): Effect.Effect< + void, + LegacyRecreateLocalDatabaseError | E, + Output | LegacyDbConnection | LegacyDockerRun | RuntimeInfo | HttpClient.HttpClient +> => + Effect.gen(function* () { + const output = yield* Output; + + yield* legacyRemoveContainer(spawner, input.dbContainerId); + yield* legacyRemoveVolume(spawner, input.dbContainerId); + + yield* legacyEnsureNetwork(spawner, input.networkId, { + [LEGACY_CLI_PROJECT_LABEL]: input.projectId, + [LEGACY_COMPOSE_PROJECT_LABEL]: input.projectId, + }); + + yield* output.raw("Recreating database...\n", "stderr"); + + const resolvedPostgresImage = yield* input.resolvePostgresImage; + const postgresSpec = legacyBuildPostgresStartContainerSpec({ + ...input.postgresSpec, + image: resolvedPostgresImage, + }); + yield* legacyCreateContainer(spawner, postgresSpec, input.containerOpts); + + // Never swallowed — reset has no `--from-backup`-equivalent gate at all. + yield* legacyWaitForHealthyServices(spawner, [postgresSpec.containerName], { + timeoutSeconds: input.dbHealthTimeoutSeconds, + images: new Map([[postgresSpec.containerName, resolvedPostgresImage]]), + }); + + // UNCONDITIONAL — no fresh-volume gate: a reset just removed the volume above, so + // it's always fresh. Passes the RESOLVED reset `version`/`seedFlags`, unlike `db + // start`'s own call — see `db-setup.ts`'s header for this one real difference. + yield* legacyRunFreshDbSetup(spawner, { + fs: input.fs, + path: input.path, + workdir: input.workdir, + projectId: input.projectId, + networkId: input.networkId, + hostname: input.hostname, + dbPort: input.dbPort, + version: input.version, + seedFlags: input.seedFlags, + setup: input.setup, + }); + + yield* output.raw("Restarting containers...\n", "stderr"); + yield* legacyRestartServicesAndReloadKong(spawner, input.projectId); + }); + +/** + * Port of Go's `resetDatabase14` (`reset.go:128-144`) — see this module's own + * header for the full sequence and citations. Loads `config.toml` once, ahead of + * `initDatabase` (needs `api.auto_expose_new_tables`) and the final + * `MigrateAndSeed` (needs `db.migrations.enabled`/`[db.seed]`/pg-delta gate) — + * the same "each caller re-loads its own config" duplication `db start`'s own + * handler and `legacyStartSetupLocalDatabase` both already take independently. + */ +const legacyRecreateLocalDatabase14 = ( + spawner: Spawner, + input: LegacyRecreateLocalDatabaseInput, +): Effect.Effect< + void, + LegacyRecreateLocalDatabaseError | E, + Output | LegacyDbConnection | LegacyDockerRun | RuntimeInfo | HttpClient.HttpClient +> => + Effect.gen(function* () { + const { setup, fs, path, workdir } = input; + const dbPassword = legacyStartInternalDbPassword(setup.dbUrl); + const toml = yield* legacyCheckDbToml(fs, path, workdir); + const dbConnection = yield* LegacyDbConnection; + const output = yield* Output; + + const connectAs = (user: string, database: string) => + dbConnection.connect( + { host: input.hostname, port: input.dbPort, user, password: dbPassword, database }, + { isLocal: true, dnsResolver: "native" }, + ); + + // recreateDatabase: connect as `supabase_admin` to `template1`. + yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* connectAs("supabase_admin", "template1"); + yield* legacyResetRecreateDatabases(session); + }), + ); + + // initDatabase: connect as `supabase_admin` to the default `postgres` database. + yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* connectAs("supabase_admin", "postgres"); + const tmpDir = yield* fs + .makeTempDirectoryScoped({ prefix: "supabase-reset-db-setup-" }) + .pipe( + Effect.mapError( + (error) => + new LegacyDbSetupError({ + message: `failed to create temp directory: ${errMessage(error)}`, + }), + ), + ); + yield* legacyInitSchema14(session, fs, path, tmpDir, setup.majorVersion); + yield* legacyApplyApiPrivileges( + session, + fs, + path, + tmpDir, + toml.baseline.apiAutoExposeNewTables, + ); + }), + ); + + // RestartDatabase: "Restarting containers..." FIRST, then a REAL restart of the `db` + // container itself (pg_cron must restart after `pg_terminate_backend`) — NOT tolerant + // of "not found", unlike the satellite restarts inside `legacyRestartServicesAndReloadKong`. + yield* output.raw("Restarting containers...\n", "stderr"); + yield* legacyRestartContainer(spawner, input.dbContainerId); + yield* legacyWaitForHealthyServices(spawner, [input.dbContainerId], { + timeoutSeconds: input.dbHealthTimeoutSeconds, + }); + yield* legacyRestartServicesAndReloadKong(spawner, input.projectId); + + // Final connect as `postgres`/`postgres` -> apply.MigrateAndSeed(ctx, version, ...). + yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* connectAs("postgres", "postgres"); + yield* legacyMigrateAndSeed(session, fs, path, workdir, input.version, { + migrationsEnabled: toml.migrationsEnabled, + seed: legacyResolveResetSeedConfig(toml.seed, input.seedFlags, path), + experimental: setup.experimental, + pgDeltaEnabled: toml.pgDelta.enabled, + schemaPaths: setup.config.db.migrations.schema_paths, + }); + }), + ); + }); + +/** + * Runs the exact Go `resetDatabase`/`resetDatabase14`/`resetDatabase15` sequence — + * see this module's header for the full call order and citations. The caller has + * already printed `Resetting local database…`, matching Go's own `resetDatabase` + * wrapper (`reset.go:81-87`) minus that one line (which the seam this replaces + * used to print itself, and which `db/reset/reset.handler.ts` now prints + * directly, exactly like before). + */ +export const legacyRecreateLocalDatabase = ( + spawner: Spawner, + input: LegacyRecreateLocalDatabaseInput, +): Effect.Effect< + void, + LegacyRecreateLocalDatabaseError | E, + Output | LegacyDbConnection | LegacyDockerRun | RuntimeInfo | HttpClient.HttpClient +> => + input.setup.majorVersion <= 14 + ? legacyRecreateLocalDatabase14(spawner, input) + : legacyRecreateLocalDatabase15(spawner, input); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.unit.test.ts new file mode 100644 index 0000000000..c57b08ad80 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.unit.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Fiber } from "effect"; +import * as TestClock from "effect/testing/TestClock"; + +import type { LegacyDbSession } from "../legacy-db-connection.service.ts"; +import { LegacyDbExecError } from "../legacy-db-connection.errors.ts"; +import { legacyResetDisconnectClients } from "./recreate-local-database.ts"; + +const COUNT_REPLICATION_SLOTS = + "SELECT COUNT(*) FROM pg_replication_slots WHERE database IN ('postgres', '_supabase')"; + +/** + * A minimal {@link LegacyDbSession} mock built entirely from `Effect.succeed`/ + * `Effect.suspend` — no real filesystem/Docker I/O anywhere in the chain, unlike + * driving the full `legacyDbReset` composite effect through `TestClock`. That's + * what makes the boundary tests below reliable: `legacyResetDisconnectClients` + * reaches its retry schedule's sleep on the very first synchronous pass, so a + * single `TestClock.adjust` per round always lands exactly where expected — + * see `legacyResetDisconnectClients`'s own doc comment. + */ +function mockSession(opts: { + readonly counts?: ReadonlyArray; + readonly queryFails?: boolean; +}) { + const queries: Array = []; + let callIndex = 0; + const session: LegacyDbSession = { + exec: () => Effect.void, + query: (sql): Effect.Effect>, LegacyDbExecError> => + Effect.suspend(() => { + queries.push(sql); + if (sql !== COUNT_REPLICATION_SLOTS) return Effect.succeed([]); + if (opts.queryFails === true) { + return Effect.fail(new LegacyDbExecError({ message: "connection reset" })); + } + const counts = opts.counts ?? [0]; + const count = counts[Math.min(callIndex, counts.length - 1)] ?? 0; + callIndex++; + return Effect.succeed([{ count: String(count) }]); + }), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }; + return { + session, + get queries() { + return queries; + }, + }; +} + +describe("legacyResetDisconnectClients", () => { + it.effect("resolves once replication slots drain within the retry budget", () => + Effect.gen(function* () { + const mock = mockSession({ counts: [2, 1, 0] }); + const fiber = yield* legacyResetDisconnectClients(mock.session).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("1 seconds"); + yield* TestClock.adjust("1 seconds"); + const exit = yield* Fiber.await(fiber); + expect(Exit.isSuccess(exit)).toBe(true); + expect(mock.queries.filter((sql) => sql === COUNT_REPLICATION_SLOTS)).toHaveLength(3); + }), + ); + + it.effect( + "is still retrying after 9 one-second backoffs, but fails once the 10th is exhausted — pins Go's `NewBackoffPolicy(ctx, 10*time.Second)` constant", + () => + Effect.gen(function* () { + // Never drains — pegs the retry schedule to its hard 10-retry ceiling. + const mock = mockSession({ counts: [1] }); + const fiber = yield* legacyResetDisconnectClients(mock.session).pipe( + Effect.forkChild({ startImmediately: true }), + ); + + for (let i = 0; i < 9; i++) { + yield* TestClock.adjust("1 seconds"); + } + // Not yet exhausted — 9 retries is one short of Go's hardcoded 10-retry cap. + expect(fiber.pollUnsafe()).toBeUndefined(); + + // The 10th one-second backoff crosses the boundary. + yield* TestClock.adjust("1 seconds"); + const exit = yield* Fiber.await(fiber); + expect(Exit.isFailure(exit)).toBe(true); + expect(mock.queries.filter((sql) => sql === COUNT_REPLICATION_SLOTS)).toHaveLength(11); + }), + ); + + it.effect( + "fails permanently, without retrying, when counting replication slots itself fails", + () => + Effect.gen(function* () { + const mock = mockSession({ queryFails: true }); + const exit = yield* legacyResetDisconnectClients(mock.session).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + // A single attempt — the permanent (non-retryable) failure never retries. + expect(mock.queries.filter((sql) => sql === COUNT_REPLICATION_SLOTS)).toHaveLength(1); + }), + ); +}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/restart-services.ts b/apps/cli/src/legacy/shared/db-bootstrap/restart-services.ts new file mode 100644 index 0000000000..c440aa255d --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/restart-services.ts @@ -0,0 +1,240 @@ +/** + * Post-recreate satellite-container restart + Kong reload, shared by both PG14's + * `RestartDatabase` and PG15's `resetDatabase15` (`apps/cli-go/internal/db/reset/ + * reset.go:246-317`) — the ONLY two Go call sites of `restartServices`. Neither `db + * start` nor `supabase start` calls any of this: it exists purely to bring the + * satellite containers (storage/auth/realtime/pooler) back in sync with a `db` + * container that was just recreated or force-restarted out from under them, and to + * reload Kong's nginx so its cached upstream addresses (which may have changed if a + * satellite container came back on a different one) stop 502ing. + */ + +import { Data, Effect, Option, Result } from "effect"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import { legacyAqua } from "../legacy-colors.ts"; +import { + collectText, + legacyDescribeContainerCliFailure, + legacyIsContainerNotFoundMessage, + runContainerCliExpectSuccess, + spawnContainerCli, +} from "../legacy-container-cli.ts"; +import { legacyInspectContainerState } from "../legacy-docker-lifecycle.ts"; +import { legacyServiceContainerName } from "../legacy-docker-ids.ts"; + +type Spawner = ChildProcessSpawner["Service"]; + +/** `docker restart ` (the db container itself) failed — used only by PG14's `RestartDatabase`. */ +export class LegacyContainerRestartError extends Data.TaggedError("LegacyContainerRestartError")<{ + readonly message: string; +}> {} + +/** + * Port of Go's `Docker.ContainerRestart(ctx, utils.DbId, container.StopOptions{})` + * (`apps/cli-go/internal/db/reset/reset.go:250-252`), used ONLY by PG14's + * `RestartDatabase` to restart the `db` container itself after `pg_terminate_backend` + * (pg_cron must restart, per Go's own comment). Unlike the satellite restarts below, + * this one does NOT tolerate "not found" — Go's own `RestartDatabase` has no + * `errdefs.IsNotFound` guard on this call at all, so ANY failure is a hard + * `failed to restart container: %w`. + */ +export function legacyRestartContainer( + spawner: Spawner, + containerId: string, +): Effect.Effect { + return runContainerCliExpectSuccess( + spawner, + ["restart", containerId], + "restart container", + (message) => new LegacyContainerRestartError({ message }), + ); +} + +/** + * One satellite service's restart, tolerant of "not found" (Go's `!errdefs.IsNotFound(err)` + * guard, `reset.go:263`) — a service excluded from the stack (e.g. `[realtime] enabled = + * false`) has no container to restart, and that's not an error. Never fails the surrounding + * `Effect.all` itself: resolves `Option.some(message)` on a genuine failure so the caller + * can join every service's outcome the way Go's `errors.Join(result...)` does, and + * `Option.none()` on success OR a tolerated not-found. + */ +const legacyRestartSatelliteService = ( + spawner: Spawner, + containerId: string, +): Effect.Effect> => + Effect.scoped( + Effect.gen(function* () { + const child = yield* spawnContainerCli(spawner, ["restart", containerId], { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }); + const [exitCode, stderr] = yield* Effect.all( + [child.exitCode.pipe(Effect.map(Number)), collectText(child.stderr)], + { concurrency: "unbounded" }, + ); + if (exitCode === 0) return Option.none(); + const trimmed = stderr.trim(); + if (legacyIsContainerNotFoundMessage(trimmed)) return Option.none(); + return Option.some( + `failed to restart ${containerId}: ${trimmed.length > 0 ? trimmed : `exit ${exitCode}`}`, + ); + }), + ).pipe( + Effect.catch((cause) => + Effect.succeed( + Option.some( + `failed to restart ${containerId}: ${legacyDescribeContainerCliFailure(cause)}`, + ), + ), + ), + ); + +/** One or more satellite-service restarts failed. Messages are newline-joined, matching Go's `errors.Join`. */ +export class LegacyRestartServicesError extends Data.TaggedError("LegacyRestartServicesError")<{ + readonly message: string; +}> {} + +/** + * Port of Go's `restartServices` restart half (`reset.go:259-271`): restarts + * storage/auth/realtime/pooler CONCURRENTLY (Go's `utils.WaitAll`, a goroutine per + * service) — NOT PostgREST, which "automatically reconnects and listens for schema + * changes" (Go's own comment) — and does NOT wait for them to become healthy + * afterward ("those services may be excluded from starting"). Every per-service + * failure (excluding a tolerated not-found) is joined into one newline-separated + * message, matching `errors.Join`. Not exported outside this module — only + * {@link legacyRestartServicesAndReloadKong} calls this directly. + */ +function legacyRestartSatelliteServices( + spawner: Spawner, + projectId: string, +): Effect.Effect { + const containerIds = [ + legacyServiceContainerName("storage", projectId), + legacyServiceContainerName("auth", projectId), + legacyServiceContainerName("realtime", projectId), + legacyServiceContainerName("pooler", projectId), + ]; + return Effect.gen(function* () { + const results = yield* Effect.all( + containerIds.map((containerId) => legacyRestartSatelliteService(spawner, containerId)), + { concurrency: "unbounded" }, + ); + const failures = results.filter(Option.isSome).map((result) => result.value); + if (failures.length > 0) { + return yield* Effect.fail(new LegacyRestartServicesError({ message: failures.join("\n") })); + } + }); +} + +/** + * Gateway-recovery hint, byte-matching Go's `suggestKongRecovery` + * (`reset.go:307-317`): rendered as a `Suggestion:` line by `Output.fail`, mirroring + * `utils.CmdSuggestion`. + */ +function legacyKongRecoverySuggestion(kongId: string): string { + return ( + "Local services restarted, but API routes may return 502 until the gateway reloads.\n" + + `Try restarting it with ${legacyAqua(`docker restart ${kongId}`)}, and check ${legacyAqua( + `docker logs ${kongId}`, + )} if the failure persists.` + ); +} + +/** Kong could not be reloaded — fails the WHOLE command (unlike `functions serve`'s best-effort reload). */ +export class LegacyKongReloadError extends Data.TaggedError("LegacyKongReloadError")<{ + readonly message: string; + readonly suggestion: string; +}> {} + +/** `docker exec `, combined stdout+stderr into one buffer — mirrors Go's shared `io.Writer` in `DockerExecOnceWithStream(ctx, KongId, "", nil, cmd, &out, &out)`. Never fails the Effect itself: a spawn failure (no docker/podman) folds into `exitCode: 1`. */ +function legacyExecCaptureCombined( + spawner: Spawner, + containerId: string, + cmd: ReadonlyArray, +): Effect.Effect<{ readonly exitCode: number; readonly output: string }> { + return Effect.scoped( + Effect.gen(function* () { + const child = yield* spawnContainerCli(spawner, ["exec", containerId, ...cmd], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + child.exitCode.pipe(Effect.map(Number)), + collectText(child.stdout), + collectText(child.stderr), + ], + { concurrency: "unbounded" }, + ); + return { exitCode, output: stdout + stderr }; + }), + ).pipe( + Effect.catch((cause) => + Effect.succeed({ exitCode: 1, output: legacyDescribeContainerCliFailure(cause) }), + ), + ); +} + +/** + * Port of Go's `reloadKong` (`reset.go:285-305`): inspect Kong's container — not + * found means Kong is excluded from the stack (`return nil`, not an error); any OTHER + * inspect failure is wrapped with the recovery suggestion; not running means there's + * no stale cache to flush (`return nil`); otherwise `docker exec kong + * reload`, failing hard (with the same suggestion) on a non-zero exit, the combined + * output appended when non-empty. Not exported outside this module — only + * {@link legacyRestartServicesAndReloadKong} calls this directly. + */ +function legacyReloadKong( + spawner: Spawner, + projectId: string, +): Effect.Effect { + const kongId = legacyServiceContainerName("kong", projectId); + return Effect.gen(function* () { + const inspected = yield* legacyInspectContainerState(spawner, kongId).pipe(Effect.result); + if (Result.isFailure(inspected)) { + if (legacyIsContainerNotFoundMessage(inspected.failure.message)) return; + return yield* Effect.fail( + new LegacyKongReloadError({ + message: `failed to inspect kong: ${inspected.failure.message}`, + suggestion: legacyKongRecoverySuggestion(kongId), + }), + ); + } + if (!inspected.success.running) return; + const result = yield* legacyExecCaptureCombined(spawner, kongId, ["kong", "reload"]); + if (result.exitCode !== 0) { + const trimmed = result.output.trim(); + // Go's `DockerExecOnceWithStream` (`utils/docker.go:646-648`) sets a FIXED constant + // error, `errors.New("error executing command")`, for `iresp.ExitCode > 0` — not the + // exit code itself. `reloadKong` then wraps it as `failed to reload kong: %w[:\n%s]` + // (`reset.go:298-303`), so the `%w` slot is always this exact string, never `exit N`. + return yield* Effect.fail( + new LegacyKongReloadError({ + message: + trimmed.length > 0 + ? `failed to reload kong: error executing command:\n${trimmed}` + : "failed to reload kong: error executing command", + suggestion: legacyKongRecoverySuggestion(kongId), + }), + ); + } + }); +} + +/** + * Port of Go's `restartServices` (`reset.go:259-273`): the satellite restarts above, + * then {@link legacyReloadKong} — ONLY when every restart succeeded (Go returns the + * joined restart error immediately, without ever attempting the Kong reload). + */ +export function legacyRestartServicesAndReloadKong( + spawner: Spawner, + projectId: string, +): Effect.Effect { + return Effect.gen(function* () { + yield* legacyRestartSatelliteServices(spawner, projectId); + yield* legacyReloadKong(spawner, projectId); + }); +} diff --git a/apps/cli/src/legacy/shared/db-bootstrap/restart-services.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/restart-services.unit.test.ts new file mode 100644 index 0000000000..f7ff729eb0 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/restart-services.unit.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + LegacyContainerRestartError, + LegacyKongReloadError, + legacyRestartContainer, + legacyRestartServicesAndReloadKong, +} from "./restart-services.ts"; + +/** Matches the standing `mockSpawner` shape used across `legacy-docker-*.unit.test.ts` files. */ +function mockSpawner( + handler: (args: ReadonlyArray) => { exitCode: number; stdout?: string; stderr?: string }, +) { + const spawned: Array> = []; + + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push(args); + const result = handler(args); + + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(result.exitCode)); + + const encoder = new TextEncoder(); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.fromIterable( + result.stdout !== undefined ? [encoder.encode(result.stdout)] : [], + ), + stderr: Stream.fromIterable( + result.stderr !== undefined ? [encoder.encode(result.stderr)] : [], + ), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + return { + spawner, + get spawned() { + return spawned; + }, + }; +} + +const HEALTHY_STATE = '{"Running":true,"Status":"running","Health":{"Status":"healthy"}}'; +const STOPPED_STATE = '{"Running":false,"Status":"exited"}'; + +describe("legacyRestartContainer", () => { + it.live("spawns `docker restart ` and succeeds on exit 0", () => { + const mock = mockSpawner(() => ({ exitCode: 0 })); + return legacyRestartContainer(mock.spawner, "supabase_db_proj").pipe( + Effect.map(() => { + expect(mock.spawned).toEqual([["restart", "supabase_db_proj"]]); + }), + ); + }); + + it.live('fails on a "not found" restart — NOT tolerant, unlike the satellite restarts', () => { + const mock = mockSpawner(() => ({ + exitCode: 1, + stderr: "Error: No such container: supabase_db_proj\n", + })); + return legacyRestartContainer(mock.spawner, "supabase_db_proj").pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyContainerRestartError); + expect(error.message).toContain("failed to restart container"); + }), + ); + }); +}); + +describe("legacyRestartServicesAndReloadKong", () => { + const PROJECT_ID = "proj"; + const KONG_ID = "supabase_kong_proj"; + + it.live("restarts the four satellite services then reloads Kong", () => { + const mock = mockSpawner((args) => { + if (args[0] === "container" && args[1] === "inspect") + return { exitCode: 0, stdout: HEALTHY_STATE }; + return { exitCode: 0 }; + }); + return legacyRestartServicesAndReloadKong(mock.spawner, PROJECT_ID).pipe( + Effect.map(() => { + const restarted = mock.spawned.filter((args) => args[0] === "restart").map((a) => a[1]); + expect(restarted).toEqual( + expect.arrayContaining([ + "supabase_storage_proj", + "supabase_auth_proj", + "supabase_realtime_proj", + "supabase_pooler_proj", + ]), + ); + expect(mock.spawned.some((args) => args[0] === "exec" && args[1] === KONG_ID)).toBe(true); + }), + ); + }); + + it.live("restarts the four satellite services CONCURRENTLY, not sequentially", () => + Effect.gen(function* () { + const barrier = yield* Deferred.make(); + let inFlight = 0; + const restarted: Array = []; + + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = command._tag === "StandardCommand" ? command.args : []; + if (args[0] === "restart") { + restarted.push(args[1] ?? ""); + inFlight++; + if (inFlight === 4) yield* Deferred.succeed(barrier, undefined); + // Every one of the four restarts blocks here until ALL FOUR are in flight + // simultaneously (Go's `utils.WaitAll`, a goroutine per service — reset.go:259-271). + // If `legacyRestartSatelliteServices` ever regressed to a sequential restart (e.g. + // `concurrency: 1`), the second restart would never even be DISPATCHED until the + // first resolves, so `inFlight` would never reach 4 and this `await` would hang + // forever, timing out the test instead of silently passing. + yield* Deferred.await(barrier); + } else if (args[0] === "container" && args[1] === "inspect" && args[2] === KONG_ID) { + // Kong excluded from the stack — skips the reload, keeping this test focused on + // the satellite-restart concurrency guarantee alone. + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(1)); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.empty, + stderr: Stream.fromIterable([ + new TextEncoder().encode(`Error: No such container: ${KONG_ID}\n`), + ]), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + } + + const exitDeferred = yield* Deferred.make(); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(0)); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + yield* legacyRestartServicesAndReloadKong(spawner, PROJECT_ID); + + expect(restarted).toEqual( + expect.arrayContaining([ + "supabase_storage_proj", + "supabase_auth_proj", + "supabase_realtime_proj", + "supabase_pooler_proj", + ]), + ); + }), + ); + + it.live('tolerates a "not found" satellite restart without failing', () => { + const mock = mockSpawner((args) => { + if (args[0] === "restart" && args[1] === "supabase_realtime_proj") { + return { exitCode: 1, stderr: "Error: No such container: supabase_realtime_proj\n" }; + } + if (args[0] === "container" && args[1] === "inspect") + return { exitCode: 0, stdout: HEALTHY_STATE }; + return { exitCode: 0 }; + }); + return legacyRestartServicesAndReloadKong(mock.spawner, PROJECT_ID).pipe(Effect.asVoid); + }); + + it.live("joins multiple satellite-restart failures and never attempts the Kong reload", () => { + const mock = mockSpawner((args) => { + if (args[0] === "restart" && args[1] === "supabase_storage_proj") { + return { exitCode: 1, stderr: "boom-storage" }; + } + if (args[0] === "restart" && args[1] === "supabase_auth_proj") { + return { exitCode: 1, stderr: "boom-auth" }; + } + return { exitCode: 0 }; + }); + return legacyRestartServicesAndReloadKong(mock.spawner, PROJECT_ID).pipe( + Effect.flip, + Effect.map((error) => { + expect(error.message).toContain("failed to restart supabase_storage_proj"); + expect(error.message).toContain("failed to restart supabase_auth_proj"); + expect(mock.spawned.some((args) => args[0] === "exec")).toBe(false); + }), + ); + }); + + it.live("skips the reload without failing when Kong is excluded from the stack", () => { + const mock = mockSpawner((args) => { + if (args[0] === "container" && args[1] === "inspect" && args[2] === KONG_ID) { + return { exitCode: 1, stderr: `Error: No such container: ${KONG_ID}\n` }; + } + return { exitCode: 0 }; + }); + return legacyRestartServicesAndReloadKong(mock.spawner, PROJECT_ID).pipe( + Effect.map(() => { + expect(mock.spawned.some((args) => args[0] === "exec")).toBe(false); + }), + ); + }); + + it.live("skips the reload without failing when Kong is present but stopped", () => { + const mock = mockSpawner((args) => { + if (args[0] === "container" && args[1] === "inspect" && args[2] === KONG_ID) { + return { exitCode: 0, stdout: STOPPED_STATE }; + } + return { exitCode: 0 }; + }); + return legacyRestartServicesAndReloadKong(mock.spawner, PROJECT_ID).pipe( + Effect.map(() => { + expect(mock.spawned.some((args) => args[0] === "exec")).toBe(false); + }), + ); + }); + + it.live("fails with the exact suggestion when the Kong inspect fails for another reason", () => { + const mock = mockSpawner((args) => { + if (args[0] === "container" && args[1] === "inspect" && args[2] === KONG_ID) { + return { exitCode: 1, stderr: "Cannot connect to the Docker daemon\n" }; + } + return { exitCode: 0 }; + }); + return legacyRestartServicesAndReloadKong(mock.spawner, PROJECT_ID).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyKongReloadError); + if (!(error instanceof LegacyKongReloadError)) return; + expect(error.message).toContain("failed to inspect kong"); + expect(error.suggestion).toContain( + "Local services restarted, but API routes may return 502", + ); + expect(error.suggestion).toContain(`docker restart ${KONG_ID}`); + expect(error.suggestion).toContain(`docker logs ${KONG_ID}`); + }), + ); + }); + + it.live("fails with the combined output and suggestion when `kong reload` itself fails", () => { + const mock = mockSpawner((args) => { + if (args[0] === "container" && args[1] === "inspect" && args[2] === KONG_ID) { + return { exitCode: 0, stdout: HEALTHY_STATE }; + } + if (args[0] === "exec" && args[1] === KONG_ID) { + return { exitCode: 1, stderr: "nginx: [error] invalid config\n" }; + } + return { exitCode: 0 }; + }); + return legacyRestartServicesAndReloadKong(mock.spawner, PROJECT_ID).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyKongReloadError); + if (!(error instanceof LegacyKongReloadError)) return; + // Byte-matches Go: `DockerExecOnceWithStream` sets a fixed `error executing command` + // for a non-zero exec exit code (`utils/docker.go:646-648`) — not the exit code itself. + expect(error.message).toContain("failed to reload kong: error executing command"); + expect(error.message).toContain("nginx: [error] invalid config"); + expect(error.suggestion).toContain(`docker restart ${KONG_ID}`); + }), + ); + }); +}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/rollback.ts b/apps/cli/src/legacy/shared/db-bootstrap/rollback.ts index 83186f2c64..3613430938 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/rollback.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/rollback.ts @@ -4,7 +4,7 @@ import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSp import type { LegacyContainerIdName } from "../legacy-docker-lifecycle.ts"; import { legacyDockerRemoveAll } from "../legacy-docker-remove-all.ts"; import { legacyCleanupStartSecrets } from "../legacy-start-secrets-cleanup.ts"; -import { LegacyHealthCheckTimeoutError } from "./health-check.ts"; +import { LegacyHealthCheckTimeoutError } from "../containers/health-check.ts"; type Spawner = ChildProcessSpawner["Service"]; diff --git a/apps/cli/src/legacy/shared/db-bootstrap/rollback.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/rollback.unit.test.ts index de172b4b46..eacec72842 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/rollback.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/rollback.unit.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "@effect/vitest"; import { Data, Deferred, Effect, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { LegacyHealthCheckTimeoutError } from "./health-check.ts"; +import { LegacyHealthCheckTimeoutError } from "../containers/health-check.ts"; import { legacyIsUnhealthyStartError, legacyRollbackStart } from "./rollback.ts"; function captureStderr() { diff --git a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts index 07aa056a63..48fc11b9d1 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts @@ -49,42 +49,38 @@ import type * as HttpClient from "effect/unstable/http/HttpClient"; import { Output } from "../../../shared/output/output.service.ts"; import type { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; -import type { LocalServiceVersionOverrides } from "../../../shared/services/services.shared.ts"; import { legacyAqua } from "../legacy-colors.ts"; import { LegacyDbConnection } from "../legacy-db-connection.service.ts"; import type { LegacyDbConnectError } from "../legacy-db-connection.errors.ts"; import { LEGACY_CLI_PROJECT_LABEL } from "../legacy-docker-ids.ts"; import type { LegacyDockerRun } from "../legacy-docker-run.service.ts"; import { - legacyEnsureStartNetwork, - legacyStartContainer, - legacyStartVolumeExists, + legacyEnsureNetwork, + legacyCreateContainer, + legacyVolumeExists, LEGACY_COMPOSE_PROJECT_LABEL, - type LegacyStartContainerCreateError, - type LegacyStartContainerOpts, - type LegacyStartContainerStartError, - type LegacyStartNetworkCreateError, - type LegacyStartVolumeCreateError, - type LegacyStartVolumeInspectError, -} from "./container-lifecycle.ts"; + type LegacyContainerCreateError, + type LegacyContainerOpts, + type LegacyContainerStartError, + type LegacyNetworkCreateError, + type LegacyVolumeCreateError, + type LegacyVolumeInspectError, +} from "../containers/container-lifecycle.ts"; import { + legacyRunFreshDbSetup, legacyStartInitCurrentBranch, - legacyStartSetupLocalDatabase, - type LegacyStartDbSetupImages, + type LegacyFreshDbSetupInput, type LegacyStartSetupLocalDatabaseError, - type LegacyStartSetupLocalDatabaseInput, } from "./db-setup.ts"; -import { legacyEnsureImagesCached, type LegacyImagePrepullError } from "./image-prepull.ts"; +import type { LegacyImagePrepullError } from "../containers/image-prepull.ts"; import { legacyWaitForHealthyServices, type LegacyHealthCheckTimeoutError, -} from "./health-check.ts"; -import { legacyStartInternalDbPassword } from "./internal-db-connection.ts"; +} from "../containers/health-check.ts"; import { LEGACY_START_STARTING_DATABASE_FROM_BACKUP_MESSAGE, LEGACY_START_STARTING_DATABASE_MESSAGE, } from "./messages.ts"; -import { legacyResolvePinnedImage } from "./pinned-image.ts"; import { legacyBuildPostgresStartContainerSpec, type LegacyPostgresStartServiceInput, @@ -111,46 +107,17 @@ class LegacyStartBackupVolumeExistsError extends Data.TaggedError( /** Every failure {@link legacyStartDatabase} itself can produce, independent of the caller's own `E`. */ export type LegacyStartDatabaseError = - | LegacyStartNetworkCreateError - | LegacyStartVolumeInspectError + | LegacyNetworkCreateError + | LegacyVolumeInspectError | LegacyStartBackupVolumeExistsError - | LegacyStartVolumeCreateError - | LegacyStartContainerCreateError - | LegacyStartContainerStartError + | LegacyVolumeCreateError + | LegacyContainerCreateError + | LegacyContainerStartError | LegacyImagePrepullError | LegacyHealthCheckTimeoutError | LegacyDbConnectError | LegacyStartSetupLocalDatabaseError; -/** - * Everything {@link legacyStartSetupLocalDatabase} needs, minus what `legacyStartDatabase` - * itself already resolves/threads through (`session`, `majorVersion`, `projectId`, - * `networkId`, `images`). Not exported outside this module — callers build this shape as - * the `setup` field of {@link LegacyStartDatabaseInput} without needing to name the type. - */ -interface LegacyStartDatabaseSetupInput { - readonly majorVersion: number; - /** Already spliced with the caller's own realtime/storage/auth enabled-for-setup + ip_version/max_header_length/file_size_limit overrides — see `bootstrap-config.ts`'s `LegacyDbBootstrapConfig`. */ - readonly config: LegacyStartSetupLocalDatabaseInput["config"]; - /** Threaded straight through to {@link LegacyStartSetupLocalDatabaseInput.experimental} — see its own doc comment. */ - readonly experimental: boolean; - readonly dbUrl: string; - readonly jwtSecret: string; - /** Lazy — evaluated only when reached (fresh volume, `fromBackup` unset) AND `realtimeEnabledForSetup`. See this module's header for why this is caller-supplied rather than resolved here unconditionally. */ - readonly jwks: Effect.Effect; - readonly apiUrl: string; - readonly authExternalUrl: string | undefined; - readonly siteUrl: string; - readonly anonKey: string; - readonly serviceRoleKey: string; - readonly storageTargetMigration: string; - readonly realtimeEnabledForSetup: boolean; - readonly storageEnabledForSetup: boolean; - readonly authEnabledForSetup: boolean; - readonly serviceVersionOverrides: LocalServiceVersionOverrides; - readonly projectEnvValues: Readonly> | undefined; -} - export interface LegacyStartDatabaseInput { readonly fs: FileSystem.FileSystem; readonly path: Path.Path; @@ -161,7 +128,7 @@ export interface LegacyStartDatabaseInput { /** `localDbContainerId(projectId)` — also the connect-target host inside the local Postgres session below. */ readonly dbContainerId: string; readonly dbPort: number; - readonly containerOpts: LegacyStartContainerOpts; + readonly containerOpts: LegacyContainerOpts; /** Fed straight to `legacyBuildPostgresStartContainerSpec` — `fromBackup` (if set) drives BOTH the restore-entrypoint variant and the backup-volume-exists guard below. */ readonly postgresSpec: Omit; /** @@ -173,7 +140,7 @@ export interface LegacyStartDatabaseInput { */ readonly resolvePostgresImage: Effect.Effect; readonly dbHealthTimeoutSeconds: number; - readonly setup: LegacyStartDatabaseSetupInput; + readonly setup: LegacyFreshDbSetupInput; /** * Fired synchronously, exactly once, right after the pre-create volume probe resolves — * the caller's own equivalent of Go's package-level `utils.NoBackupVolume` global, needed by @@ -198,9 +165,8 @@ export const legacyStartDatabase = ( > => Effect.gen(function* () { const output = yield* Output; - const dbConnection = yield* LegacyDbConnection; - yield* legacyEnsureStartNetwork(spawner, input.networkId, { + yield* legacyEnsureNetwork(spawner, input.networkId, { [LEGACY_CLI_PROJECT_LABEL]: input.projectId, [LEGACY_COMPOSE_PROJECT_LABEL]: input.projectId, }); @@ -208,7 +174,7 @@ export const legacyStartDatabase = ( // Go's pre-create volume-existence check (`internal/db/start/start.go:165-167`) — MUST run // before Postgres's own volume gets created below: `docker volume create` is idempotent, so // creating first would make "did this volume already exist" unobservable. - const isFreshVolume = !(yield* legacyStartVolumeExists(spawner, input.dbContainerId)); + const isFreshVolume = !(yield* legacyVolumeExists(spawner, input.dbContainerId)); input.onFreshVolumeResolved(isFreshVolume); const fromBackup = input.postgresSpec.fromBackup; @@ -237,7 +203,7 @@ export const legacyStartDatabase = ( ...input.postgresSpec, image: resolvedPostgresImage, }); - yield* legacyStartContainer(spawner, postgresSpec, input.containerOpts); + yield* legacyCreateContainer(spawner, postgresSpec, input.containerOpts); const postgresHealthResult = yield* legacyWaitForHealthyServices( spawner, @@ -262,89 +228,21 @@ export const legacyStartDatabase = ( // (`start.go:184-188`) — SKIPPED IN FULL when `fromBackup` is set, not merely reduced: no // initSchema/ApplyApiPrivileges/vault/roles.sql/MigrateAndSeed on that path at all. if (isFreshVolume && fromBackup === undefined) { - yield* Effect.scoped( - Effect.gen(function* () { - const { setup } = input; - const dbPassword = legacyStartInternalDbPassword(setup.dbUrl); - const session = yield* dbConnection.connect( - { - host: input.hostname, - port: input.dbPort, - user: "postgres", - password: dbPassword, - database: "postgres", - }, - { isLocal: true, dnsResolver: "native" }, - ); - - // Go's `initSchema15`'s realtime job resolves JWKS itself — see this module's header - // for why this is a caller-supplied lazy `Effect`, gated the same way Go gates the - // call: only when reached AND `Realtime.Enabled`. - const jwks = setup.realtimeEnabledForSetup ? yield* setup.jwks : ""; - - // Go's one-shot fresh-DB setup jobs (`initSchema15`) use the SAME already-pin-rewritten - // `utils.Config.{Realtime,Storage,Auth}.Image` fields the long-running containers would - // use (`internal/db/start/start.go:270,299,321`), regardless of `--exclude` — resolved - // through `legacyResolvePinnedImage`, not the raw Dockerfile default, so a linked - // project's version pins apply here too. Resolved lazily (only when the job will - // actually run), matching Go's own `ensureImagesCached` (`start.go:237-262`), which - // never pre-pulls these for EITHER caller. - const rawSetupJobImages = { - realtime: legacyResolvePinnedImage( - "realtime", - "realtime", - setup.serviceVersionOverrides, - ), - storage: legacyResolvePinnedImage("storage", "storage", setup.serviceVersionOverrides), - auth: legacyResolvePinnedImage("gotrue", "auth", setup.serviceVersionOverrides), - }; - const setupJobImagesToResolve = - setup.majorVersion >= 15 - ? [ - ...(setup.realtimeEnabledForSetup ? [rawSetupJobImages.realtime] : []), - ...(setup.storageEnabledForSetup ? [rawSetupJobImages.storage] : []), - ...(setup.authEnabledForSetup ? [rawSetupJobImages.auth] : []), - ] - : []; - const resolvedSetupJobImages = - setupJobImagesToResolve.length > 0 - ? yield* legacyEnsureImagesCached( - spawner, - setupJobImagesToResolve, - setup.projectEnvValues, - ) - : new Map(); - const resolveSetupJobImage = (image: string) => - resolvedSetupJobImages.get(image) ?? image; - const dbSetupImages: LegacyStartDbSetupImages = { - realtime: resolveSetupJobImage(rawSetupJobImages.realtime), - storage: resolveSetupJobImage(rawSetupJobImages.storage), - auth: resolveSetupJobImage(rawSetupJobImages.auth), - }; - - yield* legacyStartSetupLocalDatabase({ - session, - fs: input.fs, - path: input.path, - workdir: input.workdir, - config: setup.config, - experimental: setup.experimental, - majorVersion: setup.majorVersion, - projectId: input.projectId, - networkId: input.networkId, - dbUrl: setup.dbUrl, - jwtSecret: setup.jwtSecret, - jwks, - apiUrl: setup.apiUrl, - authExternalUrl: setup.authExternalUrl, - siteUrl: setup.siteUrl, - anonKey: setup.anonKey, - serviceRoleKey: setup.serviceRoleKey, - storageTargetMigration: setup.storageTargetMigration, - images: dbSetupImages, - }); - }), - ); + yield* legacyRunFreshDbSetup(spawner, { + fs: input.fs, + path: input.path, + workdir: input.workdir, + projectId: input.projectId, + networkId: input.networkId, + hostname: input.hostname, + dbPort: input.dbPort, + // Go's own `StartDatabase` -> `SetupLocalDatabase(ctx, "", ...)` call + // (`start.go:185`) — every pending migration, no `db reset`-only seed + // override (`db start` has neither `--no-seed` nor `--sql-paths`). + version: "", + seedFlags: { noSeed: false, sqlPaths: [] }, + setup: input.setup, + }); } // Go's `initCurrentBranch` (`db/start/start.go:189`) — the LAST line of `StartDatabase`, diff --git a/apps/cli/src/legacy/shared/legacy-bitbucket-pipeline.ts b/apps/cli/src/legacy/shared/legacy-bitbucket-pipeline.ts index 9cee8f5dad..9edbbfcbe9 100644 --- a/apps/cli/src/legacy/shared/legacy-bitbucket-pipeline.ts +++ b/apps/cli/src/legacy/shared/legacy-bitbucket-pipeline.ts @@ -9,7 +9,7 @@ * * Hoisted here because it is needed by ≥2 call sites: `legacy-docker-run.layer.ts` * (`docker run`, e.g. `db dump`/`db test`) and `start`'s per-service container - * creation (`legacy/shared/db-bootstrap/container-lifecycle.ts`). + * creation (`legacy/shared/containers/container-lifecycle.ts`). */ export function legacyIsBitbucketPipeline(): boolean { const value = globalThis.process.env["BITBUCKET_CLONE_DIR"]; diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.ts b/apps/cli/src/legacy/shared/legacy-container-cli.ts index 07b10dae3b..9b57ae9910 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.ts @@ -122,7 +122,13 @@ export const containerCliExitCode = ( ), ); -function collectDockerCliText(stream: Stream.Stream) { +/** + * Folds a byte stream into a decoded string. Hoisted here (the shared home for + * container-CLI plumbing) so `container-lifecycle.ts`/`restart-services.ts`/ + * `legacy-docker-lifecycle.ts` — every module that spawns `docker`/`podman` and + * needs its stdout/stderr as text — stop each defining their own copy. + */ +export function collectText(stream: Stream.Stream) { const decoder = new TextDecoder(); return Stream.runFold( stream, @@ -131,6 +137,59 @@ function collectDockerCliText(stream: Stream.Stream) { ).pipe(Effect.map((text) => text + decoder.decode())); } +/** + * Docker's/Podman's "container doesn't exist" stderr shapes — "No such container" + * or "No such object" depending on daemon version/CLI path — Go's + * `errdefs.IsNotFound(err)` equivalent for a CLI-shelled-out (rather than + * Engine-API) caller. Hoisted here so callers across the container-lifecycle/ + * restart/health-check domain (`legacyIsLocalDbRunning`, + * `legacyRestartSatelliteService`, `legacyReloadKong`) share one predicate + * instead of re-deriving the same substring match. + */ +export function legacyIsContainerNotFoundMessage(message: string): boolean { + return message.includes("No such container") || message.includes("No such object"); +} + +/** + * Runs a container-CLI command that must succeed outright — no tolerance for any + * failure mode (spawn failure, non-zero exit) — the shared shape behind every + * "docker verb target" primitive that fails hard on any problem + * (`legacyRemoveContainer`/`legacyRemoveVolume`/`legacyRestartContainer`; see + * `containers/container-lifecycle.ts` and `db-bootstrap/restart-services.ts`). + * `verb` is the human-readable action embedded in the error message (e.g. + * `"remove container"` → `"failed to remove container: "`). + */ +export function runContainerCliExpectSuccess( + spawner: Spawner, + args: ReadonlyArray, + verb: string, + makeError: (message: string) => E, +): Effect.Effect { + return Effect.scoped( + Effect.gen(function* () { + const child = yield* spawnContainerCli(spawner, args, { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }).pipe( + Effect.mapError((cause) => + makeError(`failed to ${verb}: ${legacyDescribeContainerCliFailure(cause)}`), + ), + ); + const [exitCode, stderr] = yield* Effect.all( + [child.exitCode.pipe(Effect.map(Number)), collectText(child.stderr)], + { concurrency: "unbounded" }, + ).pipe(Effect.mapError(() => makeError(`failed to ${verb}`))); + if (exitCode !== 0) { + const message = stderr.trim(); + return yield* Effect.fail( + makeError(message.length > 0 ? `failed to ${verb}: ${message}` : `failed to ${verb}`), + ); + } + }), + ); +} + /** * Mirrors Go's `versions.GreaterThanOrEqualTo` (`docker/api/types/versions`, * used by `apps/cli-go/internal/utils/docker.go:128`): splits each version on @@ -180,7 +239,7 @@ export const legacyDockerSupportsVolumePruneAllFlag = (spawner: Spawner) => }), ); const [exitCode, stdout] = yield* Effect.all( - [child.exitCode.pipe(Effect.map(Number)), collectDockerCliText(child.stdout)], + [child.exitCode.pipe(Effect.map(Number)), collectText(child.stdout)], { concurrency: "unbounded" }, ); if (exitCode !== 0) return false; diff --git a/apps/cli/src/legacy/shared/legacy-docker-bind-classify.ts b/apps/cli/src/legacy/shared/legacy-docker-bind-classify.ts index 7c1e5f0bc2..0bdd2a50f3 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-bind-classify.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-bind-classify.ts @@ -8,7 +8,7 @@ * * Hoisted here so every `docker run`/`docker create` argv builder that needs * this classification — `legacy-docker-run.args.ts` (`docker run`) and - * `legacy/shared/db-bootstrap/docker-create-args.ts` (`docker create`) — shares one + * `legacy/shared/containers/docker-create-args.ts` (`docker create`) — shares one * implementation instead of duplicating the regex. */ export function legacyIsBindMountSource(source: string): boolean { diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.ts index 5334a5ee30..e7a06c2460 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.ts @@ -83,7 +83,7 @@ export const LEGACY_CLI_PROJECT_LABEL = "com.supabase.cli.project"; * TS-port-only Docker label (no Go equivalent — Go never stages secrets on host disk in * the first place, see `legacy-start-secrets-cleanup.ts`'s doc comment) recording the * absolute `LegacyCliConfig.workdir` a container was created under, set on every - * container `start` creates (`container-lifecycle.ts`'s `legacyStartContainer`). + * container `start` creates (`container-lifecycle.ts`'s `legacyCreateContainer`). * * Read back by `legacyListContainerIdsAndNames` (`legacy-docker-lifecycle.ts`) so a later * `stop`/`legacyRollbackStart` can reclaim `legacyCleanupStartSecrets`'s staged-secret diff --git a/apps/cli/src/legacy/shared/legacy-kong-auth.ts b/apps/cli/src/legacy/shared/legacy-kong-auth.ts index 4b92aa18cb..64e5120aa2 100644 --- a/apps/cli/src/legacy/shared/legacy-kong-auth.ts +++ b/apps/cli/src/legacy/shared/legacy-kong-auth.ts @@ -8,7 +8,7 @@ * Hoisted here because it is needed by every local Kong-gateway caller across * command families: `legacy-storage-gateway.ts` (Storage, `seed buckets` / * `storage ls/cp/mv/rm`) and `start`'s PostgREST HTTP-HEAD readiness probe - * (`legacy/shared/db-bootstrap/health-check.ts`). + * (`legacy/shared/containers/health-check.ts`). */ export function legacyKongAuthHeaders(apiKey: string): Readonly> { const isOpaqueServiceKey = apiKey.startsWith("sb_"); diff --git a/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts b/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts index f0511423d5..ae18567ede 100644 --- a/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts +++ b/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts @@ -7,7 +7,7 @@ import type { LegacyContainerIdName } from "./legacy-docker-lifecycle.ts"; /** * Best-effort removal of `legacyStageStartSecretFiles`'s - * (`legacy/shared/db-bootstrap/container-lifecycle.ts`) per-container + * (`legacy/shared/containers/container-lifecycle.ts`) per-container * staged-secret directories for every container in `containers` — plaintext * JWT/TLS/pgsodium/pooler secret material `start` stages on host disk (Kong, * Postgres, Supavisor) that otherwise survives indefinitely, since neither diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index 55bb3ae6cf..3765698a36 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -73,13 +73,19 @@ const globalFlagsWithValues = new Set([ // legacyRollbackStart(...))` wrapper `supabase start` uses, which only ever fires when this // process's own fiber is interrupted (by `Fiber.interrupt` below, or by an ordinary typed // failure) — a raw, unhandled OS signal skips it entirely, exactly like the `start` case above. -const selfManagedSignalCommands: ReadonlyArray> = [ - // `db reset` (local path) drives the bootstrap seam, which holds SIGINT/SIGTERM/SIGHUP with - // no-op listeners while the Go child recreates the container; the global handler would - // otherwise race that and cut off the child's Docker cleanup / status propagation. - ["db", "reset"], - ["functions", "serve"], -]; +// +// `["db", "reset"]` was ALSO listed here once, for the same reason `db start` used to be: +// its local path drove the hidden `db __db-bootstrap --mode recreate`/`--mode await-storage` +// seam via a bespoke DIRECT `ChildProcess.make` spawn (not through `LegacyGoProxy`), which +// held SIGINT/SIGTERM/SIGHUP itself while the Go child recreated the container — the global +// handler's own `Fiber.interrupt` would otherwise race that child's Docker cleanup and lose +// its real exit status. CLI-1955 removed that seam entirely: `db reset --local` is now fully +// native TS (`legacy/shared/db-bootstrap/recreate-local-database.ts`), installing no signal +// handling of its own. Its only remaining Go child is the niche `--experimental` remote +// delegate, via the SAME `LegacyGoProxy.exec`/`execCapture` every other unlisted legacy +// command already uses safely alongside this global handler — so `db reset` was removed from +// this list too, matching `db start`'s own precedent exactly. +const selfManagedSignalCommands: ReadonlyArray> = [["functions", "serve"]]; /** Positional command-path tokens from argv, skipping global flags and their values. */ export function extractCommandPath(args: ReadonlyArray): ReadonlyArray { diff --git a/apps/cli/src/shared/cli/run.unit.test.ts b/apps/cli/src/shared/cli/run.unit.test.ts index 0189a5f5c4..28fac7c014 100644 --- a/apps/cli/src/shared/cli/run.unit.test.ts +++ b/apps/cli/src/shared/cli/run.unit.test.ts @@ -47,20 +47,21 @@ describe("extractCommandPath", () => { describe("shouldUseGlobalSignalInterrupt", () => { it("opts out for self-managed signal commands, even behind global flags", () => { expect(shouldUseGlobalSignalInterrupt(["functions", "serve"])).toBe(false); - // `db reset` drives the bootstrap seam (holds signals for the Go child), so it must not - // be wrapped in the global handler either. - expect(shouldUseGlobalSignalInterrupt(["db", "reset"])).toBe(false); expect( shouldUseGlobalSignalInterrupt(["--workdir", "/tmp/app", "functions", "serve", "--debug"]), ).toBe(false); }); - it("opts in for ordinary commands, including native start/db start (each installs no signal handling of its own, so the global wrapper's rollback-on-interrupt is the only thing that runs legacyRollbackStart on Ctrl-C)", () => { + it("opts in for ordinary commands, including native start/db start/db reset (each installs no signal handling of its own, so the global wrapper's rollback-on-interrupt/finalizers are the only thing that runs on Ctrl-C)", () => { expect(shouldUseGlobalSignalInterrupt(["functions", "list"])).toBe(true); expect(shouldUseGlobalSignalInterrupt(["db", "push"])).toBe(true); expect(shouldUseGlobalSignalInterrupt(["projects", "list"])).toBe(true); expect(shouldUseGlobalSignalInterrupt(["start"])).toBe(true); expect(shouldUseGlobalSignalInterrupt(["db", "start"])).toBe(true); + // `db reset` (CLI-1955): the hidden `db __db-bootstrap` seam this used to drive is + // gone — the local path is fully native TS, installing no signal handling of its + // own, so it participates in the global handler like `db start` (CLI-1954) before it. + expect(shouldUseGlobalSignalInterrupt(["db", "reset"])).toBe(true); expect(shouldUseGlobalSignalInterrupt([])).toBe(true); }); diff --git a/apps/cli/src/shared/legacy/legacy-go-child-exit.error.ts b/apps/cli/src/shared/legacy/legacy-go-child-exit.error.ts index d2b7f22ef3..c0cd93673e 100644 --- a/apps/cli/src/shared/legacy/legacy-go-child-exit.error.ts +++ b/apps/cli/src/shared/legacy/legacy-go-child-exit.error.ts @@ -1,8 +1,8 @@ import { Data, Runtime } from "effect"; /** - * A spawned `supabase-go` child process — via `LegacyGoProxy.exec`/`execCapture`, - * or the hidden `db __db-bootstrap` seam (`legacy-db-bootstrap.seam.layer.ts`) — + * A spawned `supabase-go` child process — via `LegacyGoProxy.exec`/`execCapture`, or + * (historically, before CLI-1955 removed it) the hidden `db __db-bootstrap` seam — * exited non-zero, or could not be spawned at all (binary not found). * * Carries the child's exact exit code through Effect's `Runtime.errorExitCode` From 7cd904d2cd623e90f7572c543a51379657d0a038 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 03:26:23 +0100 Subject: [PATCH 2/3] fix(cli): treat Podman's lowercase not-found errors as absent containers (review: PRRT_kwDOErm0O86VkikD) legacyIsContainerNotFoundMessage matched Docker's "No such container"/"No such object" case-sensitively, missing Podman's lowercase variants and its "no container with name or ID" wording that start.handler.ts's own Podman-aware parser already tolerates. db reset --local's new satellite restart/Kong reload tolerance (restart-services.ts) relied on this predicate, so a database-only db start or excluded storage/auth/realtime/pooler/Kong services would report a hard restart/reload failure on Podman instead of tolerating the absent container, unlike the Go implementation's errdefs.IsNotFound (which is text/case agnostic). --- .../src/legacy/shared/legacy-container-cli.ts | 25 ++++++++++++------ .../shared/legacy-container-cli.unit.test.ts | 26 +++++++++++++++++++ 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.ts b/apps/cli/src/legacy/shared/legacy-container-cli.ts index 9b57ae9910..a3abad73d8 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.ts @@ -138,16 +138,25 @@ export function collectText(stream: Stream.Stream) { } /** - * Docker's/Podman's "container doesn't exist" stderr shapes — "No such container" - * or "No such object" depending on daemon version/CLI path — Go's - * `errdefs.IsNotFound(err)` equivalent for a CLI-shelled-out (rather than - * Engine-API) caller. Hoisted here so callers across the container-lifecycle/ - * restart/health-check domain (`legacyIsLocalDbRunning`, - * `legacyRestartSatelliteService`, `legacyReloadKong`) share one predicate - * instead of re-deriving the same substring match. + * Docker's/Podman's "container doesn't exist" stderr shapes — "No such container"/ + * "No such object" (Docker, either casing depending on daemon version/CLI path) or + * "no container with name or ID" (Podman's own wording) — Go's `errdefs.IsNotFound(err)` + * equivalent for a CLI-shelled-out (rather than Engine-API) caller. Case-insensitive + * and covers all three shapes, matching the pre-existing Podman-aware parser in + * `commands/start/start.handler.ts`'s own `isContainerNotFoundMessage` — a lowercase + * Podman message must be tolerated exactly like an uppercase Docker one, or a reset + * excluding a satellite service (storage/auth/realtime/pooler) or Kong would report a + * hard restart/reload failure instead of tolerating the absent container. Hoisted here + * so callers across the container-lifecycle/restart/health-check domain + * (`legacyIsLocalDbRunning`, `legacyRestartSatelliteService`, `legacyReloadKong`) share + * one predicate instead of re-deriving the same match. */ export function legacyIsContainerNotFoundMessage(message: string): boolean { - return message.includes("No such container") || message.includes("No such object"); + return ( + /no such container/iu.test(message) || + /no such object/iu.test(message) || + /no container with name or id/iu.test(message) + ); } /** diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts b/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts index 331b482212..cd8e1715a7 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts @@ -7,6 +7,7 @@ import { legacyContainerRuntimeNotFoundMessage, legacyDescribeContainerCliFailure, legacyDockerSupportsVolumePruneAllFlag, + legacyIsContainerNotFoundMessage, spawnContainerCli, } from "./legacy-container-cli.ts"; @@ -221,3 +222,28 @@ describe("legacyDescribeContainerCliFailure", () => { expect(legacyDescribeContainerCliFailure(42)).toBe("42"); }); }); + +describe("legacyIsContainerNotFoundMessage", () => { + it("recognizes Docker's uppercase shapes", () => { + expect(legacyIsContainerNotFoundMessage("Error: No such container: db")).toBe(true); + expect(legacyIsContainerNotFoundMessage("Error: No such object: db")).toBe(true); + }); + + it("recognizes Podman's lowercase shapes, case-insensitively", () => { + expect(legacyIsContainerNotFoundMessage("error: no such container db")).toBe(true); + expect(legacyIsContainerNotFoundMessage("Error: no such object: db")).toBe(true); + }); + + it("recognizes Podman's 'no container with name or ID' shape", () => { + expect( + legacyIsContainerNotFoundMessage( + 'no container with name or ID "db" found: no such container', + ), + ).toBe(true); + expect(legacyIsContainerNotFoundMessage("No container with name or ID db found")).toBe(true); + }); + + it("rejects unrelated failures", () => { + expect(legacyIsContainerNotFoundMessage("Cannot connect to the Docker daemon")).toBe(false); + }); +}); From f74d5cd987b45696f0d6c8121ad824acba19f8a7 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 3 Aug 2026 15:10:05 +0100 Subject: [PATCH 3/3] fix(cli): thread --debug through db start's rollback after develop merge origin/develop (#6037) added a debug parameter to legacyRollbackStart and renamed legacyEnsureStartVolume/LegacyStartVolumeCreateError to legacyEnsureVolume/LegacyVolumeCreateError independently of this branch's own container-lifecycle.ts consolidation. Update db start's call site and its stale test names/references to match post-merge. --- apps/cli/src/legacy/commands/db/start/start.handler.ts | 8 ++++++-- .../legacy/commands/db/start/start.integration.test.ts | 2 ++ .../legacy/shared/containers/container-lifecycle.ts | 2 +- .../shared/containers/container-lifecycle.unit.test.ts | 10 +++++----- 4 files changed, 14 insertions(+), 8 deletions(-) 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 a33d10c1e1..3df02f0bfd 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -3,7 +3,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { Output } from "../../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; -import { LegacyNetworkIdFlag } from "../../../../shared/legacy/global-flags.ts"; +import { LegacyDebugFlag, LegacyNetworkIdFlag } from "../../../../shared/legacy/global-flags.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { legacyCheckDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; @@ -50,6 +50,10 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega const runtimeInfo = yield* RuntimeInfo; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const networkIdFlag = yield* LegacyNetworkIdFlag; + // Threaded into `legacyRollbackStart`'s own `legacyDockerRemoveAll` teardown — Go's + // `--debug` gates that function's `Pruned …:` stderr reports (`docker.go:123-143`, + // `viper.GetBool("DEBUG")`), matching `supabase start`'s own handler. + const debug = yield* LegacyDebugFlag; const body = Effect.gen(function* () { // Go's `flags.LoadConfig(fsys)` runs first thing in `start.Run` @@ -167,7 +171,7 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega }, }).pipe( Effect.onError(() => - legacyRollbackStart(spawner, filterValue, isFreshVolume, cliConfig.workdir), + legacyRollbackStart(spawner, filterValue, isFreshVolume, cliConfig.workdir, debug), ), ); 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 index f1e13fb53c..1f94211f30 100644 --- a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts @@ -20,6 +20,7 @@ import { } from "../../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { + LegacyDebugFlag, LegacyExperimentalFlag, LegacyNetworkIdFlag, } from "../../../../shared/legacy/global-flags.ts"; @@ -301,6 +302,7 @@ function setup(opts: SetupOpts = {}) { opts.networkId === undefined ? Option.none() : Option.some(opts.networkId), ), Layer.succeed(CliArgs, { args: ["db", "start"] }), + Layer.succeed(LegacyDebugFlag, false), Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? false), ); return { layer, out, telemetry, child, dbSession }; diff --git a/apps/cli/src/legacy/shared/containers/container-lifecycle.ts b/apps/cli/src/legacy/shared/containers/container-lifecycle.ts index c9aef450ad..d69e00924e 100644 --- a/apps/cli/src/legacy/shared/containers/container-lifecycle.ts +++ b/apps/cli/src/legacy/shared/containers/container-lifecycle.ts @@ -280,7 +280,7 @@ function legacyIsVolumeAlreadyExistsError(stderr: string): boolean { /** * Go's per-source-name `Docker.VolumeCreate` call (`docker.go:407-415`) via * `docker volume create --label ...`, treating "already exists" as success the - * same way {@link legacyEnsureStartNetwork} does; any other non-zero exit is a + * same way {@link legacyEnsureNetwork} does; any other non-zero exit is a * real failure. * * Go's Engine API is idempotent for a repeated name, including against Podman's diff --git a/apps/cli/src/legacy/shared/containers/container-lifecycle.unit.test.ts b/apps/cli/src/legacy/shared/containers/container-lifecycle.unit.test.ts index ad7f3c0dd0..dd9a9dec52 100644 --- a/apps/cli/src/legacy/shared/containers/container-lifecycle.unit.test.ts +++ b/apps/cli/src/legacy/shared/containers/container-lifecycle.unit.test.ts @@ -772,7 +772,7 @@ describe("legacyEnsureVolume", () => { exitCode: 125, stderr: "Error: volume with name supabase_db_proj already exists: volume already exists\n", })); - return legacyEnsureStartVolume(mock.spawner, "supabase_db_proj", {}).pipe( + return legacyEnsureVolume(mock.spawner, "supabase_db_proj", {}).pipe( Effect.map(() => { // Just needs to not fail — no return value to assert on. }), @@ -784,19 +784,19 @@ describe("legacyEnsureVolume", () => { exitCode: 125, stderr: "volume with name supabase_db_proj already exists\n", })); - return legacyEnsureStartVolume(mock.spawner, "supabase_db_proj", {}).pipe( + return legacyEnsureVolume(mock.spawner, "supabase_db_proj", {}).pipe( Effect.map(() => { // Just needs to not fail — no return value to assert on. }), ); }); - it.live("fails with LegacyStartVolumeCreateError on any other failure", () => { + it.live("fails with LegacyVolumeCreateError on any other failure", () => { const mock = mockSpawner(() => ({ exitCode: 1, stderr: "permission denied\n" })); - return legacyEnsureStartVolume(mock.spawner, "supabase_db_proj", {}).pipe( + return legacyEnsureVolume(mock.spawner, "supabase_db_proj", {}).pipe( Effect.flip, Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyStartVolumeCreateError); + expect(error).toBeInstanceOf(LegacyVolumeCreateError); expect(error.message).toBe("failed to create volume: permission denied"); }), );