From 4bf6abbea380f85f6ade9efa225f3b7e318f3488 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 31 Jul 2026 20:26:08 +0100 Subject: [PATCH 01/48] fix(cli): port db start container bootstrap to native TS (CLI-1954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `supabase db start` delegated its container-bootstrap step to the bundled Go binary via a hidden `db __db-bootstrap --mode start` seam. Ports this to native TS, including the `--from-backup` restore path (a distinct entrypoint variant, backup bind mount, health-check swallow, and full setup skip) that had zero Go test coverage to check against — verified empirically by executing the real Go binary and diffing its container-create payload byte-for-byte against the TS output. Rather than duplicating `supabase start`'s existing container-bootstrap sequence a second time, extracts a shared `legacyStartDatabase` (mirroring Go's own single `StartDatabase` function, which both `db start` and `supabase start` call) into `legacy/shared/db-bootstrap/` — along with the rest of the container-lifecycle/health-check/db-setup/postgres-spec machinery that command family already had, hoisted per this repo's "Hoist Before You Duplicate" rule now that a second command family needs it. Also: hoists the already-native `isDbRunning` probe out of the Go-proxy-named seam (zero Go involvement, a plain `docker container inspect`) so `db start` composes no Go delegation at all anymore, and removes the now-unreachable `case "start"` dispatch arm from the Go-side hidden seam (the real, customer-facing `db start` Go command and `StartDatabase` itself are untouched and remain the parity oracle). Fixes CLI-1954 --- .../live/db-reset-start.live.e2e.test.ts | 13 +- apps/cli-go/cmd/db.go | 49 +- apps/cli/docs/go-cli-porting-status.md | 90 +-- .../legacy/commands/db/reset/reset.handler.ts | 14 +- .../db/reset/reset.integration.test.ts | 73 +- .../db/shared/legacy-db-bootstrap.errors.ts | 14 +- .../shared/legacy-db-bootstrap.seam.layer.ts | 88 +-- .../legacy-db-bootstrap.seam.service.ts | 40 +- .../legacy/commands/db/start/SIDE_EFFECTS.md | 170 +++- .../legacy/commands/db/start/start.handler.ts | 256 +++++- .../db/start/start.integration.test.ts | 747 ++++++++++++++---- .../legacy/commands/db/start/start.layers.ts | 36 +- .../start/services/edge-runtime.service.ts | 10 +- .../commands/start/services/gotrue.service.ts | 4 +- .../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 | 8 +- .../start/services/realtime.service.ts | 81 +- .../services/realtime.service.unit.test.ts | 52 -- .../start/services/storage.service.ts | 8 +- .../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.format.ts | 22 - .../commands/start/start.format.unit.test.ts | 16 - .../src/legacy/commands/start/start.gates.ts | 28 +- .../legacy/commands/start/start.handler.ts | 604 ++++---------- .../commands/start/start.integration.test.ts | 2 +- .../src/legacy/commands/stop/SIDE_EFFECTS.md | 2 +- .../src/legacy/commands/stop/stop.handler.ts | 2 +- .../shared/db-bootstrap/bootstrap-config.ts | 294 +++++++ .../db-bootstrap}/container-lifecycle.ts | 14 +- .../container-lifecycle.unit.test.ts | 0 .../lib => shared/db-bootstrap}/db-setup.ts | 49 +- .../db-bootstrap}/db-setup.unit.test.ts | 11 +- .../db-bootstrap}/docker-create-args.ts | 2 +- .../docker-create-args.unit.test.ts | 0 .../db-bootstrap}/health-check.ts | 6 +- .../db-bootstrap}/health-check.unit.test.ts | 0 .../db-bootstrap}/image-prepull.ts | 4 +- .../db-bootstrap}/image-prepull.unit.test.ts | 0 .../db-bootstrap}/internal-db-connection.ts | 10 +- .../internal-db-connection.unit.test.ts | 0 .../shared/db-bootstrap/local-db-running.ts | 127 +++ .../legacy/shared/db-bootstrap/messages.ts | 32 + .../shared/db-bootstrap/messages.unit.test.ts | 20 + .../shared/db-bootstrap/pinned-image.ts | 30 + .../db-bootstrap}/postgres.service.ts | 113 ++- .../postgres.service.unit.test.ts | 72 +- .../shared/db-bootstrap/realtime-env.ts | 89 +++ .../db-bootstrap/realtime-env.unit.test.ts | 54 ++ .../db-bootstrap/rollback.ts} | 10 +- .../db-bootstrap/rollback.unit.test.ts} | 4 +- .../shared/db-bootstrap/start-database.ts | 351 ++++++++ .../db-bootstrap}/templates/db-globals.sql.ts | 0 .../templates/db-initial-schema-13.sql.ts | 0 .../templates/db-initial-schema-14.sql.ts | 0 .../db-bootstrap/templates/db-restore.sh.ts | 57 ++ .../db-bootstrap}/templates/db-schema.sql.ts | 0 .../templates/db-supabase.sql.ts | 0 .../db-bootstrap}/templates/db-webhook.sql.ts | 0 .../shared/legacy-bitbucket-pipeline.ts | 2 +- .../shared/legacy-docker-bind-classify.ts | 2 +- .../legacy/shared/legacy-docker-remove-all.ts | 4 +- .../src/legacy/shared/legacy-go-duration.ts | 22 + .../cli/src/legacy/shared/legacy-kong-auth.ts | 2 +- .../shared/legacy-local-config-values.ts | 23 + .../shared/legacy-start-secrets-cleanup.ts | 4 +- .../shared/cli/code-structure.unit.test.ts | 16 + 72 files changed, 2657 insertions(+), 1212 deletions(-) create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/bootstrap-config.ts rename apps/cli/src/legacy/{commands/start/lib => shared/db-bootstrap}/container-lifecycle.ts (98%) rename apps/cli/src/legacy/{commands/start/lib => shared/db-bootstrap}/container-lifecycle.unit.test.ts (100%) rename apps/cli/src/legacy/{commands/start/lib => shared/db-bootstrap}/db-setup.ts (94%) rename apps/cli/src/legacy/{commands/start/lib => shared/db-bootstrap}/db-setup.unit.test.ts (98%) rename apps/cli/src/legacy/{commands/start/lib => shared/db-bootstrap}/docker-create-args.ts (99%) rename apps/cli/src/legacy/{commands/start/lib => shared/db-bootstrap}/docker-create-args.unit.test.ts (100%) rename apps/cli/src/legacy/{commands/start/lib => shared/db-bootstrap}/health-check.ts (98%) rename apps/cli/src/legacy/{commands/start/lib => shared/db-bootstrap}/health-check.unit.test.ts (100%) rename apps/cli/src/legacy/{commands/start/lib => shared/db-bootstrap}/image-prepull.ts (96%) rename apps/cli/src/legacy/{commands/start/lib => shared/db-bootstrap}/image-prepull.unit.test.ts (100%) rename apps/cli/src/legacy/{commands/start/lib => shared/db-bootstrap}/internal-db-connection.ts (87%) rename apps/cli/src/legacy/{commands/start/lib => shared/db-bootstrap}/internal-db-connection.unit.test.ts (100%) create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/local-db-running.ts create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/messages.ts create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/messages.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/pinned-image.ts rename apps/cli/src/legacy/{commands/start/services => shared/db-bootstrap}/postgres.service.ts (72%) rename apps/cli/src/legacy/{commands/start/services => shared/db-bootstrap}/postgres.service.unit.test.ts (79%) create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/realtime-env.ts create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/realtime-env.unit.test.ts rename apps/cli/src/legacy/{commands/start/start.rollback.ts => shared/db-bootstrap/rollback.ts} (89%) rename apps/cli/src/legacy/{commands/start/start.rollback.unit.test.ts => shared/db-bootstrap/rollback.unit.test.ts} (98%) create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/start-database.ts rename apps/cli/src/legacy/{commands/start => shared/db-bootstrap}/templates/db-globals.sql.ts (100%) rename apps/cli/src/legacy/{commands/start => shared/db-bootstrap}/templates/db-initial-schema-13.sql.ts (100%) rename apps/cli/src/legacy/{commands/start => shared/db-bootstrap}/templates/db-initial-schema-14.sql.ts (100%) create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/templates/db-restore.sh.ts rename apps/cli/src/legacy/{commands/start => shared/db-bootstrap}/templates/db-schema.sql.ts (100%) rename apps/cli/src/legacy/{commands/start => shared/db-bootstrap}/templates/db-supabase.sql.ts (100%) rename apps/cli/src/legacy/{commands/start => shared/db-bootstrap}/templates/db-webhook.sql.ts (100%) 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 9c2ec6f8e7..278b984341 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 @@ -13,9 +13,14 @@ import { testLive } from "./live-context.ts"; // destructive remote reset below is safe against the throwaway per-run project. // --- Local leg: db start + db reset --local against the real Docker socket ----- -// Exercises the hidden `db __db-bootstrap` Go seam end-to-end — the boundary the -// in-process integration suites mock. The start → already-running → reset cycle -// runs in one test so it shares a single booted stack, and `finally` stops it +// 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. describe.skipIf(TARGET === "ts-next")("db start / db reset --local (live, local Docker)", () => { testLive( @@ -25,7 +30,7 @@ describe.skipIf(TARGET === "ts-next")("db start / db reset --local (live, local try { const start = await run(["db", "start"]); expect(start.exitCode, start.stderr).toBe(0); - // Go tees bootstrap progress to stderr (mode-independent). + // Bootstrap progress goes to stderr on every target (Go, and native TS since CLI-1954). expect(`${start.stdout}${start.stderr}`).toMatch(/Starting database|Initialising schema/i); // Second start is a no-op: the db is already running, exit 0. diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index 66df4311cc..f4c80517b1 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -1,7 +1,6 @@ package cmd import ( - "context" "errors" "fmt" "os" @@ -271,24 +270,29 @@ var ( }, } - bootstrapMode string - bootstrapSqlPaths []string - bootstrapFromBackup string - bootstrapVersion string - bootstrapNoSeed bool - - // dbBootstrapCmd is a hidden seam used by the native-TypeScript `db start` and - // `db reset --local` commands to drive the container-bootstrap primitives that - // are not yet ported to TypeScript: creating/recreating the local Postgres - // container, applying the initial schema, and the storage health gate. The TS - // caller orchestrates everything else (the "already running?" check and its - // message, version/last resolution, bucket seeding, the git-branch "Finished…" - // line, telemetry, and --output-format shaping); the seam stays in Go only for - // the Docker lifecycle. It mirrors the existing db __shadow seam: it carries no + 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"). + // 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, @@ -299,16 +303,6 @@ var ( return err } switch bootstrapMode { - case "start": - // Mirror start.Run minus the "already running?" check, which the TS - // caller performs (and prints "Postgres database is already running."). - if err := start.StartDatabase(cmd.Context(), bootstrapFromBackup, fsys, os.Stderr); err != nil { - if rmErr := utils.DockerRemoveAll(context.Background(), os.Stderr, utils.Config.ProjectId); rmErr != nil { - fmt.Fprintln(os.Stderr, rmErr) - } - return err - } - return nil case "recreate": // The PG14/PG15 container-recreate half of local db reset. The TS // caller has already printed "Resetting local database…" and validated @@ -688,8 +682,7 @@ func init() { dbCmd.AddCommand(dbShadowCmd) // Build hidden container-bootstrap seam command (native db start / db reset) bootstrapFlags := dbBootstrapCmd.Flags() - bootstrapFlags.StringVar(&bootstrapMode, "mode", "start", "Bootstrap mode: start, recreate, or await-storage.") - bootstrapFlags.StringVar(&bootstrapFromBackup, "from-backup", "", "Path to a logical backup file (start mode).") + bootstrapFlags.StringVar(&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).") diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 3eeb785c83..ea5b297adc 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` | Native TS port. Validates config, checks "already running" (prints Go's line), else delegates the container bootstrap (create + health + initial schema/roles/migrations/seed + `_current_branch`) to the hidden Go `db __db-bootstrap --mode start` seam. No status table / `cli_stack_started` (those are `supabase start`). `--from-backup` supported. | -| `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | -| `inspect db db-stats` | `ported` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db replication-slots` | `ported` | `legacy/commands/inspect/db/replication-slots/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db locks` | `ported` | `legacy/commands/inspect/db/locks/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db blocking` | `ported` | `legacy/commands/inspect/db/blocking/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db outliers` | `ported` | `legacy/commands/inspect/db/outliers/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db calls` | `ported` | `legacy/commands/inspect/db/calls/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db index-stats` | `ported` | `legacy/commands/inspect/db/index-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db long-running-queries` | `ported` | `legacy/commands/inspect/db/long-running-queries/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db bloat` | `ported` | `legacy/commands/inspect/db/bloat/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db role-stats` | `ported` | `legacy/commands/inspect/db/role-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db vacuum-stats` | `ported` | `legacy/commands/inspect/db/vacuum-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db table-stats` | `ported` | `legacy/commands/inspect/db/table-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db traffic-profile` | `ported` | `legacy/commands/inspect/db/traffic-profile/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db cache-hit` | `ported` | `legacy/commands/inspect/db/cache-hit/` | `n/a` | `n/a` | Native TS port. Deprecated (use db-stats); routes to the active query. | -| `inspect db index-usage` | `ported` | `legacy/commands/inspect/db/index-usage/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db total-index-size` | `ported` | `legacy/commands/inspect/db/total-index-size/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db index-sizes` | `ported` | `legacy/commands/inspect/db/index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db table-sizes` | `ported` | `legacy/commands/inspect/db/table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db table-index-sizes` | `ported` | `legacy/commands/inspect/db/table-index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db total-table-sizes` | `ported` | `legacy/commands/inspect/db/total-table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db unused-indexes` | `ported` | `legacy/commands/inspect/db/unused-indexes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db table-record-counts` | `ported` | `legacy/commands/inspect/db/table-record-counts/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db seq-scans` | `ported` | `legacy/commands/inspect/db/seq-scans/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db role-configs` | `ported` | `legacy/commands/inspect/db/role-configs/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | -| `inspect db role-connections` | `ported` | `legacy/commands/inspect/db/role-connections/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | -| `migration down` | `ported` | `legacy/commands/migration/down/` | `n/a` | `n/a` | Native TS port. Revert prompt → drop user schemas → vault upsert → migrate&seed to the target version; defaults to `--local`. Skips Go's pgcache catalog write. | -| `migration fetch` | `ported` | `legacy/commands/migration/fetch/` | `n/a` | `n/a` | Native TS port. Reads `schema_migrations` and writes `supabase/migrations/_.sql`; overwrite prompt for a non-empty dir. | -| `migration list` | `ported` | `legacy/commands/migration/list/` | `n/a` | `n/a` | Native TS port. Merges remote `schema_migrations` with local files into a Glamour ASCII table (Local / Remote / Time-UTC columns); defaults to `--linked`. | -| `migration new` | `ported` | `legacy/commands/migration/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/migrations/_.sql` (mode 0644) from piped stdin; no DB/API. | -| `migration repair` | `ported` | `legacy/commands/migration/repair/` | `n/a` | `n/a` | Native TS port. Transactional create-table + TRUNCATE/UPSERT/DELETE; applied mode reads local files; repair-all prompt; defaults to `--linked`. | -| `migration squash` | `missing` | `missing` | `n/a` | `n/a` | 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` | 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). | ## Code Generation 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 aae30666de..e493e2280f 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts @@ -1,4 +1,5 @@ import { Effect, FileSystem, Option, Path } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { detectGitBranch } from "../../../../shared/git/git-branch.ts"; @@ -31,6 +32,7 @@ import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-proje 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"; @@ -110,6 +112,7 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega const linkedProjectCache = yield* LegacyLinkedProjectCache; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const cliArgs = yield* CliArgs; const dnsResolver = yield* LegacyDnsResolverFlag; @@ -310,8 +313,15 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega // relaxed, or refactored to stop validating. yield* legacyCheckDbToml(fs, path, workdir); - // AssertSupabaseDbIsRunning — error if the local db container is down. - const running = yield* seam.isDbRunning(); + // AssertSupabaseDbIsRunning — error if the local db container is down. Native TS, + // hoisted out of the seam by CLI-1954 (see `legacyIsLocalDbRunning`'s own header). + const running = yield* legacyIsLocalDbRunning( + spawner, + fs, + path, + workdir, + Option.getOrUndefined(cliConfig.projectId), + ); if (!running) { return yield* Effect.fail( new LegacyDbResetNotRunningError({ 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 3f710a511a..79b1bc935d 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 @@ -3,7 +3,8 @@ import { dirname, join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option, PlatformError, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -159,18 +160,16 @@ function mockConnection(opts: { remoteSeeds?: Readonly> } } /** - * Stateful mock of the container-bootstrap seam. `running` drives - * `AssertSupabaseDbIsRunning`; `storageReady` drives the bucket-seed gate. Records - * the recreate args so tests can assert version / `--no-seed` propagation. - * `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). + * 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`). */ -function mockBootstrapSeam(opts: { - running?: boolean; - storageReady?: boolean; - awaitStorageReadyExitCode?: number; -}) { +function mockBootstrapSeam(opts: { storageReady?: boolean; awaitStorageReadyExitCode?: number }) { const recreateCalls: Array<{ version: string; noSeed: boolean; @@ -178,8 +177,6 @@ function mockBootstrapSeam(opts: { }> = []; let storageChecked = false; const layer = Layer.succeed(LegacyDbBootstrapSeam, { - isDbRunning: () => Effect.succeed(opts.running ?? true), - startDatabase: () => Effect.void, recreateDatabase: (args: { version: string; noSeed: boolean; @@ -215,6 +212,51 @@ function mockBootstrapSeam(opts: { }; } +/** + * 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; + const encoder = new TextEncoder(); + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (command._tag !== "StandardCommand") { + return yield* Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }), + ); + } + const stderrLines = running ? [] : ["Error: No such container: supabase_db_test"]; + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(7000), + stdout: Stream.empty, + stderr: Stream.fromIterable(stderrLines.map((line) => encoder.encode(`${line}\n`))), + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(running ? 0 : 1)), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ), + ); + 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. @@ -296,10 +338,10 @@ function setup( const conn = mockConnection(opts); const proxy = mockProxy({ execCaptureExitCode: opts.execCaptureExitCode }); const seam = mockBootstrapSeam({ - running: opts.running, 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 @@ -320,6 +362,7 @@ function setup( resolver.layer, mockLegacyCliConfig({ workdir }), BunServices.layer, + runningCheck.layer, mockRuntimeInfo(), // The remote-reset confirmation is answered through mockOutput's // `promptConfirmResponses` (the TTY/clack path), so mark stdin a TTY. Stdin is 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 index 673e78a466..1eac829601 100644 --- 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 @@ -2,12 +2,14 @@ import { Data } from "effect"; /** * Driving the bundled Go binary's hidden `db __db-bootstrap` seam failed — the - * container-lifecycle primitives that back native `db start` / `db reset --local` - * (create/recreate the local Postgres container, apply the initial schema, the - * storage health gate) are not yet ported to TypeScript. Wraps a failed inspect, - * a missing `supabase-go` binary, or a non-zero seam exit. The seam tees its own - * progress to stderr, so this message is the fallback shown when the subprocess - * dies without surfacing a more specific Go error. + * 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; 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 index d6b1793166..3060be9e64 100644 --- 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 @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; +import { Effect, Layer, Option, Stream } from "effect"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; @@ -11,16 +11,6 @@ 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 { spawnContainerCli } from "../../../shared/legacy-container-cli.ts"; -import { legacyReadDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; -import { - legacyResolveLocalProjectId, - localDbContainerId, -} from "../../../shared/legacy-docker-ids.ts"; -import { - LEGACY_SUGGEST_DOCKER_INSTALL, - legacyIsDockerDaemonUnreachable, -} from "../../../shared/legacy-docker-suggest.ts"; import { LegacyDbBootstrapError } from "./legacy-db-bootstrap.errors.ts"; import { LegacyDbBootstrapSeam } from "./legacy-db-bootstrap.seam.service.ts"; @@ -60,8 +50,6 @@ export const legacyDbBootstrapSeamLayer = Layer.effect( const experimentalArgs = experimental ? ["--experimental"] : []; const spawner = yield* ChildProcessSpawner; const processControl = yield* ProcessControl; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const resolved = resolveBinary(); /** @@ -158,80 +146,6 @@ export const legacyDbBootstrapSeamLayer = Layer.effect( ); return LegacyDbBootstrapSeam.of({ - isDbRunning: () => - Effect.scoped( - Effect.gen(function* () { - // Resolve `utils.DbId` exactly as Go does (env → config.toml → workdir - // basename); the config.toml read is best-effort (`validate: false`) since - // the handler has already run Go's `LoadConfig` validation — an invalid - // config would have failed there, so here we only want the `projectId` and - // tolerate a fallback to the workdir basename rather than re-throwing. - const tomlProjectId = yield* legacyReadDbToml(fs, path, cliConfig.workdir, undefined, { - validate: false, - }).pipe( - Effect.map((toml) => toml.projectId), - // The lenient read still surfaces a genuinely unreadable/malformed project - // `.env`; fall back to the workdir basename in that case rather than failing - // the running-check (the handler has already validated config). - Effect.orElseSucceed(() => Option.none()), - ); - const projectId = legacyResolveLocalProjectId( - Option.getOrUndefined(cliConfig.projectId), - Option.getOrUndefined(tomlProjectId), - cliConfig.workdir, - ); - const containerId = localDbContainerId(projectId); - // Go's AssertSupabaseDbIsRunning = ContainerInspect → NotFound ⇒ not - // running. Discard stdout (the inspect JSON) so the unconsumed pipe can - // never deadlock; only the exit code + stderr matter. - const child = yield* spawnContainerCli(spawner, ["container", "inspect", containerId], { - stdin: "ignore", - stdout: "ignore", - stderr: "pipe", - extendEnv: true, - }).pipe(Effect.mapError(() => seamFailure("failed to inspect service"))); - const stderrChunks: Array = []; - yield* Stream.runForEach(child.stderr, (chunk) => - Effect.sync(() => { - stderrChunks.push(chunk); - }), - ).pipe(Effect.mapError(() => seamFailure("failed to inspect service"))); - const inspectExit = yield* child.exitCode.pipe( - Effect.map(Number), - Effect.mapError(() => seamFailure("failed to inspect service")), - ); - if (inspectExit === 0) return true; // container exists ⇒ running - - const stderr = decodeChunks(stderrChunks).trim(); - // Only a missing container means "not running". Docker reports this as - // either "No such container" or "No such object" depending on daemon - // version/CLI path (the same pair handled in `shared/functions/serve.ts`). - // Any other inspect failure (e.g. the Docker daemon is down) propagates, - // matching Go's `AssertSupabaseDbIsRunning`. - if (!stderr.includes("No such container") && !stderr.includes("No such object")) { - // 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. - return yield* Effect.fail( - new LegacyDbBootstrapError({ - message: - stderr.length > 0 - ? `failed to inspect service: ${stderr}` - : "failed to inspect service", - ...(legacyIsDockerDaemonUnreachable(stderr) - ? { suggestion: LEGACY_SUGGEST_DOCKER_INSTALL } - : {}), - }), - ); - } - return false; - }), - ), - startDatabase: ({ fromBackup }) => - runBootstrap( - ["--mode", "start", ...(fromBackup !== undefined ? ["--from-backup", fromBackup] : [])], - false, - ).pipe(Effect.asVoid), recreateDatabase: ({ version, noSeed, sqlPaths }) => runBootstrap( [ 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 index 7029a3c8bd..3f5a08dcee 100644 --- 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 @@ -5,12 +5,21 @@ import type { LegacyDbBootstrapError } from "./legacy-db-bootstrap.errors.ts"; /** * Seam over the bundled Go binary's hidden `db __db-bootstrap` command, exposing - * the container-bootstrap primitives that native `db start` / `db reset --local` - * still need but that are not ported to TypeScript: the local-stack "is running?" - * probe, the database container create/recreate flows, and the storage health gate - * before bucket seeding. The TS handlers orchestrate everything else (user-facing - * messages, version resolution, bucket seeding, the git-branch line, telemetry, - * and `--output-format` shaping); only the Docker lifecycle lives behind here. + * 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 @@ -18,25 +27,6 @@ import type { LegacyDbBootstrapError } from "./legacy-db-bootstrap.errors.ts"; * stderr. */ interface LegacyDbBootstrapSeamShape { - /** - * Go's `utils.AssertSupabaseDbIsRunning` (`internal/utils/misc.go:144`): inspect - * the local Postgres container. `true` when it exists (the stack is up), `false` - * when Docker reports "No such container" (Go's `ErrNotRunning`). Any other - * inspect failure (e.g. the Docker daemon is unreachable) fails with - * {@link LegacyDbBootstrapError}, matching Go, which returns the wrapped inspect - * error rather than treating the database as stopped. - */ - readonly isDbRunning: () => Effect.Effect; - /** - * `db start`'s container bootstrap — `start.StartDatabase(fromBackup)` plus Go's - * `DockerRemoveAll` cleanup on failure (`internal/db/start/start.go:54-60`): - * create the Postgres container, wait for health, apply the initial schema + - * roles + migrations + seed on a fresh volume, and write `_current_branch`. - * Progress (`Starting database...`, `Initialising schema...`) is teed to stderr. - */ - readonly startDatabase: (opts: { - readonly fromBackup?: string; - }) => Effect.Effect; /** * The PG14/PG15 container-recreate half of local `db reset` * (`reset.RecreateLocalDatabase`): recreate the db container/volume, init schema, 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 dacbedff73..00f9ec0cf6 100644 --- a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md @@ -1,35 +1,102 @@ # `supabase db start` -Native TS port of `apps/cli-go/internal/db/start/start.go` `Run`. The handler -validates config, checks whether the local Postgres container is already running, -and otherwise delegates the container bootstrap to the bundled Go binary's hidden -`db __db-bootstrap --mode start` seam (the container-lifecycle primitives are not -ported). This is `db start`, **not** the top-level `supabase start`: no status -table, no `cli_stack_started` event, no `Finished` line. +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`. + +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): + +1. Ensure the Docker network exists (`--network-id` override or `supabase_network_`). +2. Probe whether the Postgres data volume (`supabase_db_`) already exists — + BEFORE creating it, matching Go's `NoBackupVolume` check ordering. +3. **`--from-backup` + an existing volume**: fail with `backup volume already exists` + (suggestion: `supabase stop --no-backup`), roll back (see below), and exit — no + container is created on this path. +4. Print `Starting database...` (fresh volume) or `Starting database from backup...` + (existing volume — despite the wording, unrelated to `--from-backup`; see + `legacy/shared/db-bootstrap/messages.ts`). +5. Resolve the Postgres image (version-pin-aware) and create + start the container. + `--from-backup` set: a THIRD entrypoint variant (`legacyBuildPostgresStartContainerSpec`'s + `fromBackup` branch) — schema.sql + `_supabase.sql` (no `webhook.sql`), a ported + `migrate.sh` (`templates/db-restore.sh.ts`, transcribed from Go's `templates/restore.sh`) + that restores roles then schema from the bind-mounted backup file, and + `cron.launch_active_jobs = off` appended to `postgresql.conf` — applies regardless of + `db.major_version`. The backup file itself is bind-mounted `:ro` at `/etc/backup.sql` + (host path resolved against the CALLER's cwd when relative, matching Go's + `CurrentDirAbs`). +6. Wait for the container to become healthy (`db.health_timeout`, default `2m`). A timeout + fails the command UNLESS `--from-backup` is set, in which case it is swallowed (a large + restore can exceed the timeout) — the container-logs dump to stderr still happens + either way. +7. On a fresh volume with `--from-backup` unset: run the `SetupLocalDatabase`-equivalent + pipeline (`legacy/shared/db-bootstrap/db-setup.ts`) — initial schema (PG<=14: SQL over a + direct `LegacyDbConnection`; PG>=15: up to three one-shot `docker run --rm` migrate jobs + for realtime/storage/auth, each gated on its own `enabled` flag), API-privilege + revocation, `[db.vault]` secret upsert, `supabase/roles.sql` seed, and every pending + migration + seed. Skipped IN FULL when `--from-backup` is set (not merely reduced). +8. Write `supabase/.branches/_current_branch` = `"main"` if absent — runs on EVERY path + that reaches this point (fresh volume, existing volume, and a swallowed + `--from-backup` health-check timeout), but NOT on the already-running short-circuit or + the `backup volume already exists` guard. + +Any failure from step 1 onward rolls back via `legacyRollbackStart` (stop + prune every +container/network this project's label matches; volumes are pruned too, but ONLY when the +volume was confirmed fresh this run) — matching Go's `Run`, which calls `DockerRemoveAll` +on any `StartDatabase` failure. ## Files Read -| Path | Format | When | -| -------------------------------- | ------ | --------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always — parsed up front; a malformed config aborts before work | -| `` (from `--from-backup`) | binary | when `--from-backup` is set (read by the Go seam on start) | +| 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/.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 -| Path | Format | When | -| ---------------------------------------------- | ------ | --------------------------------------------------------------------------- | -| `/supabase/.branches/_current_branch` | text | by the Go seam (`initCurrentBranch`) when starting; writes `main` if absent | -| local Docker volume `supabase_db_` | — | by the Go seam — the Postgres data volume created on first start | -| `~/.supabase/telemetry.json` | JSON | always (telemetry flush, success and failure) | +| Path | Format | When | +| --------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.branches/_current_branch` | text | only if absent — writes `"main"` (see the step-by-step sequence above for exactly when) | +| `/supabase/.temp/start-secrets//secret-0` | binary | Postgres's pgsodium root key (mode `0644`, directory mode `0700`) — every entrypoint variant except the PG<=14 no-backup one carries this | +| local Docker volume `supabase_db_` | — | the Postgres data volume, created on first start (or first `--from-backup` restore) | +| local Docker network `supabase_network_` (or `--network-id`) | — | created if it doesn't already exist | +| `~/.supabase/telemetry.json` | JSON | always — telemetry flush (`Effect.ensuring(telemetryState.flush)`), success and failure | ## Subprocesses -| Command | When | Purpose | -| ---------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `docker container inspect supabase_db_` | always | `AssertSupabaseDbIsRunning` probe (Podman fallback) | -| `supabase-go db __db-bootstrap --mode start [--from-backup

]` | when the database is not running | create container + health check + initial schema/roles/migrations/seed + `_current_branch`; telemetry disabled (`SUPABASE_TELEMETRY_DISABLED=1`), progress teed to stderr | - -`--network-id` and a flag-selected `--profile` are forwarded to the seam. +Every step below shells out to `docker` (falling back to `podman`), matching every other +native container command in this codebase — never `supabase-go`. + +| Command | When | +| -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `docker container inspect supabase_db_` | always — the already-running probe | +| `docker network create --label ... ` | when not already running, unless `--network-id` names a built-in network | +| `docker volume inspect supabase_db_` | when not already running — the pre-create fresh-volume probe | +| `docker image inspect` / `docker pull` (registry-fallback resolve) | when not already running — resolves the Postgres image | +| `docker volume create --label ...` | when not already running, unless a `--from-backup` restore onto an existing volume (fails first) | +| `docker create` + `docker start` | when not already running | +| `docker container inspect` (repeated) | health-wait polling, 1s constant backoff up to `db.health_timeout` | +| `docker logs ` | on a health-check timeout (either path — swallowed or not) | +| `docker run --rm ...` | fresh volume, no `--from-backup`, `db.major_version >= 15`: up to 3 one-shot migrate jobs (realtime/storage/auth) | +| `docker ps` / `docker stop` / `docker container prune` / `docker volume prune` (fresh-volume runs only) / `docker network prune` | on ANY failure from network-ensure through `_current_branch` — the rollback | ## API Routes @@ -37,36 +104,51 @@ table, no `cli_stack_started` event, no `Finished` line. | ------ | ---- | ---- | ------------ | ---------------------- | | — | — | — | — | — | -(The Go seam may call Auth's JWKS endpoint while applying service migrations on a -fresh PG15 volume; that is internal to the seam, not the TS handler.) - ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------------- | ---------------------------------------------------- | ---------- | -| `SUPABASE_PROJECT_ID` | overrides the local container id (`utils.DbId`) | no | -| `SUPABASE_TELEMETRY_DISABLED` | set on the seam subprocess so it never double-counts | (internal) | +| 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 | + +`--network-id` (a global CLI flag, not an environment variable — `shared/legacy/global-flags.ts`) +forces every created container/network onto that Docker network instead of the generated +`supabase_network_`. ## Exit Codes -| Code | Condition | -| -------------------- | --------------------------------------------------------------------- | -| `0` | success — database started, or already running | -| `1` | malformed `supabase/config.toml` | -| `1` | Docker daemon unreachable / inspect failure | -| child's exact code\* | container bootstrap failed (the seam cleans up via `DockerRemoveAll`) | - -\* The `db __db-bootstrap` seam propagates the spawned `supabase-go` child's -real exit code (e.g. `130` after a Ctrl-C mid-bootstrap) instead of collapsing -every failure to `1` — in every `--output-format` (CLI-1879). +| Code | Condition | +| ---- | --------------------------------------------------------------------------- | +| `0` | success — database started, or already running | +| `0` | `--from-backup` set and the health-check timed out (swallowed) | +| `1` | malformed `supabase/config.toml` | +| `1` | Docker daemon unreachable / inspect failure | +| `1` | `backup volume already exists` (`--from-backup` against a non-fresh volume) | +| `1` | a health-check timeout with `--from-backup` unset | +| `1` | any other container-bootstrap failure (network/volume/create/start/setup) | ## Output ### `--output-format text` (Go CLI compatible) - Already running → `Postgres database is already running.` on **stderr**, exit 0. -- Starting → the Go seam tees `Starting database...` / `Initialising schema...` to - **stderr**. No stdout output, no `Finished` line. +- Starting → `Starting database...` / `Starting database from backup...`, then (fresh + volume, no `--from-backup`) `Initialising schema...` and `Seeding globals from +roles.sql...`, all on **stderr**. No stdout output, no `Finished` line. +- `backup volume already exists` → the message on **stderr**, followed by the + `supabase stop --no-backup` suggestion (aqua-colored). ### `--output-format json` @@ -79,7 +161,11 @@ Same result object as the terminal `result` event; progress on stderr. ## Notes -- `--from-backup` restores the database from a logical backup file on start; the - health check is skipped for backups (a large restore can exceed the timeout). +- `--from-backup` restores the database from a logical backup file on start; the health + check is skipped (not failed) for backups — a large restore can exceed + `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. 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 7428173f1a..338c97234c 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -1,47 +1,90 @@ import { Effect, FileSystem, Option, Path } from "effect"; +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 { 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 { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.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 { 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"; /** * `supabase db start` — start the local Postgres database. * - * Strict 1:1 port of `apps/cli-go/internal/db/start/start.go` `Run`. Native TS - * orchestrates: it validates config, checks whether the database is already - * running (printing Go's "already running" line), and otherwise delegates the - * container bootstrap to the hidden Go `__db-bootstrap` seam (create container + - * health + initial schema + `_current_branch`), whose progress is teed to stderr. + * Strict 1:1 port of `apps/cli-go/internal/db/start/start.go` `Run` + `StartDatabase`. + * `Run` is native TS here: config load+validate, the already-running short-circuit, and + * this command's own lean prelude. The `StartDatabase` sequence itself + * (network/volume/container bring-up, health wait, fresh-volume setup, `_current_branch`) + * is the SAME shared function `supabase start` uses — see + * `legacy/shared/db-bootstrap/start-database.ts`'s header for why this is a single, + * shared TS home rather than two independently-drifting copies. The already-running + * check (`legacyIsLocalDbRunning`) is already a native TS implementation of + * `AssertSupabaseDbIsRunning` (a plain `docker container inspect`), not a Go subprocess + * or a seam call — `db start` composes no `LegacyDbBootstrapSeam` at all anymore; the + * container-bootstrap Go delegation (`db __db-bootstrap --mode start`) has been + * removed entirely. * - * Parity notes: this is `db start`, NOT the top-level `supabase start`. It does - * NOT print a status table and does NOT fire `cli_stack_started` — those belong to - * `internal/start/start.go`. There is no `Finished` line. + * Parity notes: this is `db start`, NOT the top-level `supabase start`. It does NOT print + * a status table and does NOT fire `cli_stack_started` — those belong to + * `internal/start/start.go`. There is no `Finished` line. Unlike `supabase start`, there + * is no `--exclude`/`--ignore-health-check` here at all (Go's `db start` has neither + * flag) — a health-check timeout always fails the command UNLESS `--from-backup` is set, + * in which case `legacyStartDatabase` itself swallows it (a large restore can take longer + * than the health timeout, `start.go:179-181`) and the command still succeeds. + * `--exclude`'s absence also means the fresh-volume one-shot setup jobs (realtime/storage/ + * auth migrate) are gated purely on each service's own `enabled` flag, with no `--exclude` + * filtering to layer on top. */ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: LegacyDbStartFlags) { const output = yield* Output; const cliConfig = yield* LegacyCliConfig; - const seam = yield* LegacyDbBootstrapSeam; const telemetryState = yield* LegacyTelemetryState; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runtimeInfo = yield* RuntimeInfo; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const networkIdFlag = yield* LegacyNetworkIdFlag; const body = Effect.gen(function* () { // Go's `flags.LoadConfig(fsys)` runs first thing in `start.Run` // (`internal/db/start/start.go:45`): a missing config is tolerated (defaults), but // a present config that is malformed, references an undecryptable `encrypted:` // secret, or fails Validate aborts before any container work. `legacyCheckDbToml` - // is that exact load+validate — call it here (not via the seam's best-effort read, - // which swallows config errors) so `db start` fails fast on a broken config. + // is that exact load+validate — call it here (not via `legacyIsLocalDbRunning`'s + // best-effort read, which swallows config errors) so `db start` fails fast on a + // broken config. yield* legacyCheckDbToml(fs, path, cliConfig.workdir); // Go's AssertSupabaseDbIsRunning: if the db container is already up, print to - // stderr and return nil (exit 0). - const running = yield* seam.isDbRunning(); + // stderr and return nil (exit 0). Already native — see this module's header. + const running = yield* legacyIsLocalDbRunning( + spawner, + fs, + path, + cliConfig.workdir, + Option.getOrUndefined(cliConfig.projectId), + ); if (running) { if (output.format === "text") { yield* output.raw("Postgres database is already running.\n", "stderr"); @@ -53,16 +96,9 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega return; } - // Not running → bootstrap the container (StartDatabase + DockerRemoveAll on - // failure). The seam tees "Starting database...", "Initialising schema...", - // etc. to stderr. - // // Resolve a relative `--from-backup` against the CALLER's cwd, mirroring Go's // `StartDatabase` (`filepath.Join(utils.CurrentDirAbs, fromBackup)`, start.go:160-161) - // where `CurrentDirAbs` is captured before `ChangeWorkDir`. The seam spawns the Go child - // with cwd = the project workdir, so passing a relative path would resolve it against the - // project root (wrong file / not found) when `db start` runs from a subdirectory or with - // `--workdir`. Passing an absolute path makes the child's resolution a no-op. + // where `CurrentDirAbs` is captured before `ChangeWorkDir`. const fromBackupFlag = Option.getOrUndefined(flags.fromBackup); // An empty `--from-backup ""` is a normal no-backup start in Go (`len(fromBackup) == 0`), // so treat it as absent rather than joining it to a directory path. @@ -72,7 +108,181 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega : path.isAbsolute(fromBackupFlag) ? fromBackupFlag : path.join(runtimeInfo.cwd, fromBackupFlag); - yield* seam.startDatabase({ fromBackup }); + + // Not running → bring up the container natively. `db start`'s OWN lean prelude: + // config values (via `legacyResolveLocalConfigValues`, matching `stop`/`status`'s own + // 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( + cliConfig.workdir, + (message) => new LegacyDbConfigLoadError({ message }), + ); + const { config, projectEnvValues, loaded, hostname, projectId } = context; + + 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 dbContainerId = localDbContainerId(projectId); + const filterValue = legacyCliProjectFilterValue(projectId); + + // Go's `utils.NoBackupVolume` package var — assigned by `legacyStartDatabase`'s own + // pre-create volume-existence check; defaults to `false` (matching Go's zero value) so a + // rollback triggered by an earlier failure (e.g. network creation) never deletes a volume + // this run never confirmed was fresh. + let isFreshVolume = false; + + // Runs the exact Go `StartDatabase` sequence (network -> volume probe -> container + // create+start -> health wait -> fresh-volume setup -> `_current_branch`) — shared with + // `supabase start`, see `legacyStartDatabase`'s own header. Any failure rolls back via the + // SAME `Effect.onError` wrapper `supabase start` uses (not `tapError` — see + // `legacyRollbackStart`'s own doc comment for why `onError` is required), matching Go's + // `Run`, which calls `DockerRemoveAll` on ANY `StartDatabase` failure (`start.go:54-59`). + yield* legacyStartDatabase(spawner, { + fs, + path, + workdir: cliConfig.workdir, + projectId, + networkId, + 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, + }, + // 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, + ), + ), + dbHealthTimeoutSeconds: bootstrapConfig.dbHealthTimeoutSeconds, + setup: { + majorVersion: bootstrapConfig.majorVersion, + 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, + }, + onFreshVolumeResolved: (resolved) => { + isFreshVolume = resolved; + }, + }).pipe( + Effect.onError(() => + legacyRollbackStart(spawner, filterValue, isFreshVolume, cliConfig.workdir), + ), + ); if (output.format !== "text") { yield* output.success("Started local database.", { status: "started" }); 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 e53edc5f2b..2de9e54886 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 @@ -1,202 +1,590 @@ -import { mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option, PlatformError, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; -import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; +import { + mockOutput, + mockProcessControl, + mockRuntimeInfo, +} from "../../../../../tests/helpers/mocks.ts"; import { mockLegacyCliConfig, mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; -import { LegacyGoChildExitError } from "../../../../shared/legacy/legacy-go-child-exit.error.ts"; +import { LegacyNetworkIdFlag } from "../../../../shared/legacy/global-flags.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; -import { LegacyDbBootstrapError } from "../shared/legacy-db-bootstrap.errors.ts"; -import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.ts"; +import { + LegacyDbConnection, + type LegacyDbSession, +} from "../../../shared/legacy-db-connection.service.ts"; +import { legacyDockerRunLayer } from "../../../shared/legacy-docker-run.layer.ts"; import { legacyDbStart } from "./start.handler.ts"; import type { LegacyDbStartFlags } from "./start.command.ts"; const DEFAULT_FLAGS: LegacyDbStartFlags = { fromBackup: Option.none() }; -/** - * Stateful mock of the container-bootstrap seam. `running` drives - * `AssertSupabaseDbIsRunning`; `runningFails` / `startFails` make the respective - * call fail (Docker daemon down / StartDatabase error). `startExitCode`, when set, - * fails `startDatabase` with a `LegacyGoChildExitError` carrying that code instead — - * simulating the seam's real bootstrap child (`db __db-bootstrap --mode start`) - * exiting non-zero (CLI-1879). Records the args passed to `startDatabase`. - */ -function mockSeam( - opts: { - running?: boolean; - runningFails?: boolean; - startFails?: boolean; - startExitCode?: number; - } = {}, -) { - const startCalls: Array<{ fromBackup?: string }> = []; - const layer = Layer.succeed(LegacyDbBootstrapSeam, { - isDbRunning: () => - opts.runningFails === true - ? Effect.fail(new LegacyDbBootstrapError({ message: "failed to inspect service" })) - : Effect.succeed(opts.running ?? false), - startDatabase: (args: { fromBackup?: string }) => { - if (opts.startExitCode !== undefined) { - return Effect.fail( - new LegacyGoChildExitError({ - exitCode: opts.startExitCode, - message: `failed to bootstrap the local database: exit ${opts.startExitCode}`, - }), - ); - } - return opts.startFails === true - ? Effect.fail(new LegacyDbBootstrapError({ message: "failed to bootstrap" })) - : Effect.sync(() => { - startCalls.push(args); - }); - }, - recreateDatabase: () => Effect.void, - awaitStorageReady: () => Effect.succeed(false), - }); +function flags(fromBackup?: string): LegacyDbStartFlags { + return { fromBackup: fromBackup === undefined ? Option.none() : Option.some(fromBackup) }; +} + +interface SpawnRecord { + readonly args: ReadonlyArray; +} + +type RouteResult = { + readonly exitCode?: number; + readonly stdout?: ReadonlyArray; + readonly stderr?: ReadonlyArray; +}; + +/** Scoped-down port of `start.integration.test.ts`'s own `mockStartContainerCliSpawner` — one container instead of 14. */ +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({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }), + ); + } + + 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(6000 + spawned.length), + stdout: Stream.fromIterable(stdoutBytes), + stderr: Stream.fromIterable(stderrBytes), + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode ?? 0)), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ), + ); + return { layer, - get startCalls() { - return startCalls; + get spawned() { + return spawned; }, }; } -function setup( - workdir: string, - opts: { - toml?: string; - format?: OutputFormat; - running?: boolean; - runningFails?: boolean; - startFails?: boolean; - startExitCode?: number; - /** Caller cwd (Go's `CurrentDirAbs`) for relative `--from-backup` resolution. */ - cwd?: string; - }, -) { - if (opts.toml !== undefined) { - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "config.toml"), opts.toml); +const HEALTHY_STATE = '{"Running":true,"Status":"running","Health":{"Status":"healthy"}}'; +const STARTING_STATE = '{"Running":true,"Status":"running","Health":{"Status":"starting"}}'; + +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); +} + +/** The single `docker create` call for the `db` container, if one happened. */ +function createArgs(spawned: ReadonlyArray): ReadonlyArray | undefined { + return spawned.find((s) => s.args[0] === "create")?.args; +} + +/** Every `-v ` value passed to `docker create`. */ +function bindsFromCreateArgs(args: ReadonlyArray): ReadonlyArray { + const binds: Array = []; + for (let i = 0; i < args.length; i++) { + if (args[i] === "-v") binds.push(args[i + 1] ?? ""); + } + return binds; +} + +/** The three PG15+ one-shot migrate jobs (`legacyStartSetupLocalDatabase`'s `LegacyDockerRun` calls). */ +function dbSetupJobCalls(spawned: ReadonlyArray): ReadonlyArray { + return spawned.filter((s) => s.args[0] === "run" && s.args[1] === "--rm"); +} + +function rollbackWasAttempted(spawned: ReadonlyArray): boolean { + return spawned.some((s) => s.args[0] === "container" && s.args[1] === "prune"); +} + +function volumePruneWasAttempted(spawned: ReadonlyArray): boolean { + return spawned.some((s) => s.args[0] === "volume" && s.args[1] === "prune"); +} + +/** Stateful default route: only created containers inspect successfully, matching Docker across initial state detection and post-create health waits. Existing volume by default (a restart). */ +function defaultRoute(opts: { readonly neverHealthy?: boolean } = {}) { + const created = new Set(); + return (args: ReadonlyArray): RouteResult => { + if (args[0] === "image" && args[1] === "inspect") return { exitCode: 0 }; + if (args[0] === "network" && args[1] === "create") return { exitCode: 0 }; + if (args[0] === "volume" && args[1] === "inspect") return { exitCode: 0 }; + if (args[0] === "volume" && args[1] === "create") return { exitCode: 0 }; + if (args[0] === "context" && args[1] === "inspect") return { exitCode: 1 }; + if (args[0] === "create") { + const name = containerNameFromCreateArgs(args); + created.add(name); + return { stdout: [fakeContainerId(name)] }; + } + if (args[0] === "start") return { exitCode: 0 }; + if (args[0] === "container" && args[1] === "inspect") { + const id = args[2] ?? ""; + if (!created.has(id)) { + 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 }; + }; +} + +/** Overrides the default route's "volume already exists" answer to simulate a brand-new Postgres volume. */ +function freshVolumeRoute( + base: (args: ReadonlyArray) => RouteResult, +): (args: ReadonlyArray) => RouteResult { + return (args) => { + if (args[0] === "volume" && args[1] === "inspect") { + return { exitCode: 1, stderr: [`Error: No such volume: ${args[2] ?? ""}`] }; + } + return base(args); + }; +} + +/** + * Makes `legacyIsLocalDbRunning`'s pre-bring-up `container inspect` succeed + * unconditionally, simulating an already-up local db — this is the very first + * Docker call the handler makes, so no other `container inspect` call happens on + * this path (the already-running short-circuit returns before `StartDatabase`). + */ +function alreadyRunningRoute( + base: (args: ReadonlyArray) => RouteResult, +): (args: ReadonlyArray) => RouteResult { + return (args) => { + if (args[0] === "container" && args[1] === "inspect") return { stdout: [HEALTHY_STATE] }; + return base(args); + }; +} + +/** + * Makes `legacyIsLocalDbRunning`'s pre-bring-up `container inspect` fail for a + * reason other than "no such container" — simulates an unreachable Docker daemon + * during the running-check, which `AssertSupabaseDbIsRunning` propagates instead of + * treating as "not running". + */ +function runningCheckFailsRoute( + base: (args: ReadonlyArray) => RouteResult, +): (args: ReadonlyArray) => RouteResult { + return (args) => { + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 1, stderr: ["Error: cannot connect to the Docker daemon"] }; + } + return base(args); + }; +} + +const alwaysReadyHttpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))), + ), +); + +/** Mirrors `start.integration.test.ts`'s own `fakeDbSession` — PG15+ (this suite's default) never calls `exec`/`query` (its schema init is three one-shot `LegacyDockerRun` jobs instead). */ +function fakeDbSession() { + const calls: Array<{ kind: "exec" | "query"; sql: string }> = []; + const session: LegacyDbSession = { + exec: (sql) => + Effect.sync(() => { + calls.push({ kind: "exec", sql }); + }), + query: (sql) => + Effect.sync(() => { + calls.push({ kind: "query", sql }); + return []; + }), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }; + return { session, calls }; +} + +const tempRoot = useLegacyTempWorkdir("supabase-db-start-int-"); + +function writeConfig(workdir: string, contents: string) { + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), contents); +} + +interface SetupOpts { + readonly format?: OutputFormat; + readonly route?: (args: ReadonlyArray) => RouteResult; + readonly running?: boolean; + readonly runningFails?: boolean; + readonly configContents?: string; + readonly skipConfig?: boolean; + readonly workdir?: string; + readonly cwd?: string; + readonly platform?: NodeJS.Platform; + readonly networkId?: string; +} + +function setup(opts: SetupOpts = {}) { + const workdir = opts.workdir ?? tempRoot.current; + if (opts.skipConfig !== true) { + writeConfig(workdir, opts.configContents ?? 'project_id = "test"\n'); } const out = mockOutput({ format: opts.format ?? "text" }); - const seam = mockSeam(opts); const telemetry = mockLegacyTelemetryStateTracked(); + const cliConfig = mockLegacyCliConfig({ workdir }); + const baseRoute = opts.route ?? defaultRoute(); + const route = + opts.running === true + ? alreadyRunningRoute(baseRoute) + : opts.runningFails === true + ? runningCheckFailsRoute(baseRoute) + : baseRoute; + const child = mockContainerCliSpawner(route); + const dbSession = fakeDbSession(); + const layer = Layer.mergeAll( + BunServices.layer, out.layer, - seam.layer, - mockLegacyCliConfig({ workdir }), + cliConfig, telemetry.layer, - mockRuntimeInfo({ cwd: opts.cwd ?? workdir }), - BunServices.layer, + child.layer, + alwaysReadyHttpClientLayer, + Layer.succeed(LegacyDbConnection, { connect: () => Effect.succeed(dbSession.session) }), + legacyDockerRunLayer.pipe( + Layer.provide(child.layer), + Layer.provide(mockProcessControl().layer), + ), + mockProcessControl().layer, + mockRuntimeInfo({ platform: opts.platform ?? "linux", cwd: opts.cwd ?? workdir }), + Layer.succeed( + LegacyNetworkIdFlag, + opts.networkId === undefined ? Option.none() : Option.some(opts.networkId), + ), ); - return { layer, out, seam, telemetry }; + return { layer, out, telemetry, child, dbSession }; } -describe("legacy db start", () => { - const tmp = useLegacyTempWorkdir("supabase-db-start-"); +const currentBranchPath = (workdir: string) => + join(workdir, "supabase", ".branches", "_current_branch"); +describe("legacy db start", () => { it.live("reports an already-running database without starting a container", () => { - const { layer, out, seam, telemetry } = setup(tmp.current, { - toml: 'project_id = "test"\n', - running: true, - }); + const { layer, out, telemetry, child } = setup({ running: true }); return Effect.gen(function* () { yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Postgres database is already running."); - expect(seam.startCalls).toHaveLength(0); + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); expect(telemetry.flushed).toBe(true); + // `initCurrentBranch` is inside `StartDatabase`, never reached on the already-running + // short-circuit — `AssertSupabaseDbIsRunning` returns before `StartDatabase` is ever + // called (`start.go:48-50`). + expect(existsSync(currentBranchPath(tempRoot.current))).toBe(false); }); }); - it.live("starts the database when it is not running", () => { - const { layer, out, seam } = setup(tmp.current, { - toml: 'project_id = "test"\n', - running: false, - }); - return Effect.gen(function* () { - yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(seam.startCalls).toEqual([{ fromBackup: undefined }]); - // db start prints no "Finished" line and no status table. - expect(out.stderrText).not.toContain("Finished"); - }); - }); + it.live( + "starts the database on a fresh volume: creates the container, runs the SetupLocalDatabase-equivalent pipeline, and writes _current_branch", + () => { + const { layer, out, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Starting database...\n"); + expect(out.stderrText).not.toContain("Starting database from backup..."); + expect(createArgs(child.spawned)).not.toBeUndefined(); + expect(out.stderrText).toContain("Initialising schema..."); + // Default config: realtime, storage, and auth are all enabled (PG >= 15 default). + expect(dbSetupJobCalls(child.spawned)).toHaveLength(3); + expect(readFileSync(currentBranchPath(tempRoot.current), "utf8")).toBe("main"); + expect(out.stderrText).not.toContain("Finished"); + }); + }, + ); - it.live("forwards an absolute --from-backup to the bootstrap seam unchanged", () => { - const { layer, seam } = setup(tmp.current, { toml: 'project_id = "test"\n' }); - return Effect.gen(function* () { - yield* legacyDbStart({ fromBackup: Option.some("/tmp/dump.sql") }).pipe( - Effect.provide(layer), + it.live( + "PG <= 14 on a fresh volume: execs schema/globals SQL directly instead of the PG15+ one-shot migrate jobs", + () => { + const { layer, out, child, dbSession } = setup({ + configContents: 'project_id = "test"\n[db]\nmajor_version = 14\n', + route: freshVolumeRoute(defaultRoute()), + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Initialising schema..."); + // PG <= 14's `initSchema` execs globals.sql + the initial-schema SQL directly over the + // `LegacyDbConnection` session — no PG15+ one-shot `docker run --rm` migrate jobs at all. + expect(dbSetupJobCalls(child.spawned)).toHaveLength(0); + expect(dbSession.calls.length).toBeGreaterThan(0); + expect(readFileSync(currentBranchPath(tempRoot.current), "utf8")).toBe("main"); + }); + }, + ); + + it.live( + "a fresh volume with realtime disabled skips the realtime migrate job AND never attempts JWKS resolution", + () => { + // A configured (but unreachable) third-party JWKS issuer would fail `legacyResolveLocalJwks` + // if it were ever called — Go's own `initSchema15`'s realtime job resolves JWKS itself, + // gated on `Realtime.Enabled` (`internal/db/start/start.go:337-341`), so a fresh volume + // with realtime disabled must never even attempt it, regardless of what it would have + // resolved to. This is the one place `db start`'s own JWKS gating is directly observable + // (`legacyStartDatabase`'s `setup.jwks` is a LAZY `Effect`, evaluated only when reached). + const previousFetch = globalThis.fetch; + globalThis.fetch = Object.assign(() => Promise.reject(new Error("ECONNREFUSED")), { + preconnect: previousFetch.preconnect, + }); + const { layer, child } = setup({ + configContents: + 'project_id = "test"\n[realtime]\nenabled = false\n[auth.third_party.firebase]\nenabled = true\nproject_id = "fb-project"\n', + route: freshVolumeRoute(defaultRoute()), + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + // Default config: storage and auth stay enabled — only the realtime job is skipped. + expect(dbSetupJobCalls(child.spawned)).toHaveLength(2); + expect(readFileSync(currentBranchPath(tempRoot.current), "utf8")).toBe("main"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + globalThis.fetch = previousFetch; + }), + ), ); - expect(seam.startCalls).toEqual([{ fromBackup: "/tmp/dump.sql" }]); - }); - }); + }, + ); + + it.live( + "a fresh volume with realtime enabled fails with a typed error when JWKS resolution fails", + () => { + const previousFetch = globalThis.fetch; + globalThis.fetch = Object.assign(() => Promise.reject(new Error("ECONNREFUSED")), { + preconnect: previousFetch.preconnect, + }); + const { layer, child } = setup({ + configContents: + 'project_id = "test"\n[auth.third_party.firebase]\nenabled = true\nproject_id = "fb-project"\n', + route: freshVolumeRoute(defaultRoute()), + }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyDbConfigLoadError"); + } + // The container was already created/started/healthy by the time JWKS resolution runs + // (deep inside the fresh-volume setup step) — the rollback still tears it down. + expect(rollbackWasAttempted(child.spawned)).toBe(true); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + globalThis.fetch = previousFetch; + }), + ), + ); + }, + ); + + it.live( + "restarts against an existing volume: skips the SetupLocalDatabase-equivalent pipeline but still writes _current_branch", + () => { + const { layer, out, child } = setup(); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Starting database from backup...\n"); + expect(out.stderrText).not.toContain("Initialising schema..."); + expect(dbSetupJobCalls(child.spawned)).toHaveLength(0); + expect(readFileSync(currentBranchPath(tempRoot.current), "utf8")).toBe("main"); + }); + }, + ); + + it.live( + "--from-backup on a fresh volume: uses the restore entrypoint, binds the backup file, and skips the SetupLocalDatabase-equivalent pipeline entirely", + () => { + const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); + return Effect.gen(function* () { + yield* legacyDbStart(flags("/abs/host/backup.sql")).pipe(Effect.provide(layer)); + const args = createArgs(child.spawned); + expect(args).not.toBeUndefined(); + const script = args?.[(args?.indexOf("-c") ?? -1) + 1]; + expect(script).toContain("/docker-entrypoint-initdb.d/migrate.sh"); + expect(bindsFromCreateArgs(args ?? [])).toContain( + "/abs/host/backup.sql:/etc/backup.sql:ro", + ); + expect(dbSetupJobCalls(child.spawned)).toHaveLength(0); + expect(readFileSync(currentBranchPath(tempRoot.current), "utf8")).toBe("main"); + }); + }, + ); + + it.live( + '--from-backup against an existing volume fails with "backup volume already exists" and rolls back without creating a container', + () => { + const { layer, child } = setup(); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(flags("/abs/host/backup.sql")).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect(error).toMatchObject({ + _tag: "LegacyStartBackupVolumeExistsError", + message: "backup volume already exists", + }); + expect((error as { suggestion?: string }).suggestion).toContain( + "supabase stop --no-backup", + ); + } + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + // Go's `Run` always calls `DockerRemoveAll` on ANY `StartDatabase` failure + // (`start.go:54-59`), including this guard — `deleteVolumes: false` since the volume + // this guard detected must never be pruned. + expect(rollbackWasAttempted(child.spawned)).toBe(true); + expect(volumePruneWasAttempted(child.spawned)).toBe(false); + }); + }, + ); it.live("resolves a relative --from-backup against the caller cwd, not the workdir", () => { - // Go resolves a relative fromBackup against `CurrentDirAbs` (the caller cwd, captured - // before ChangeWorkDir), so the seam must receive the caller-relative absolute path even - // though its Go child runs with cwd = the project workdir. - const { layer, seam } = setup(tmp.current, { - toml: 'project_id = "test"\n', + const { layer, child } = setup({ + route: freshVolumeRoute(defaultRoute()), cwd: "/caller/here", }); return Effect.gen(function* () { - yield* legacyDbStart({ fromBackup: Option.some("dump.sql") }).pipe(Effect.provide(layer)); - expect(seam.startCalls).toEqual([{ fromBackup: "/caller/here/dump.sql" }]); + yield* legacyDbStart(flags("dump.sql")).pipe(Effect.provide(layer)); + const args = createArgs(child.spawned); + expect(bindsFromCreateArgs(args ?? [])).toContain("/caller/here/dump.sql:/etc/backup.sql:ro"); }); }); it.live("treats an empty --from-backup as a normal no-backup start", () => { - // Go's StartDatabase sees `len(fromBackup) == 0` and starts without a backup; an empty - // string must not be joined to the caller cwd and passed as a directory path. - const { layer, seam } = setup(tmp.current, { - toml: 'project_id = "test"\n', - cwd: "/caller/here", + const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); + return Effect.gen(function* () { + yield* legacyDbStart(flags("")).pipe(Effect.provide(layer)); + const args = createArgs(child.spawned); + expect(bindsFromCreateArgs(args ?? []).some((b) => b.endsWith(":/etc/backup.sql:ro"))).toBe( + false, + ); + }); + }); + + it.live("a health-check timeout without --from-backup fails the command and rolls back", () => { + // `db.health_timeout` (unlike the generic 30s `serviceTimeout` every other service waits on) + // is a real config.toml-configurable seam — this keeps the scenario fast instead of waiting + // out the real 2m default. + const { layer, child } = setup({ + configContents: 'project_id = "test"\n[db]\nhealth_timeout = "1s"\n', + route: freshVolumeRoute(defaultRoute({ neverHealthy: true })), }); return Effect.gen(function* () { - yield* legacyDbStart({ fromBackup: Option.some("") }).pipe(Effect.provide(layer)); - expect(seam.startCalls).toEqual([{ fromBackup: undefined }]); + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(rollbackWasAttempted(child.spawned)).toBe(true); + // This run's own volume was confirmed fresh (`freshVolumeRoute`), so the rollback prunes + // it too — matching Go's `NoBackupVolume` gate. A regression that hardcoded + // `legacyRollbackStart`'s `deleteVolumes` to `false` would still pass every OTHER + // assertion in this file, since only the "backup volume already exists" test (a + // non-fresh-volume scenario) currently asserts the negative half. + expect(volumePruneWasAttempted(child.spawned)).toBe(true); + // The health-check timeout aborts before `SetupLocalDatabase`/`initCurrentBranch` ever run. + expect(existsSync(currentBranchPath(tempRoot.current))).toBe(false); }); }); + it.live( + "a health-check timeout WITH --from-backup is swallowed: the command still succeeds and writes _current_branch", + () => { + const { layer, child } = setup({ + configContents: 'project_id = "test"\n[db]\nhealth_timeout = "1s"\n', + route: freshVolumeRoute(defaultRoute({ neverHealthy: true })), + }); + 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; + // 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); + expect(readFileSync(currentBranchPath(tempRoot.current), "utf8")).toBe("main"); + }); + }, + ); + it.live("proceeds with no config file (missing config is tolerated)", () => { - const { layer, seam } = setup(tmp.current, { running: false }); + const { layer, child } = setup({ skipConfig: true, route: freshVolumeRoute(defaultRoute()) }); return Effect.gen(function* () { yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(seam.startCalls).toHaveLength(1); + expect(createArgs(child.spawned)).not.toBeUndefined(); }); }); + it.live( + "fails with a typed error on a malformed supabase/.env file, before any container is created", + () => { + const { layer, child } = setup({}); + writeFileSync(join(tempRoot.current, "supabase", ".env"), "not a valid env line at all\n"); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyDbConfigLoadError"); + } + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + }); + }, + ); + it.live("fails fast on a malformed config.toml", () => { - const { layer, seam, telemetry } = setup(tmp.current, { - toml: 'project_id = "unterminated\n', - }); + const { layer, child, telemetry } = setup({ configContents: 'project_id = "unterminated\n' }); return Effect.gen(function* () { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(JSON.stringify(exit.cause)).toContain("failed to load config"); } - // No container work attempted; telemetry still flushes on failure. - expect(seam.startCalls).toHaveLength(0); + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); expect(telemetry.flushed).toBe(true); }); }); it.live("fails fast on an undecryptable secret even when the db is already running", () => { - // Regression for the seam swallowing config-load errors: Go runs `flags.LoadConfig` - // (which decrypts every secret) BEFORE `AssertSupabaseDbIsRunning`, so a broken - // config aborts `db start` regardless of container state. Previously the handler's - // only config read was the seam's best-effort one, so an undecryptable secret with - // the container already up printed "already running" and exited 0. - const { layer, out } = setup(tmp.current, { - toml: '[db]\nroot_key = "encrypted:anything"\n', + const { layer, out } = setup({ + configContents: '[db]\nroot_key = "encrypted:anything"\n', running: true, }); return Effect.gen(function* () { @@ -209,8 +597,59 @@ describe("legacy db start", () => { }); }); + it.live( + "--network-id forces the created network/container onto the override, not the generated network name", + () => { + const { layer, child } = setup({ + route: freshVolumeRoute(defaultRoute()), + networkId: "custom-network", + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect( + child.spawned.some((s) => s.args[0] === "network" && s.args.at(-1) === "custom-network"), + ).toBe(true); + const args = createArgs(child.spawned); + const networkIndex = args?.indexOf("--network") ?? -1; + expect(args?.[networkIndex + 1]).toBe("custom-network"); + }); + }, + ); + + it.live( + "fails with a typed config error on a malformed SUPABASE_DB_HEALTH_TIMEOUT, before any container is created", + () => { + const { layer, child } = setup({ + configContents: 'project_id = "test"\n[db]\nhealth_timeout = "not-a-duration"\n', + }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyDbConfigLoadError"); + } + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + }); + }, + ); + + it.live( + "does not add the Linux-only host.docker.internal extra host on a non-Linux platform", + () => { + const { layer, child } = setup({ + route: freshVolumeRoute(defaultRoute()), + platform: "darwin", + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + const args = createArgs(child.spawned); + expect(args?.includes("--add-host")).toBe(false); + }); + }, + ); + it.live("propagates a Docker inspect failure", () => { - const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', runningFails: true }); + const { layer } = setup({ runningFails: true }); return Effect.gen(function* () { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -220,49 +659,25 @@ describe("legacy db start", () => { }); }); - it.live("propagates a StartDatabase failure", () => { - const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', startFails: true }); + it.live("propagates a container-create failure and rolls back", () => { + const base = defaultRoute(); + const route = freshVolumeRoute((args) => { + if (args[0] === "create") return { exitCode: 1, stderr: ["boom"] }; + return base(args); + }); + const { layer, child } = setup({ route }); return Effect.gen(function* () { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to bootstrap"); - } + expect(rollbackWasAttempted(child.spawned)).toBe(true); + // Same reasoning as the health-timeout rollback test above — this run's own volume was + // confirmed fresh, so the rollback prunes it too. + expect(volumePruneWasAttempted(child.spawned)).toBe(true); }); }); - it.live( - "propagates the bootstrap child's exact exit code as LegacyGoChildExitError and still flushes telemetry", - () => { - // The bootstrap seam's `startDatabase` (the `!captureStdout` bootstrap-child - // path) failing non-zero must reach the handler as the exact `LegacyGoChildExitError` - // it fails with — not a generic `LegacyDbBootstrapError` collapsing every exit code to - // 1 — 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', - running: false, - startExitCode: 3, - }); - return Effect.gen(function* () { - const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = Cause.squash(exit.cause); - expect(error).toBeInstanceOf(LegacyGoChildExitError); - expect((error as LegacyGoChildExitError).exitCode).toBe(3); - } - expect(telemetry.flushed).toBe(true); - }); - }, - ); - it.live("emits a json result when the database is already running", () => { - const { layer, out } = setup(tmp.current, { - toml: 'project_id = "test"\n', - running: true, - format: "json", - }); + const { layer, out } = setup({ running: true, format: "json" }); return Effect.gen(function* () { yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); const success = out.messages.find((m) => m.type === "success"); @@ -271,14 +686,10 @@ describe("legacy db start", () => { }); it.live("emits a json result after starting the database", () => { - const { layer, out, seam } = setup(tmp.current, { - toml: 'project_id = "test"\n', - running: false, - format: "json", - }); + const { layer, out, child } = setup({ format: "json" }); return Effect.gen(function* () { yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(seam.startCalls).toHaveLength(1); + expect(createArgs(child.spawned)).not.toBeUndefined(); const success = out.messages.find((m) => m.type === "success"); expect(success?.data?.["status"]).toBe("started"); }); diff --git a/apps/cli/src/legacy/commands/db/start/start.layers.ts b/apps/cli/src/legacy/commands/db/start/start.layers.ts index 71603292ee..8185bcc681 100644 --- a/apps/cli/src/legacy/commands/db/start/start.layers.ts +++ b/apps/cli/src/legacy/commands/db/start/start.layers.ts @@ -2,26 +2,40 @@ import { Layer } from "effect"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyHttpClientLayer } from "../../../auth/legacy-http-debug.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 { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; -import { legacyDbBootstrapSeamLayer } from "../shared/legacy-db-bootstrap.seam.layer.ts"; /** - * Runtime layer for `supabase db start`. The command is local-only, so it needs - * far less than the remote-capable db commands: just the container-bootstrap seam - * (`db __db-bootstrap`), the CLI config (workdir + project id), and the telemetry - * flush. The seam's other dependencies (`LegacyNetworkIdFlag`, `LegacyProfileFlag`, - * `ChildProcessSpawner`, `FileSystem`, `Path`) are ambient from the root runtime, - * matching how `db diff` composes the `db __shadow` seam. `LegacyCliConfig` is - * provided to the seam explicitly (legacy CLAUDE.md rule 5). + * Runtime layer for `supabase db start`. `LegacyCliConfig`/`ChildProcessSpawner`/ + * `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`). + * + * `legacyDockerRunLayer`/`legacyDbConnectionLayer`/`legacyHttpClientLayer` back the native + * container bootstrap itself (`start.handler.ts`): the fresh-volume `SetupLocalDatabase`- + * equivalent pipeline runs its PG15+ one-shot migrate jobs through `LegacyDockerRun` and its + * schema/globals/API-privileges SQL over a direct `LegacyDbConnection` session, and the health + * wait (`legacyWaitForHealthyServices`) requires `HttpClient.HttpClient` in its type signature + * even though `db start` never uses the PostgREST/Edge-Runtime gateway probes — same reasoning + * as `start.command.ts`'s own composition of all three. */ const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); - -const seam = legacyDbBootstrapSeamLayer.pipe(Layer.provide(cliConfig)); +const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); export const legacyDbStartRuntimeLayer = Layer.mergeAll( - seam, cliConfig, legacyTelemetryStateLayer, commandRuntimeLayer(["db", "start"]), + legacyDockerRunLayer, + legacyDbConnectionLayer, + httpClient, ); 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 99a8779835..36fe45afbf 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 @@ -14,7 +14,7 @@ * * Unlike its 12 siblings in this directory, this module does NOT build a * `LegacyStartContainerSpec` for `legacyStartContainer` - * (`../lib/container-lifecycle.ts`) to create+start uniformly. That + * (`../../../shared/db-bootstrap/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 @@ -53,7 +53,7 @@ * (`start.go:66-72,1103`) uses the real `dbConfig` — the `db` container's own * sanitized name and `config.db.password` — exactly like the other 12 * services' `dbHost`/`dbPassword` derivation - * (`../lib/internal-db-connection.ts`). {@link legacyStartEdgeRuntimeContainer} + * (`../../../shared/db-bootstrap/internal-db-connection.ts`). {@link legacyStartEdgeRuntimeContainer} * reproduces that real value, not `functions serve`'s alias-based default. */ @@ -68,7 +68,7 @@ import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts import { legacyStartInternalDbPassword, legacyStartInternalDbUrl, -} from "../lib/internal-db-connection.ts"; +} from "../../../shared/db-bootstrap/internal-db-connection.ts"; export interface LegacyEdgeRuntimeBringUpInput { /** Go's `Config.ProjectId`, already sanitized — see `legacyServiceContainerName`'s callers. */ @@ -145,7 +145,7 @@ export interface LegacyEdgeRuntimeBringUpInput { * 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`, `../lib/health-check.ts` — the same + * `LegacyWaitForHealthyServicesOptions`, `../../../shared/db-bootstrap/health-check.ts` — the same * shape as the existing `postgrest` gateway). `watchSpecs` is * `functions serve`-only file-watch plumbing and can be ignored here. * @@ -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` - * (`../lib/container-lifecycle.ts`) already applies to every other service's + * (`../../../shared/db-bootstrap/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 b915a3d2b8..12832ca852 100644 --- a/apps/cli/src/legacy/commands/start/services/gotrue.service.ts +++ b/apps/cli/src/legacy/commands/start/services/gotrue.service.ts @@ -60,11 +60,11 @@ 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 "../lib/docker-create-args.ts"; +import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; import { legacyStartInternalDbPassword, legacyStartInternalDbUrl, -} from "../lib/internal-db-connection.ts"; +} from "../../../shared/db-bootstrap/internal-db-connection.ts"; /** `utils.GotrueAliases[0]` (`apps/cli-go/internal/utils/config.go:38`) — also this service's `containerSuffix` in `LEGACY_SERVICE_CATALOG`. */ const LEGACY_GOTRUE_CONTAINER_SUFFIX = "auth"; 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 69c9a32831..f3d0fac655 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 "../lib/docker-create-args.ts"; +import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/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 f36cdd93d1..e4b0dd8c92 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 "../lib/docker-create-args.ts"; +import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/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 3b61d6a9d7..43bb835740 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 "../lib/docker-create-args.ts"; +import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/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 9ddb6faa77..3ffd8b156f 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 "../lib/docker-create-args.ts"; +import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/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 351ed67f7e..f5ee9cfe37 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 "../lib/docker-create-args.ts"; +import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/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 c935d8a1f1..843192b117 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`, `../lib/health-check.ts`, + * (`legacyCheckHttpReady`/`LEGACY_POSTGREST_READY_PATH`, `../../../shared/db-bootstrap/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,11 +24,11 @@ import type { ProjectConfig } from "@supabase/config"; import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; -import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; +import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; import { legacyStartInternalDbPassword, legacyStartInternalDbUrl, -} from "../lib/internal-db-connection.ts"; +} from "../../../shared/db-bootstrap/internal-db-connection.ts"; export interface LegacyPostgrestEnvInput { /** `config.api.schemas` — joined with `,` into `PGRST_DB_SCHEMAS`. */ @@ -39,7 +39,7 @@ export interface LegacyPostgrestEnvInput { readonly maxRows: ProjectConfig["api"]["max_rows"]; /** The `db` container's own Docker name (`legacyServiceContainerName("db", projectId)`). */ readonly dbHost: string; - /** See `legacyStartInternalDbPassword` (`../lib/internal-db-connection.ts`). */ + /** See `legacyStartInternalDbPassword` (`../../../shared/db-bootstrap/internal-db-connection.ts`). */ readonly dbPassword: string; /** * `legacyResolveLocalJwks`'s resolved JWKS JSON string — feeds 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 1f0571da34..9ec19afa59 100644 --- a/apps/cli/src/legacy/commands/start/services/realtime.service.ts +++ b/apps/cli/src/legacy/commands/start/services/realtime.service.ts @@ -14,83 +14,12 @@ import type { ProjectConfig } from "@supabase/config"; import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; -import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; import { - legacyStartInternalDbPassword, - LEGACY_START_INTERNAL_DB_NAME, - LEGACY_START_INTERNAL_DB_PORT, -} from "../lib/internal-db-connection.ts"; - -/** - * Go's `utils.SUPERUSER_ROLE` (`apps/cli-go/internal/utils/connect.go:338`) — - * Realtime's fixed `DB_USER` (`start.go:913`). Unrelated to the per-service - * role each OTHER container's own DB connection string uses (PostgREST's - * `authenticator`, Storage's `supabase_storage_admin`), so it is not hoisted - * alongside `legacyStartInternalDbUrl`. - */ -const LEGACY_REALTIME_DB_USER = "supabase_admin"; - -/** - * Go's `realtime.TenantId` default (`pkg/config/config.go:481`) — `toml:"-"` - * (`config.go:254`), so never configurable via `config.toml` or a - * `SUPABASE_*` override; always this literal. Exported: `kong.service.ts`'s - * `kong.yml` template needs this exact same value for its `RealtimeId` field - * (Go's `Config.Realtime.TenantId`, `start.go:492` — NOT Realtime's own - * container name/id, see that module's `realtimeTenantId` doc comment). - */ -export const LEGACY_REALTIME_TENANT_ID = "realtime-dev"; - -/** Go's `realtime.EncryptionKey` default (`pkg/config/config.go:482`) — `toml:"-"`, never configurable. */ -const LEGACY_REALTIME_ENCRYPTION_KEY = "supabaserealtime"; - -/** Go's `realtime.SecretKeyBase` default (`pkg/config/config.go:483`) — `toml:"-"`, never configurable. */ -const LEGACY_REALTIME_SECRET_KEY_BASE = - "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG"; - -export interface LegacyRealtimeEnvInput { - /** `config.realtime.ip_version` — feeds `utils.ToRealtimeEnv` (`utils/config.go:209-214`). */ - readonly ipVersion: ProjectConfig["realtime"]["ip_version"]; - /** `config.realtime.max_header_length`. */ - readonly maxHeaderLength: ProjectConfig["realtime"]["max_header_length"]; - /** The `db` container's own Docker name (`legacyServiceContainerName("db", projectId)`). */ - readonly dbHost: string; - /** See {@link legacyStartInternalDbPassword}. */ - readonly dbPassword: string; - /** `LegacyLocalConfigValues.jwtSecret` — feeds both `API_JWT_SECRET` and `METRICS_JWT_SECRET`. */ - readonly jwtSecret: string; - /** `legacyResolveLocalJwks`'s resolved JWKS JSON string — feeds `API_JWT_JWKS`. */ - readonly jwks: string; -} - -/** - * Pure env-var builder, split out from {@link legacyBuildRealtimeContainerSpec} - * so the full Go `Env` literal (`start.go:909-929`) is unit-testable without - * constructing a whole container spec. - */ -export function legacyBuildRealtimeEnv(input: LegacyRealtimeEnvInput): Record { - return { - PORT: "4000", - DB_HOST: input.dbHost, - DB_PORT: String(LEGACY_START_INTERNAL_DB_PORT), - DB_USER: LEGACY_REALTIME_DB_USER, - DB_PASSWORD: input.dbPassword, - DB_NAME: LEGACY_START_INTERNAL_DB_NAME, - DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime", - DB_ENC_KEY: LEGACY_REALTIME_ENCRYPTION_KEY, - API_JWT_SECRET: input.jwtSecret, - API_JWT_JWKS: input.jwks, - METRICS_JWT_SECRET: input.jwtSecret, - APP_NAME: "realtime", - SECRET_KEY_BASE: LEGACY_REALTIME_SECRET_KEY_BASE, - ERL_AFLAGS: input.ipVersion === "IPv6" ? "-proto_dist inet6_tcp" : "-proto_dist inet_tcp", - // Two literal single-quote characters, exactly like Go's `"DNS_NODES=''"` (`start.go:924`). - DNS_NODES: "''", - RLIMIT_NOFILE: "", - SEED_SELF_HOST: "true", - RUN_JANITOR: "true", - MAX_HEADER_LENGTH: String(input.maxHeaderLength), - }; -} + LEGACY_REALTIME_TENANT_ID, + legacyBuildRealtimeEnv, +} from "../../../shared/db-bootstrap/realtime-env.ts"; +import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; +import { legacyStartInternalDbPassword } from "../../../shared/db-bootstrap/internal-db-connection.ts"; export interface LegacyRealtimeContainerSpecInput { /** Go's `Config.ProjectId`, already sanitized — see `legacyServiceContainerName`'s callers. */ diff --git a/apps/cli/src/legacy/commands/start/services/realtime.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/realtime.service.unit.test.ts index dfbf24b95d..3aca580e8b 100644 --- a/apps/cli/src/legacy/commands/start/services/realtime.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/realtime.service.unit.test.ts @@ -2,61 +2,9 @@ import { describe, expect, test } from "vitest"; import { legacyBuildRealtimeContainerSpec, - legacyBuildRealtimeEnv, type LegacyRealtimeContainerSpecInput, } from "./realtime.service.ts"; -describe("legacyBuildRealtimeEnv", () => { - const base = { - ipVersion: "IPv4" as const, - maxHeaderLength: 4096, - dbHost: "supabase_db_proj", - dbPassword: "postgres", - jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", - jwks: '{"keys":[]}', - }; - - test("wires the fixed internal DB address, JWT secret, and JWKS", () => { - const env = legacyBuildRealtimeEnv(base); - expect(env["DB_HOST"]).toBe("supabase_db_proj"); - expect(env["DB_PORT"]).toBe("5432"); - expect(env["DB_USER"]).toBe("supabase_admin"); - expect(env["DB_PASSWORD"]).toBe("postgres"); - expect(env["DB_NAME"]).toBe("postgres"); - expect(env["API_JWT_SECRET"]).toBe(base.jwtSecret); - expect(env["METRICS_JWT_SECRET"]).toBe(base.jwtSecret); - expect(env["API_JWT_JWKS"]).toBe(base.jwks); - }); - - test("matches Go's remaining static env values", () => { - const env = legacyBuildRealtimeEnv(base); - expect(env).toMatchObject({ - PORT: "4000", - DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime", - DB_ENC_KEY: "supabaserealtime", - APP_NAME: "realtime", - SECRET_KEY_BASE: "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", - DNS_NODES: "''", - RLIMIT_NOFILE: "", - SEED_SELF_HOST: "true", - RUN_JANITOR: "true", - MAX_HEADER_LENGTH: "4096", - }); - }); - - test("selects inet_tcp for IPv4", () => { - expect(legacyBuildRealtimeEnv({ ...base, ipVersion: "IPv4" })["ERL_AFLAGS"]).toBe( - "-proto_dist inet_tcp", - ); - }); - - test("selects inet6_tcp for IPv6", () => { - expect(legacyBuildRealtimeEnv({ ...base, ipVersion: "IPv6" })["ERL_AFLAGS"]).toBe( - "-proto_dist inet6_tcp", - ); - }); -}); - describe("legacyBuildRealtimeContainerSpec", () => { const input: LegacyRealtimeContainerSpecInput = { projectId: "proj", 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 439a465b9e..3cd416049d 100644 --- a/apps/cli/src/legacy/commands/start/services/storage.service.ts +++ b/apps/cli/src/legacy/commands/start/services/storage.service.ts @@ -44,12 +44,12 @@ 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 "../lib/docker-create-args.ts"; +import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; import { legacyEnvOrDefault } from "../lib/legacy-env-or-default.ts"; import { legacyStartInternalDbUrl, legacyStartInternalDbPassword, -} from "../lib/internal-db-connection.ts"; +} from "../../../shared/db-bootstrap/internal-db-connection.ts"; /** Go's `dockerStoragePath` local (`start.go:996`) — both the container's `FILE_STORAGE_BACKEND_PATH` and its named-volume mount target. */ const LEGACY_STORAGE_DOCKER_PATH = "/mnt"; @@ -57,7 +57,7 @@ const LEGACY_STORAGE_DOCKER_PATH = "/mnt"; export interface LegacyStorageVectorEnvInput { /** The `db` container's own Docker name (`legacyServiceContainerName("db", projectId)`). */ readonly dbHost: string; - /** See `legacyStartInternalDbPassword` (`../lib/internal-db-connection.ts`). */ + /** See `legacyStartInternalDbPassword` (`../../../shared/db-bootstrap/internal-db-connection.ts`). */ readonly dbPassword: string; readonly projectEnvValues?: Readonly>; } @@ -116,7 +116,7 @@ export interface LegacyStorageEnvInput { readonly jwks: string; /** The `db` container's own Docker name (`legacyServiceContainerName("db", projectId)`). */ readonly dbHost: string; - /** See `legacyStartInternalDbPassword` (`../lib/internal-db-connection.ts`). */ + /** See `legacyStartInternalDbPassword` (`../../../shared/db-bootstrap/internal-db-connection.ts`). */ readonly dbPassword: string; /** `config.storage.file_size_limit`, e.g. `"50MiB"` — converted to a byte count via `ramInBytes`. */ readonly fileSizeLimit: ProjectConfig["storage"]["file_size_limit"]; 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 5bca44b936..7e63938a30 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 "../lib/docker-create-args.ts"; +import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/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 43724b538a..3da730f4e5 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 "../lib/docker-create-args.ts"; +import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/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 383698b683..2664d2e469 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 "../lib/docker-create-args.ts"; +import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/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.format.ts b/apps/cli/src/legacy/commands/start/start.format.ts index 0afe71febc..54c16d756b 100644 --- a/apps/cli/src/legacy/commands/start/start.format.ts +++ b/apps/cli/src/legacy/commands/start/start.format.ts @@ -34,28 +34,6 @@ export const LEGACY_START_STARTING_CONTAINERS_MESSAGE = "Starting containers...\ */ export const LEGACY_START_WAITING_FOR_HEALTH_CHECKS_MESSAGE = "Waiting for health checks...\n"; -/** - * Go's `fmt.Fprintln(w, "Starting database...")` - * (`apps/cli-go/internal/db/start/start.go:165-175`) — printed right before - * the Postgres container itself is created/started, when the pre-create - * volume-existence check finds no existing volume (a brand-new, first-ever - * `start`). - */ -export const LEGACY_START_STARTING_DATABASE_MESSAGE = "Starting database...\n"; - -/** - * Go's `fmt.Fprintln(w, "Starting database from backup...")` - * (`apps/cli-go/internal/db/start/start.go:165-175`) — printed instead of - * {@link LEGACY_START_STARTING_DATABASE_MESSAGE} when the pre-create - * volume-existence check finds an EXISTING volume (a restart reusing the - * already-persisted Postgres data). Despite the wording, this has nothing to - * do with any `--from-backup` file-restore flag — Go's own `fromBackup` - * parameter is always empty for a plain `start`, so this is the only branch - * ever reached in that call path. - */ -export const LEGACY_START_STARTING_DATABASE_FROM_BACKUP_MESSAGE = - "Starting database from backup...\n"; - /** * Go's `fmt.Fprintf(os.Stderr, "Started %s local development setup.\n\n", * utils.Aqua("supabase"))` (`apps/cli-go/internal/start/start.go:84`) — diff --git a/apps/cli/src/legacy/commands/start/start.format.unit.test.ts b/apps/cli/src/legacy/commands/start/start.format.unit.test.ts index e2fd2310d6..e0a5e4f818 100644 --- a/apps/cli/src/legacy/commands/start/start.format.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/start.format.unit.test.ts @@ -3,8 +3,6 @@ import { describe, expect, it } from "vitest"; import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; import { LEGACY_START_STARTING_CONTAINERS_MESSAGE, - LEGACY_START_STARTING_DATABASE_FROM_BACKUP_MESSAGE, - LEGACY_START_STARTING_DATABASE_MESSAGE, LEGACY_START_WAITING_FOR_HEALTH_CHECKS_MESSAGE, legacyStartAlreadyRunningMessage, legacyStartCompletedMessage, @@ -31,20 +29,6 @@ describe("LEGACY_START_WAITING_FOR_HEALTH_CHECKS_MESSAGE", () => { }); }); -describe("LEGACY_START_STARTING_DATABASE_MESSAGE", () => { - it("matches Go's exact stderr line, with a single trailing newline", () => { - expect(LEGACY_START_STARTING_DATABASE_MESSAGE).toBe("Starting database...\n"); - }); -}); - -describe("LEGACY_START_STARTING_DATABASE_FROM_BACKUP_MESSAGE", () => { - it("matches Go's exact stderr line, with a single trailing newline", () => { - expect(LEGACY_START_STARTING_DATABASE_FROM_BACKUP_MESSAGE).toBe( - "Starting database from backup...\n", - ); - }); -}); - describe("legacyStartCompletedMessage", () => { it("matches Go's exact stderr line, with two trailing newlines", () => { expect(stripAnsi(legacyStartCompletedMessage())).toBe( diff --git a/apps/cli/src/legacy/commands/start/start.gates.ts b/apps/cli/src/legacy/commands/start/start.gates.ts index 041132597a..c12af51a9b 100644 --- a/apps/cli/src/legacy/commands/start/start.gates.ts +++ b/apps/cli/src/legacy/commands/start/start.gates.ts @@ -1,11 +1,11 @@ import type { ProjectConfig } from "@supabase/config"; import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; -import { - replaceImageTag, - type LocalServiceVersionName, - type LocalServiceVersionOverrides, +import type { + LocalServiceVersionName, + LocalServiceVersionOverrides, } from "../../../shared/services/services.shared.ts"; +import { legacyResolvePinnedImage } from "../../shared/db-bootstrap/pinned-image.ts"; import { legacyEnvOverrideBool } from "../../shared/legacy-local-config-values.ts"; import { LEGACY_START_SERVICES } from "./start.services.ts"; @@ -224,26 +224,6 @@ const START_SERVICE_TO_LOCAL_VERSION_NAME: Readonly( }); } -/** - * Go's `Db.HealthTimeout` (`internal/db/start/start.go:180`) — a duration - * STRING (`"2m"` default, `packages/config/src/db.ts`), unlike every other - * `start` health wait, which uses the fixed 30s `serviceTimeout` global - * (`apps/cli-go/internal/start/start.go:161,1271`). Go decodes this field via - * `mapstructure.StringToTimeDurationHookFunc()` inside the same - * `v.UnmarshalExact` call every `SUPABASE_*` override goes through - * (`pkg/config/config.go:749-756,775-784`) — a malformed value hard-fails - * `Config.Load` (`"failed to parse config: %w"`) before `start` ever runs; it - * is never silently replaced with a default. A valid-but-degenerate value - * (e.g. `"0s"`) isn't special-cased either: Go's backoff policy computes - * `uint64(timeout.Seconds())` as the retry count - * (`internal/db/start/start.go:192-198`), and the backoff library returns - * `Stop` immediately when that count is `0` — i.e. exactly one immediate - * health probe with no wait, not a 30s fallback. Throws on a malformed - * value; the caller wraps that into `LegacyStartInvalidConfigError` so - * rollback still fires (a plain throw here would surface as an - * Effect defect instead of a typed failure). - */ -function resolveDbHealthTimeoutSeconds(healthTimeout: string): number { - return Math.trunc(legacyParseGoDuration(healthTimeout) / 1_000_000_000); -} - /** * Go's `appendGotruePasskeyEnv`/`Auth.Passkey`/`Auth.Webauthn` presence gate * (`start.go:1427-1440`, `pkg/config/config.go:1117-1134`): `@supabase/config` @@ -520,30 +477,6 @@ function resolveGotrueOAuthServer( * when no `signing_keys_path` is configured or auth is disabled. */ -/** - * `auth.external_url` isn't modeled in `@supabase/config`'s schema, so it's - * read off the raw document — same presence-based pattern as passkey/ - * webauthn/external. Go's `auth.GetExternalURL` (`pkg/config/auth.go:401-405`) - * prefers this explicit value over deriving from `apiUrl`, and feeds it into - * `API_EXTERNAL_URL`, the mailer verify URL, the default JWT issuer, and - * OAuth redirect-URI fallbacks (`start.go:1354,1357,1374,1446`) for the - * long-running GoTrue container — AND into the identical `API_EXTERNAL_URL` - * Go's one-shot fresh-DB auth migration job builds (`db/start/start.go:323`). - * Both callers must resolve the SAME value, hence this standalone helper - * instead of two independent derivations. - */ -function resolveAuthExternalUrl( - document: Readonly> | undefined, - projectEnvValues: Readonly> | undefined, -): string | undefined { - const rawAuthExternalUrl = asRecord(document?.["auth"])?.["external_url"]; - return legacyEnvOverride( - "SUPABASE_AUTH_EXTERNAL_URL", - typeof rawAuthExternalUrl === "string" ? rawAuthExternalUrl : undefined, - projectEnvValues, - ); -} - function resolveGotrueEnvInput(params: { readonly context: LegacyLocalProjectContext; readonly values: LegacyLocalConfigValues; @@ -611,7 +544,7 @@ function resolveGotrueEnvInput(params: { config.auth.external, projectEnvValues, ); - const authExternalUrl = resolveAuthExternalUrl(document, projectEnvValues); + const authExternalUrl = legacyResolveAuthExternalUrl(document, projectEnvValues); return { apiUrl: values.apiUrl, @@ -690,7 +623,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 `lib/health-check.ts` + * to a warning. That decision belongs to this caller, not `../../shared/db-bootstrap/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. @@ -707,7 +640,6 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const dbConnection = yield* LegacyDbConnection; const runtimeInfo = yield* RuntimeInfo; yield* Effect.gen(function* () { @@ -898,7 +830,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // whenever `start` reused an existing volume. Called here purely for its validation side // effect and discarded — `legacyStartSetupLocalDatabase`'s own internal call (an already- // accepted duplicate config-load pass, matching `db start`'s own independent resolution — see - // `lib/db-setup.ts`'s header) still resolves the real value for its own use when it runs. + // `../../shared/db-bootstrap/db-setup.ts`'s header) still resolves the real value for its own use when it runs. yield* legacyCheckDbToml(fs, path, cliConfig.workdir).pipe(Effect.asVoid); const dbContainerId = localDbContainerId(projectId); @@ -1072,112 +1004,52 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta }), }); - // Go's `Config.Load` folds `SUPABASE_DB_MAJOR_VERSION` into - // `c.Db.MajorVersion` before the image-selection switch runs - // (`pkg/config/config.go:585-586,819-827`), and every later Go read of - // `utils.Config.Db.MajorVersion` — image, version-pin gating, the - // PG14/PG15+ branch, migration-job selection — sees that SAME - // already-overridden value. `legacyResolveLocalConfigValues` already - // computed/validated this exact value above; recomputing it here (rather - // than threading it out of `values`) matches this file's own precedent - // for the realtime/storage/auth `enabled` overrides a few dozen lines - // below. - const majorVersion = legacyEnvOverrideMajorVersion(config.db.major_version, projectEnvValues); - // Same treatment as `majorVersion` above, for the sibling + // Same treatment as `majorVersion` below, for the sibling // `edge_runtime.deno_version` -> `Config.EdgeRuntime.Image` switch // (`pkg/config/config.go:1164-1173`), applied before `Validate` at the - // end of `Config.Load` (`config.go:882`). + // end of `Config.Load` (`config.go:882`). Start-only (Edge Runtime has no + // `db start` equivalent), so it stays outside the shared bootstrap-config + // derivation below. const denoVersion = legacyEnvOverrideDenoVersion( config.edge_runtime.deno_version, projectEnvValues, ); - // Same generic-Viper-override gap as `majorVersion`/`denoVersion` above, - // for `experimental.orioledb_version` -> `Config.Db.Image` rewrite - // (`pkg/config/config.go:1041-1046`), applied at the end of `Config.Load` - // (`config.go:882`) before `start` reads it. Also threaded into the - // Postgres container spec below, since `postgres.service.ts`'s - // `legacyPostgresExtraEnv` reads this same field to decide whether to add - // the S3/`POSTGRES_INITDB_ARGS` env vars — both consumers must agree. - const orioledbVersion = legacyEnvOverride( - "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", - config.experimental.orioledb_version, - projectEnvValues, - ); - // Same generic-Viper-override gap as `orioledbVersion` above, for its four - // sibling S3 fields Go reads into the Postgres container's `S3_*` env - // alongside `orioledb_version` (`apps/cli-go/internal/db/start/ - // start.go:70-77`) — also threaded into the Postgres container spec - // below, since `legacyPostgresExtraEnv` reads these same fields raw. - const s3Host = legacyEnvOverride( - "SUPABASE_EXPERIMENTAL_S3_HOST", - config.experimental.s3_host, - projectEnvValues, - ); - // Go's one-shot fresh-DB setup jobs (`initSchema15`) read `utils.Config. - // {Realtime,Storage,Auth}.Enabled` — the EFFECTIVE, env-overridden value — and run - // regardless of `--exclude` (`internal/db/start/start.go:270,299,321`) WHENEVER they - // actually execute (see the `isFreshVolume`/`majorVersion >= 15` gate further down, - // where these booleans also gate that conditional image resolve). Hoisted here - // (rather than recomputed only inside the `isFreshVolume` block below) since - // `legacyStartSetupLocalDatabase`'s own config object a few hundred lines down also - // needs them, and computing them once keeps both consumers in agreement. - const realtimeEnabledForSetup = legacyEnvOverrideBool( - "SUPABASE_REALTIME_ENABLED", - config.realtime.enabled, - "realtime.enabled", - projectEnvValues, - ); - const storageEnabledForSetup = legacyEnvOverrideBool( - "SUPABASE_STORAGE_ENABLED", - config.storage.enabled, - "storage.enabled", - projectEnvValues, - ); - const authEnabledForSetup = legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, - ); - const s3Region = legacyEnvOverride( - "SUPABASE_EXPERIMENTAL_S3_REGION", - config.experimental.s3_region, - projectEnvValues, - ); - const s3AccessKey = legacyEnvOverride( - "SUPABASE_EXPERIMENTAL_S3_ACCESS_KEY", - config.experimental.s3_access_key, - projectEnvValues, - ); - const s3SecretKey = legacyEnvOverride( - "SUPABASE_EXPERIMENTAL_S3_SECRET_KEY", - config.experimental.s3_secret_key, - projectEnvValues, - ); - // 7. Resolve every image that will actually be pulled (Go's - // `ensureImagesCached`, `start.go:225-262,289`) BEFORE any container is - // created. - const postgresImage = yield* legacyResolveDbImage( - fs, - path, - cliConfig.workdir, + // Every field Go's `StartDatabase` (`internal/db/start/start.go:133-190`) needs + // already resolved on `utils.Config` — major version, orioledb/S3 overrides, the + // fresh-DB setup jobs' own `enabled`/`ip_version`/`max_header_length`/ + // `file_size_limit` overrides, the Postgres image + linked-service version pins, + // `db.health_timeout`, and the Storage migration pin. Shared with `db start`'s own + // native container bootstrap (`legacyStartDatabase`, `legacy/shared/db-bootstrap/ + // start-database.ts`) — see `bootstrap-config.ts`'s own header for exactly why this + // is a single TS home instead of two independently-drifting copies. + const { majorVersion, orioledbVersion, - ); - // Go's `Config.Load` rewrites `c.Auth.Image`/`c.Api.Image`/etc. from - // `supabase/.temp/{gotrue,rest,storage,realtime,studio,pgmeta,logflare, - // pooler}-version` (linked-project pins written by `supabase link`) - // BEFORE `start` ever reads them (`pkg/config/config.go:827-863`) — read - // once, reused by both the image plan below and the fresh-DB one-shot - // setup jobs' images, which Go resolves from the same already-rewritten - // `utils.Config.*.Image` fields regardless of `--exclude`. - const serviceVersionOverrides = yield* legacyReadServiceVersionOverrides( + s3Host, + s3Region, + s3AccessKey, + s3SecretKey, + realtimeEnabledForSetup, + storageEnabledForSetup, + authEnabledForSetup, + realtimeIpVersion, + realtimeMaxHeaderLength, + storageFileSizeLimit, + postgresImage, + serviceVersionOverrides, + dbHealthTimeoutSeconds, + storageTargetMigration, + } = yield* legacyResolveDbBootstrapConfig( fs, path, - cliConfig.workdir, - majorVersion, + { config, projectEnvValues, workdir: cliConfig.workdir }, + (message) => new LegacyStartInvalidConfigError({ message }), ); + + // 7. Resolve every image that will actually be pulled (Go's + // `ensureImagesCached`, `start.go:225-262,289`) BEFORE any container is + // created. const imagePlan = legacyResolveStartImagePlan(gates, serviceVersionOverrides); // Edge Runtime doesn't go through `legacyResolveStartImagePlan` (see // `start.gates.ts`'s header) — its default image is resolved independently, @@ -1250,20 +1122,6 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta ) : new Set(); - // Go's `config.Load` reads `supabase/.temp/storage-migration` (written by - // `supabase link`) into `Config.Storage.TargetMigration` whenever present - // (`pkg/config/config.go:844-846`), and that value feeds - // `DB_MIGRATIONS_FREEZE_AT` for both the Storage container and the - // fresh-DB one-shot Storage migrate job. Any read error (including - // not-exist) or blank content resolves to "", matching Go's `err == nil - // && len(version) > 0` gate. - const storageTargetMigration = yield* fs - .readFileString(legacyTempPaths(path, cliConfig.workdir).storageMigration) - .pipe( - Effect.map((content) => content.trim()), - Effect.orElseSucceed(() => ""), - ); - // 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`). @@ -1383,41 +1241,6 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta ); } - // Same generic-Viper-override gap as `apiTlsEnabled` above, for Realtime's - // two `SUPABASE_REALTIME_*` fields — both the Realtime container spec - // below AND the PG15+ Realtime setup job (`legacyStartSetupLocalDatabase`, - // via the `realtime` splice further down) must see the SAME - // already-overridden values, matching Go's single `utils.Config.Realtime` - // source of truth (`internal/start/start.go:922,928`, - // `internal/db/start/start.go:283,290`). - const realtimeIpVersion = yield* wrapConfigOverride("realtime.ip_version", () => - legacyEnvOverrideRealtimeIpVersion(config.realtime.ip_version, projectEnvValues), - ); - const realtimeMaxHeaderLength = yield* wrapConfigOverride("realtime.max_header_length", () => - legacyEnvOverrideRealtimeMaxHeaderLength(config.realtime.max_header_length, projectEnvValues), - ); - - // Same gap for Storage's file-size limit — both the long-running - // container below AND the one-shot storage migrate job - // (`legacyStartSetupLocalDatabase`, via the `storage` splice further - // down) must see the same already-overridden value (Go's - // `internal/start/start.go:1004`, `internal/db/start/start.go:307`, both - // reading the single `utils.Config.Storage.FileSizeLimit`). - const storageFileSizeLimit = - legacyEnvOverride( - "SUPABASE_STORAGE_FILE_SIZE_LIMIT", - config.storage.file_size_limit, - projectEnvValues, - ) ?? config.storage.file_size_limit; - // `@supabase/config`'s schema accepts `file_size_limit` as a plain string — it does not parse - // the size grammar itself, so a malformed value (e.g. "foobar") would otherwise only surface - // when `storage.service.ts`'s `ramInBytes` call builds the container env, well after - // network/image/Postgres work, and never at all when Storage is excluded/disabled. Go's - // `sizeInBytes.UnmarshalText` (`pkg/config/config.go:39-49`) decodes this unconditionally in - // the same `Config.Load` pass as everything else (`config.go:749-756,775-784`), before `start` - // touches Docker or looks at `--exclude` — validate eagerly here to match, discarding the - // parsed byte count since every consumer re-parses `storageFileSizeLimit` itself. - yield* wrapConfigOverride("storage.file_size_limit", () => ramInBytes(storageFileSizeLimit)); // Same gap for `storage.vector.enabled` — both the long-running Storage // container AND `legacySeedBucketsRun`'s `effectiveLocalStorageConfig` // splice further down must see the same already-overridden value (Go's @@ -1623,30 +1446,8 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta legacyEnvOverrideMaxClientConn(config.db.pooler.max_client_conn, projectEnvValues), ); - // Overridden by SUPABASE_DB_HEALTH_TIMEOUT — Go's Config.Load binds this - // generically before StartDatabase's health wait reads it - // (pkg/config/config.go:580-586, internal/db/start/start.go:180). Resolved - // here, before any Docker work, rather than inside `bringUp` right before the - // health wait: Go's `mapstructure.StringToTimeDurationHookFunc()` decodes this - // in the same unconditional `Config.Load` pass as every other field - // (`pkg/config/config.go:749-756,777`), which runs before `start.Run` touches - // Docker at all (`internal/start/start.go:51,73`) — a malformed value must - // fail before network/image/Postgres work, not after Postgres's own container - // has already been created and started. - const dbHealthTimeout = legacyEnvOverride( - "SUPABASE_DB_HEALTH_TIMEOUT", - config.db.health_timeout, - projectEnvValues, - ); - const dbHealthTimeoutSeconds = yield* Effect.try({ - try: () => resolveDbHealthTimeoutSeconds(dbHealthTimeout ?? config.db.health_timeout), - catch: (cause) => - new LegacyStartInvalidConfigError({ - message: `failed to parse config: ${cause instanceof Error ? cause.message : String(cause)}`, - }), - }); - - // Same bug class as `dbHealthTimeoutSeconds` above: Go decodes `edge_runtime.policy` + // Same bug class as `dbHealthTimeoutSeconds` (now resolved by the shared + // `legacyResolveDbBootstrapConfig` call above): Go decodes `edge_runtime.policy` // (an enum via `UnmarshalText`) and `edge_runtime.inspector_port` (a plain `uint`) during // the same unconditional `Config.Load` pass (`pkg/config/config.go:749-756,777`), before // `start.Run` touches Docker — regardless of `--exclude edge-runtime`. The Edge Runtime @@ -1962,77 +1763,120 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // falls through to that SAME unconditional tail (`start.go:74-87`) rather // than returning early from the whole command. const bringUp = Effect.gen(function* () { - yield* legacyEnsureStartNetwork(spawner, networkId, { - [LEGACY_CLI_PROJECT_LABEL]: projectId, - [LEGACY_COMPOSE_PROJECT_LABEL]: projectId, - }); - - // Go's pre-create volume-existence check (`internal/db/start/start.go: - // 165-167`) — MUST run before Postgres's own volume gets created by - // `legacyStartContainer` below: `docker volume create` is idempotent, so - // creating first would make "did this volume already exist" unobservable. - isFreshVolume = !(yield* legacyStartVolumeExists(spawner, dbContainerId)); - if (output.format === "text") { - yield* output.raw( - isFreshVolume - ? LEGACY_START_STARTING_DATABASE_MESSAGE - : LEGACY_START_STARTING_DATABASE_FROM_BACKUP_MESSAGE, - "stderr", - ); - } - - const postgresSpec = legacyBuildPostgresStartContainerSpec({ - // `port` overridden by SUPABASE_DB_PORT (Go's NewHostConfig binds the - // published port straight from the already-overridden - // utils.Config.Db.Port, apps/cli-go/internal/db/start/start.go:119-121). - // `settings` overridden by any `SUPABASE_DB_SETTINGS_*` field (Go's - // `(a *settings) ToPostgresConfig()` serializes the same - // already-overridden global `Config.Db.Settings`, `pkg/config/db.go:181-190`). - db: { - ...config.db, - port: values.dbPort, - major_version: majorVersion, - settings: legacyResolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues), - }, - // `orioledb_version` overridden by SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION, - // matching the value already used to select `postgresImage` above — - // `legacyPostgresExtraEnv` reads this same field, and its four sibling - // S3 fields, for its S3/`POSTGRES_INITDB_ARGS` branch. - experimental: { - ...config.experimental, - orioledb_version: orioledbVersion, - s3_host: s3Host, - s3_region: s3Region, - s3_access_key: s3AccessKey, - s3_secret_key: s3SecretKey, - }, - jwtSecret: values.jwtSecret, - // Overridden by SUPABASE_AUTH_JWT_EXPIRY — Postgres's JWT_EXP (seeding - // app.settings.jwt_exp) and GoTrue's GOTRUE_JWT_EXP both read the same - // already-overridden utils.Config.Auth.JwtExpiry in Go - // (internal/db/start/start.go:68, internal/start/start.go:1372); using - // the raw config value here would let Postgres and GoTrue disagree. - jwtExpiry: values.authJwtExpiry, + // Runs the exact Go `StartDatabase` sequence (network -> volume probe -> container + // create+start -> health wait -> fresh-volume setup -> `_current_branch`) — shared + // with `db start`'s own native container bootstrap, see `legacyStartDatabase`'s own + // header (`legacy/shared/db-bootstrap/start-database.ts`) for the full call order and + // for why this function has zero knowledge of `--ignore-health-check`: that decision + // belongs entirely to THIS caller, immediately below, matching Go's real function + // boundary (`internal/start/start.go`'s `Run()` vs `internal/db/start/start.go`'s + // `StartDatabase`). + const dbBootstrapResult = yield* legacyStartDatabase(spawner, { + fs, + path, + workdir: cliConfig.workdir, projectId, networkId, - image: resolveImage(postgresImage), - configImage: postgresImage, - rootKey: values.rootKey, - }); - yield* legacyStartContainer(spawner, postgresSpec, startOpts); - // `dbHealthTimeoutSeconds` is resolved eagerly, before any Docker work — see its - // definition above, alongside the other eagerly-validated config-override fields. - // Watched by container name, matching Go's `utils.DbId`. - const postgresHealthResult = yield* legacyWaitForHealthyServices( - spawner, - [postgresSpec.containerName], - { - timeoutSeconds: dbHealthTimeoutSeconds, - images: new Map([[postgresSpec.containerName, postgresSpec.image]]), + hostname: context.hostname, + dbContainerId, + dbPort: values.dbPort, + containerOpts: startOpts, + postgresSpec: { + // `port` overridden by SUPABASE_DB_PORT (Go's NewHostConfig binds the + // published port straight from the already-overridden + // utils.Config.Db.Port, apps/cli-go/internal/db/start/start.go:119-121). + // `settings` overridden by any `SUPABASE_DB_SETTINGS_*` field (Go's + // `(a *settings) ToPostgresConfig()` serializes the same + // already-overridden global `Config.Db.Settings`, `pkg/config/db.go:181-190`). + db: { + ...config.db, + port: values.dbPort, + major_version: majorVersion, + settings: legacyResolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues), + }, + // `orioledb_version` overridden by SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION, + // matching the value already used to select `postgresImage` above — + // `legacyPostgresExtraEnv` reads this same field, and its four sibling + // S3 fields, for its S3/`POSTGRES_INITDB_ARGS` branch. + experimental: { + ...config.experimental, + orioledb_version: orioledbVersion, + s3_host: s3Host, + s3_region: s3Region, + s3_access_key: s3AccessKey, + s3_secret_key: s3SecretKey, + }, + jwtSecret: values.jwtSecret, + // Overridden by SUPABASE_AUTH_JWT_EXPIRY — Postgres's JWT_EXP (seeding + // app.settings.jwt_exp) and GoTrue's GOTRUE_JWT_EXP both read the same + // already-overridden utils.Config.Auth.JwtExpiry in Go + // (internal/db/start/start.go:68, internal/start/start.go:1372); using + // the raw config value here would let Postgres and GoTrue disagree. + jwtExpiry: values.authJwtExpiry, + projectId, + networkId, + configImage: postgresImage, + rootKey: values.rootKey, + // `fromBackup` stays unset: `supabase start` always calls `StartDatabase` with an + // empty `fromBackup` (`apps/cli-go/internal/start/start.go:295`) — only `db start` + // ever sets it. + }, + // Already resolved as part of THIS run's own batched pre-pull (`resolvedImages`, + // above) — `supabase start` has no per-container lazy resolve of its own, unlike + // `db start` (see `legacyStartDatabase`'s header for why this is caller-supplied). + resolvePostgresImage: Effect.succeed(resolveImage(postgresImage)), + dbHealthTimeoutSeconds, + setup: { + majorVersion, + // Go's `initSchema15`'s per-job gates read `utils.Config.{Realtime,Storage,Auth}. + // Enabled` — the EFFECTIVE, env-overridden value (Viper's `AutomaticEnv` already + // folds any `SUPABASE_*_ENABLED` override into the single global `Config`), NOT + // additionally filtered by `--exclude` the way `gates.*` is (Go's one-shot + // migration jobs run regardless of `--exclude` — they're part of `StartDatabase`, + // which finishes before `run()`'s own excluded-services filtering even begins). + config: { + ...config, + realtime: { + ...config.realtime, + enabled: realtimeEnabledForSetup, + ip_version: realtimeIpVersion, + max_header_length: realtimeMaxHeaderLength, + }, + storage: { + ...config.storage, + enabled: storageEnabledForSetup, + file_size_limit: storageFileSizeLimit, + }, + auth: { + ...config.auth, + enabled: authEnabledForSetup, + }, + }, + dbUrl: values.dbUrl, + jwtSecret: values.jwtSecret, + // Already resolved, unconditionally, near the top of THIS handler's own prelude + // (feeding the long-running Realtime/GoTrue/PostgREST containers too) — reused + // here rather than re-resolved, see `legacyStartDatabase`'s header for why. + jwks: Effect.succeed(jwks), + apiUrl: values.apiUrl, + authExternalUrl: legacyResolveAuthExternalUrl(context.loaded?.document, projectEnvValues), + siteUrl: values.authSiteUrl, + anonKey: values.anonKey, + serviceRoleKey: values.serviceRoleKey, + storageTargetMigration, + realtimeEnabledForSetup, + storageEnabledForSetup, + authEnabledForSetup, + serviceVersionOverrides, + projectEnvValues, + }, + onFreshVolumeResolved: (resolved) => { + isFreshVolume = resolved; }, - ).pipe(Effect.result); - if (Result.isFailure(postgresHealthResult)) { - const error = postgresHealthResult.failure; + }).pipe(Effect.result); + + if (Result.isFailure(dbBootstrapResult)) { + const error = dbBootstrapResult.failure; if (flags.ignoreHealthCheck && legacyIsUnhealthyStartError(error)) { // Go's outer `Run()` check (`ignoreHealthCheck && // IsUnhealthyError(err)`, `start.go:74-75`) applies uniformly to @@ -2052,128 +1896,6 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta return yield* Effect.fail(error); } - // Go's `if utils.NoBackupVolume { SetupLocalDatabase(...) }` (`db/start/ - // start.go:184-188`) — runs immediately after Postgres's OWN health wait, - // BEFORE "Starting containers..." prints and before any other service - // starts: `internal/start/start.go:293-298` calls `StartDatabase` (which - // performs this whole sequence internally) before any other service's own - // `if` block even runs. - if (isFreshVolume) { - yield* Effect.scoped( - Effect.gen(function* () { - const session = yield* dbConnection.connect( - { - host: context.hostname, - port: values.dbPort, - user: "postgres", - password: dbPassword, - database: "postgres", - }, - { isLocal: true, dnsResolver: "native" }, - ); - // 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 use (`internal/db/start/ - // start.go:270,299,321`), regardless of `--exclude` — resolve through - // `legacyResolvePinnedImage` (not the raw Dockerfile default) so a - // linked project's version pins apply here too. Resolved HERE, inside - // this `isFreshVolume` block and additionally gated on `majorVersion - // >= 15` (this schema-init path is PG15+-only, `db-setup.ts`'s own - // `majorVersion <= 14` branch never reads `images`) — NOT pre-pulled - // unconditionally up front, matching Go's own `ensureImagesCached` - // (`start.go:237-262`), which only pre-pulls non-excluded services; - // these images are resolved lazily inside `initSchema15` itself - // (`DockerStart` -> `DockerResolveImageIfNotCached`, - // `internal/utils/docker.go:363-365`), only when the job genuinely - // runs. Still resolved through the SAME `projectEnvValues`-aware - // `legacyEnsureImagesCached` used above (not the raw image string) so - // a project-dotenv-only `SUPABASE_INTERNAL_IMAGE_REGISTRY` override - // still applies whenever the job WILL run. - const rawSetupJobImages = { - realtime: legacyResolvePinnedImage("realtime", "realtime", serviceVersionOverrides), - storage: legacyResolvePinnedImage("storage", "storage", serviceVersionOverrides), - auth: legacyResolvePinnedImage("gotrue", "auth", serviceVersionOverrides), - }; - const setupJobImagesToResolve = - majorVersion >= 15 - ? [ - ...(realtimeEnabledForSetup ? [rawSetupJobImages.realtime] : []), - ...(storageEnabledForSetup ? [rawSetupJobImages.storage] : []), - ...(authEnabledForSetup ? [rawSetupJobImages.auth] : []), - ] - : []; - const resolvedSetupJobImages = - setupJobImagesToResolve.length > 0 - ? yield* legacyEnsureImagesCached( - spawner, - setupJobImagesToResolve, - 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, - path, - workdir: cliConfig.workdir, - // Go's `initSchema15`'s per-job gates read `utils.Config. - // {Realtime,Storage,Auth}.Enabled` — the EFFECTIVE, env-overridden - // value (Viper's `AutomaticEnv` already folds any `SUPABASE_*_ - // ENABLED` override into the single global `Config`), NOT - // additionally filtered by `--exclude` the way `gates.*` is (Go's - // one-shot migration jobs run regardless of `--exclude` — they're - // part of `StartDatabase`, which finishes before `run()`'s own - // excluded-services filtering even begins). Reuses - // `{realtime,storage,auth}EnabledForSetup`, hoisted above (also - // needed by `setupJobImages`) instead of recomputing them here. - config: { - ...config, - realtime: { - ...config.realtime, - enabled: realtimeEnabledForSetup, - ip_version: realtimeIpVersion, - max_header_length: realtimeMaxHeaderLength, - }, - storage: { - ...config.storage, - enabled: storageEnabledForSetup, - file_size_limit: storageFileSizeLimit, - }, - auth: { - ...config.auth, - enabled: authEnabledForSetup, - }, - }, - majorVersion, - projectId, - networkId, - dbUrl: values.dbUrl, - jwtSecret: values.jwtSecret, - jwks, - apiUrl: values.apiUrl, - authExternalUrl: resolveAuthExternalUrl(context.loaded?.document, projectEnvValues), - siteUrl: values.authSiteUrl, - anonKey: values.anonKey, - serviceRoleKey: values.serviceRoleKey, - storageTargetMigration, - images: dbSetupImages, - }); - }), - ); - } - - // Go's `initCurrentBranch` (`db/start/start.go:189`) runs on every start - // regardless of `isFreshVolume` — unlike `SetupLocalDatabase`, which only - // runs on a fresh volume. Moved out of `legacyStartSetupLocalDatabase` (see - // that module's own comment) so it isn't accidentally skipped on a restart. - yield* legacyStartInitCurrentBranch(fs, path, cliConfig.workdir); - if (output.format === "text") { yield* output.raw(LEGACY_START_STARTING_CONTAINERS_MESSAGE, "stderr"); } 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 93aa29ac5e..7016f07643 100644 --- a/apps/cli/src/legacy/commands/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts @@ -3201,7 +3201,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 (`lib/health-check.ts`'s default), hence the generous timeout. + // wait (`../../shared/db-bootstrap/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 d73a2c77d5..0dde5cdd17 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`, `start/lib/container-lifecycle.ts`), and Edge Runtime's own +(`legacyStageStartSecretFiles`, `legacy/shared/db-bootstrap/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/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index ff8e8e5bfc..198789e49c 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -196,7 +196,7 @@ export const legacyStop = Effect.fn("legacy.stop")(function* (flags: LegacyStopF // LATER stages (volume prune, network prune) can still independently fail AFTER `container // prune` has already confirmed removal, and a plain `yield*` below would never run once that // later failure propagates — leaking staged secret directories for containers a later `stop` - // can no longer rediscover (they're already gone). `legacyRollbackStart` (`start.rollback.ts`) + // can no longer rediscover (they're already gone). `legacyRollbackStart` (`legacy/shared/db-bootstrap/rollback.ts`) // already runs this same cleanup unconditionally after its own `legacyDockerRemoveAll` call for // the identical reason; this makes `stop` consistent with that sibling caller, just without // swallowing the teardown error itself. The finalizer is wrapped in `Effect.suspend` so diff --git a/apps/cli/src/legacy/shared/db-bootstrap/bootstrap-config.ts b/apps/cli/src/legacy/shared/db-bootstrap/bootstrap-config.ts new file mode 100644 index 0000000000..0df16a5968 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/bootstrap-config.ts @@ -0,0 +1,294 @@ +/** + * The config-derivation prelude Go's `StartDatabase` (`apps/cli-go/internal/db/start/ + * start.go:133-190`) relies on being ALREADY resolved on `utils.Config` by the time it runs + * (Go's `Config.Load` folds every `SUPABASE_*` override into the single global `Config` + * struct once, at process start, before either `db start` or `supabase start`'s own `Run` + * ever executes) — every field here feeds either the Postgres container spec itself or the + * fresh-volume `SetupLocalDatabase`-equivalent pipeline. Shared by both callers of + * `legacyStartDatabase` (`./start-database.ts`) so a future Go change to one of these + * fields' derivation only needs to change in one TS home — see `apps/cli/CLAUDE.md`'s + * "Hoist Before You Duplicate" rule and CLI-1954's own report for why this was split out + * from `commands/start/start.handler.ts`. + * + * Deliberately NOT included here (stays each caller's own concern, since it's either + * `supabase start`-only or caller-timing-sensitive — see `start-database.ts`'s header): + * `--exclude` gate evaluation, JWKS resolution (`supabase start` resolves it once, eagerly, + * for its long-running containers too; `db start` resolves it lazily, conditionally, deep + * inside the fresh-volume setup step, matching Go's own `initSchema15`-local + * `ResolveJWKS` call — the two callers' timing genuinely differs, so `legacyStartDatabase` + * takes this as a caller-supplied `Effect` instead), the Postgres registry-image resolve + * (`db start` resolves lazily, per-container; `supabase start` already resolved it as part + * of its own batched pre-pull before bring-up — same caller-supplied-`Effect` treatment). + */ + +import type { ProjectConfig } from "@supabase/config"; +import { Effect, type FileSystem, type Path } from "effect"; + +import type { LocalServiceVersionOverrides } from "../../../shared/services/services.shared.ts"; +import { legacyResolveDbImage } from "../legacy-db-image.ts"; +import { legacyResolveHealthTimeoutSeconds } from "../legacy-go-duration.ts"; +import { + legacyEnvOverride, + legacyEnvOverrideBool, + legacyEnvOverrideMajorVersion, + legacyEnvOverrideRealtimeIpVersion, + legacyEnvOverrideRealtimeMaxHeaderLength, +} from "../legacy-local-config-values.ts"; +import { legacyReadServiceVersionOverrides } from "../legacy-service-version-overrides.ts"; +import { ramInBytes } from "../legacy-size-units.ts"; +import { legacyTempPaths } from "../legacy-temp-paths.ts"; + +export interface LegacyDbBootstrapConfigInput { + readonly config: ProjectConfig; + readonly projectEnvValues: Readonly> | undefined; + readonly workdir: string; +} + +export interface LegacyDbBootstrapConfig { + readonly majorVersion: number; + readonly orioledbVersion: string | undefined; + readonly s3Host: string | undefined; + readonly s3Region: string | undefined; + readonly s3AccessKey: string | undefined; + readonly s3SecretKey: string | undefined; + /** Go's one-shot fresh-DB setup jobs' own `Enabled` gates — see `start-database.ts`'s header. */ + readonly realtimeEnabledForSetup: boolean; + readonly storageEnabledForSetup: boolean; + readonly authEnabledForSetup: boolean; + readonly realtimeIpVersion: "IPv4" | "IPv6"; + readonly realtimeMaxHeaderLength: number; + readonly storageFileSizeLimit: ProjectConfig["storage"]["file_size_limit"]; + /** Pre-registry-resolution image reference (`utils.Config.Db.Image`) — the caller still resolves the registry candidate itself, see this module's header. */ + readonly postgresImage: string; + readonly serviceVersionOverrides: LocalServiceVersionOverrides; + readonly dbHealthTimeoutSeconds: number; + readonly storageTargetMigration: string; +} + +/** + * Wraps a synchronous `legacyEnvOverride*` read that throws on a malformed value into a + * typed failure, matching Go's `Config.Load` hard-failing on a bad Viper decode + * (`pkg/config/config.go:749-756`) before any Docker work — instead of leaking an untyped + * Effect defect. Message format (`invalid config for : `) matches + * `commands/start/start.handler.ts`'s own identically-shaped `wrapConfigOverride`, which + * this module doesn't import from (that one stays private to `start.handler.ts`, covering + * many more start-only fields; duplicating this ~10-line generic wrapper avoids a + * `legacy/shared/` -> `legacy/commands/start/` dependency for a trivial utility). + */ +function wrapConfigOverride( + dottedFieldPath: string, + thunk: () => T, + mapConfigError: (message: string) => E, +): Effect.Effect { + return Effect.try({ + try: thunk, + catch: (cause) => + mapConfigError( + `invalid config for ${dottedFieldPath}: ${cause instanceof Error ? cause.message : String(cause)}`, + ), + }); +} + +/** + * Resolves every field {@link legacyStartDatabase} (`./start-database.ts`) needs from + * `config`/`projectEnvValues`, in Go's own `Config.Load` sense: values already folded from + * any `SUPABASE_*` override, ready to feed the Postgres container spec and the fresh-volume + * setup pipeline. `mapConfigError` lets each caller tag a malformed-override failure with + * its own command-specific error type — `db start` uses `LegacyDbConfigLoadError`; + * `supabase start` uses its own `LegacyStartInvalidConfigError`, matching the class its + * existing tests already assert for these fields — mirroring the `mapConfigLoadError` + * idiom `legacy-local-project-context.ts`'s `legacyLoadLocalProjectContext` already uses. + */ +export const legacyResolveDbBootstrapConfig = ( + fs: FileSystem.FileSystem, + path: Path.Path, + input: LegacyDbBootstrapConfigInput, + mapConfigError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + const { config, projectEnvValues, workdir } = input; + + // Go's `Config.Load` folds `SUPABASE_DB_MAJOR_VERSION` into `c.Db.MajorVersion` before the + // image-selection switch runs (`pkg/config/config.go:585-586,819-827`) — every later read of + // `utils.Config.Db.MajorVersion` sees this same value. Not wrapped: `legacyCheckDbToml` + // (called by both callers before this function) already validates this override. + const majorVersion = legacyEnvOverrideMajorVersion(config.db.major_version, projectEnvValues); + // `experimental.orioledb_version` -> `Config.Db.Image` rewrite (`pkg/config/config.go: + // 1041-1046`), plus its four sibling S3 fields Go reads into the Postgres container's `S3_*` + // env alongside it (`apps/cli-go/internal/db/start/start.go:70-77`). Both `legacyEnvOverride` + // calls never throw (return the override or the configured value verbatim), so no wrap needed. + const orioledbVersion = legacyEnvOverride( + "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", + config.experimental.orioledb_version, + projectEnvValues, + ); + const s3Host = legacyEnvOverride( + "SUPABASE_EXPERIMENTAL_S3_HOST", + config.experimental.s3_host, + projectEnvValues, + ); + const s3Region = legacyEnvOverride( + "SUPABASE_EXPERIMENTAL_S3_REGION", + config.experimental.s3_region, + projectEnvValues, + ); + const s3AccessKey = legacyEnvOverride( + "SUPABASE_EXPERIMENTAL_S3_ACCESS_KEY", + config.experimental.s3_access_key, + projectEnvValues, + ); + const s3SecretKey = legacyEnvOverride( + "SUPABASE_EXPERIMENTAL_S3_SECRET_KEY", + config.experimental.s3_secret_key, + projectEnvValues, + ); + + // Go's one-shot fresh-DB setup jobs (`initSchema15`) read `utils.Config. + // {Realtime,Storage,Auth}.Enabled` — the EFFECTIVE, env-overridden value — and run + // regardless of `--exclude` (`internal/db/start/start.go:270,299,321`) whenever they + // actually execute. `supabase start` also reads the SAME override for its own `gates.*` + // (`start.gates.ts`'s `legacyResolveStartGates`, wrapped there too) — this wrap is + // harmless, redundant belt-and-suspenders for that caller, and the ONLY protection `db + // start` has (it has no `--exclude`/`gates` equivalent at all). + const realtimeEnabledForSetup = yield* wrapConfigOverride( + "realtime.enabled", + () => + legacyEnvOverrideBool( + "SUPABASE_REALTIME_ENABLED", + config.realtime.enabled, + "realtime.enabled", + projectEnvValues, + ), + mapConfigError, + ); + const storageEnabledForSetup = yield* wrapConfigOverride( + "storage.enabled", + () => + legacyEnvOverrideBool( + "SUPABASE_STORAGE_ENABLED", + config.storage.enabled, + "storage.enabled", + projectEnvValues, + ), + mapConfigError, + ); + const authEnabledForSetup = yield* wrapConfigOverride( + "auth.enabled", + () => + legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLED", + config.auth.enabled, + "auth.enabled", + projectEnvValues, + ), + mapConfigError, + ); + + // Both the long-running Realtime container (`supabase start` only) AND the PG15+ one-shot + // Realtime setup job (both callers, via `legacyStartSetupLocalDatabase`) must see the SAME + // already-overridden values (Go's single `utils.Config.Realtime` source of truth, + // `internal/start/start.go:922,928`, `internal/db/start/start.go:283,290`). + const realtimeIpVersion = yield* wrapConfigOverride( + "realtime.ip_version", + () => legacyEnvOverrideRealtimeIpVersion(config.realtime.ip_version, projectEnvValues), + mapConfigError, + ); + const realtimeMaxHeaderLength = yield* wrapConfigOverride( + "realtime.max_header_length", + () => + legacyEnvOverrideRealtimeMaxHeaderLength( + config.realtime.max_header_length, + projectEnvValues, + ), + mapConfigError, + ); + + // Same reasoning for Storage's file-size limit — both the long-running container + // (`supabase start` only) AND the one-shot storage migrate job (both callers) must see the + // same already-overridden value (Go's `internal/start/start.go:1004`, `internal/db/start/ + // start.go:307`, both reading the single `utils.Config.Storage.FileSizeLimit`). + // `@supabase/config`'s schema accepts `file_size_limit` as a plain string — it does not parse + // the size grammar itself, so a malformed value must be validated eagerly here (Go's + // `sizeInBytes.UnmarshalText`, `pkg/config/config.go:39-49`, decodes it unconditionally during + // `Config.Load`, before either caller touches Docker) rather than left to surface only when a + // container env builder happens to re-parse it. + const storageFileSizeLimit = + legacyEnvOverride( + "SUPABASE_STORAGE_FILE_SIZE_LIMIT", + config.storage.file_size_limit, + projectEnvValues, + ) ?? config.storage.file_size_limit; + yield* wrapConfigOverride( + "storage.file_size_limit", + () => ramInBytes(storageFileSizeLimit), + mapConfigError, + ); + + // Go's `Config.Load` rewrites `c.Db.Image` from `supabase/.temp/postgres-version` (a + // linked-project pin written by `supabase link`) BEFORE either caller reads it + // (`pkg/config/config.go:827-863`) — never fails (a missing/unreadable pin file resolves to + // the embedded default), so no wrap needed. + const postgresImage = yield* legacyResolveDbImage( + fs, + path, + workdir, + majorVersion, + orioledbVersion, + ); + // Ditto for `c.Realtime.Image`/`c.Storage.Image`/`c.Auth.Image` — read once, reused by the + // fresh-DB one-shot setup jobs' images regardless of whether this run's volume turns out to + // be fresh at all. Never fails, same reasoning. + const serviceVersionOverrides = yield* legacyReadServiceVersionOverrides( + fs, + path, + workdir, + majorVersion, + ); + + // Overridden by SUPABASE_DB_HEALTH_TIMEOUT — Go's Config.Load binds this generically before + // StartDatabase's health wait reads it (pkg/config/config.go:580-586, internal/db/start/ + // start.go:180). + const dbHealthTimeout = legacyEnvOverride( + "SUPABASE_DB_HEALTH_TIMEOUT", + config.db.health_timeout, + projectEnvValues, + ); + const dbHealthTimeoutSeconds = yield* Effect.try({ + try: () => legacyResolveHealthTimeoutSeconds(dbHealthTimeout ?? config.db.health_timeout), + catch: (cause) => + mapConfigError( + `failed to parse config: ${cause instanceof Error ? cause.message : String(cause)}`, + ), + }); + + // Go's `config.Load` reads `supabase/.temp/storage-migration` (written by `supabase link`) + // into `Config.Storage.TargetMigration` whenever present (`pkg/config/config.go:844-846`), + // feeding `DB_MIGRATIONS_FREEZE_AT` for the fresh-DB one-shot Storage migrate job. Any read + // error (including not-exist) or blank content resolves to "", matching Go's `err == nil && + // len(version) > 0` gate — never fails. + const storageTargetMigration = yield* fs + .readFileString(legacyTempPaths(path, workdir).storageMigration) + .pipe( + Effect.map((content) => content.trim()), + Effect.orElseSucceed(() => ""), + ); + + return { + majorVersion, + orioledbVersion, + s3Host, + s3Region, + s3AccessKey, + s3SecretKey, + realtimeEnabledForSetup, + storageEnabledForSetup, + authEnabledForSetup, + realtimeIpVersion, + realtimeMaxHeaderLength, + storageFileSizeLimit, + postgresImage, + serviceVersionOverrides, + dbHealthTimeoutSeconds, + storageTargetMigration, + }; + }); diff --git a/apps/cli/src/legacy/commands/start/lib/container-lifecycle.ts b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts similarity index 98% rename from apps/cli/src/legacy/commands/start/lib/container-lifecycle.ts rename to apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts index 57af689868..500172df58 100644 --- a/apps/cli/src/legacy/commands/start/lib/container-lifecycle.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts @@ -18,19 +18,13 @@ import { join } from "node:path"; import { Data, Effect, Stream } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; -import { - legacyDescribeContainerCliFailure, - spawnContainerCli, -} from "../../../shared/legacy-container-cli.ts"; +import { legacyDescribeContainerCliFailure, spawnContainerCli } from "../legacy-container-cli.ts"; import { legacyBindMountSpecSource, legacyIsBindMountSource, -} from "../../../shared/legacy-docker-bind-classify.ts"; -import { - LEGACY_CLI_PROJECT_LABEL, - LEGACY_CLI_WORKDIR_LABEL, -} from "../../../shared/legacy-docker-ids.ts"; -import { isUserDefinedDockerNetwork } from "../../../../shared/functions/deploy.ts"; +} from "../legacy-docker-bind-classify.ts"; +import { LEGACY_CLI_PROJECT_LABEL, LEGACY_CLI_WORKDIR_LABEL } from "../legacy-docker-ids.ts"; +import { isUserDefinedDockerNetwork } from "../../../shared/functions/deploy.ts"; import { legacyBuildStartContainerCreateArgs, legacyApplyBitbucketStartContainerFilter, diff --git a/apps/cli/src/legacy/commands/start/lib/container-lifecycle.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts similarity index 100% rename from apps/cli/src/legacy/commands/start/lib/container-lifecycle.unit.test.ts rename to apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts diff --git a/apps/cli/src/legacy/commands/start/lib/db-setup.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts similarity index 94% rename from apps/cli/src/legacy/commands/start/lib/db-setup.ts rename to apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts index c9722c8cdd..305b9c1f67 100644 --- a/apps/cli/src/legacy/commands/start/lib/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -20,7 +20,7 @@ * `docker.go:379-383`), each gated on its own service's `enabled` flag and none * of which touch `conn` directly: * - `initRealtimeJob` (`start.go:268-295`) — reuses - * `../services/realtime.service.ts`'s `legacyBuildRealtimeEnv`, which builds + * `./realtime-env.ts`'s `legacyBuildRealtimeEnv`, which builds * the byte-identical env-var literal Go's own `initRealtimeJob` embeds * verbatim (both are the same Go `Env` list, just addressed from two call * sites: the long-running container and this one-shot job). @@ -65,39 +65,30 @@ * 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 already takes - * independently of the top-level `start` command's own config resolution (see - * `commands/db/start/start.handler.ts:40`). + * 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. */ import type { ProjectConfig } from "@supabase/config"; import { Data, Effect, type FileSystem, Option, type Path } from "effect"; -import { Output } from "../../../../shared/output/output.service.ts"; -import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; -import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; -import { LegacyDbConfigLoadError } from "../../../shared/legacy-db-config.errors.ts"; -import { legacyCheckDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; -import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; -import { - LegacyDockerRun, - type LegacyDockerRunOpts, -} from "../../../shared/legacy-docker-run.service.ts"; -import { legacyMigrateAndSeed } from "../../../shared/legacy-migrate-and-seed.ts"; -import { - LegacyMigrationApplyError, - legacyExecSqlFile, -} from "../../../shared/legacy-migration-apply.ts"; -import type { LegacyMigrationSeedError } from "../../../shared/legacy-seed.ts"; -import { ramInBytes } from "../../../shared/legacy-size-units.ts"; -import { - LegacyMigrationVaultError, - legacyUpsertVaultSecrets, -} from "../../../shared/legacy-vault.ts"; -import { LEGACY_REALTIME_TENANT_ID, legacyBuildRealtimeEnv } from "../services/realtime.service.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"; -import { LEGACY_START_DB_INITIAL_SCHEMA_14_SQL } from "../templates/db-initial-schema-14.sql.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 { LegacyDbConfigLoadError } from "../legacy-db-config.errors.ts"; +import { legacyCheckDbToml } 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 { ramInBytes } from "../legacy-size-units.ts"; +import { LegacyMigrationVaultError, legacyUpsertVaultSecrets } from "../legacy-vault.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"; +import { LEGACY_START_DB_INITIAL_SCHEMA_14_SQL } from "./templates/db-initial-schema-14.sql.ts"; import { legacyStartInternalDbPassword, legacyStartInternalDbUrl, diff --git a/apps/cli/src/legacy/commands/start/lib/db-setup.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts similarity index 98% rename from apps/cli/src/legacy/commands/start/lib/db-setup.unit.test.ts rename to apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts index 8f2decfc6f..7c5cfbcb88 100644 --- a/apps/cli/src/legacy/commands/start/lib/db-setup.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts @@ -7,13 +7,10 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path, Schema } from "effect"; -import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; -import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; -import { - LegacyDockerRun, - type LegacyDockerRunOpts, -} from "../../../shared/legacy-docker-run.service.ts"; -import { LegacyDockerRunError } from "../../../shared/legacy-docker-run.errors.ts"; +import { mockOutput, mockRuntimeInfo } from "../../../../tests/helpers/mocks.ts"; +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, legacyStartInitCurrentBranch, diff --git a/apps/cli/src/legacy/commands/start/lib/docker-create-args.ts b/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts similarity index 99% rename from apps/cli/src/legacy/commands/start/lib/docker-create-args.ts rename to apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts index ba559f47ad..349fb8e901 100644 --- a/apps/cli/src/legacy/commands/start/lib/docker-create-args.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts @@ -54,7 +54,7 @@ import { legacyBindMountSpecSource, legacyIsBindMountSource, -} from "../../../shared/legacy-docker-bind-classify.ts"; +} from "../legacy-docker-bind-classify.ts"; /** * `container.HealthConfig` (`docker/docker/api/types/container`). Not diff --git a/apps/cli/src/legacy/commands/start/lib/docker-create-args.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.unit.test.ts similarity index 100% rename from apps/cli/src/legacy/commands/start/lib/docker-create-args.unit.test.ts rename to apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.unit.test.ts diff --git a/apps/cli/src/legacy/commands/start/lib/health-check.ts b/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts similarity index 98% rename from apps/cli/src/legacy/commands/start/lib/health-check.ts rename to apps/cli/src/legacy/shared/db-bootstrap/health-check.ts index 20b0a7524e..ac4cc07bdf 100644 --- a/apps/cli/src/legacy/commands/start/lib/health-check.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts @@ -18,9 +18,9 @@ import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSp import { legacySpawnContainerCliWithRuntime, type LegacyContainerRuntime, -} from "../../../shared/legacy-container-cli.ts"; -import { legacyInspectContainerState } from "../../../shared/legacy-docker-lifecycle.ts"; -import { legacyKongAuthHeaders } from "../../../shared/legacy-kong-auth.ts"; +} from "../legacy-container-cli.ts"; +import { legacyInspectContainerState } from "../legacy-docker-lifecycle.ts"; +import { legacyKongAuthHeaders } from "../legacy-kong-auth.ts"; type Spawner = ChildProcessSpawner["Service"]; diff --git a/apps/cli/src/legacy/commands/start/lib/health-check.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/health-check.unit.test.ts similarity index 100% rename from apps/cli/src/legacy/commands/start/lib/health-check.unit.test.ts rename to apps/cli/src/legacy/shared/db-bootstrap/health-check.unit.test.ts diff --git a/apps/cli/src/legacy/commands/start/lib/image-prepull.ts b/apps/cli/src/legacy/shared/db-bootstrap/image-prepull.ts similarity index 96% rename from apps/cli/src/legacy/commands/start/lib/image-prepull.ts rename to apps/cli/src/legacy/shared/db-bootstrap/image-prepull.ts index bf43bd04ee..99bd60544c 100644 --- a/apps/cli/src/legacy/commands/start/lib/image-prepull.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/image-prepull.ts @@ -19,11 +19,11 @@ import { Data, Effect, Result } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; -import { legacyMakeDockerImageResolver } from "../../../shared/legacy-docker-image-resolve.ts"; +import { legacyMakeDockerImageResolver } from "../legacy-docker-image-resolve.ts"; import { LEGACY_SUGGEST_DOCKER_INSTALL, legacyIsDockerDaemonUnreachable, -} from "../../../shared/legacy-docker-suggest.ts"; +} from "../legacy-docker-suggest.ts"; type Spawner = ChildProcessSpawner["Service"]; diff --git a/apps/cli/src/legacy/commands/start/lib/image-prepull.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/image-prepull.unit.test.ts similarity index 100% rename from apps/cli/src/legacy/commands/start/lib/image-prepull.unit.test.ts rename to apps/cli/src/legacy/shared/db-bootstrap/image-prepull.unit.test.ts diff --git a/apps/cli/src/legacy/commands/start/lib/internal-db-connection.ts b/apps/cli/src/legacy/shared/db-bootstrap/internal-db-connection.ts similarity index 87% rename from apps/cli/src/legacy/commands/start/lib/internal-db-connection.ts rename to apps/cli/src/legacy/shared/db-bootstrap/internal-db-connection.ts index 7f7dd46b06..bae3b3bb12 100644 --- a/apps/cli/src/legacy/commands/start/lib/internal-db-connection.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/internal-db-connection.ts @@ -16,11 +16,11 @@ * always-5432 internal port — the two never share a value except by * coincidence (`db.port` happening to equal 5432). * - * Hoisted here (`start/lib/`, the `start` command family's shared root) per - * `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" rule: Realtime, - * PostgREST, and Storage's own container-spec builders - * (`start/services/*.service.ts`) all need this exact host/port/password - * derivation. + * Hoisted here (`legacy/shared/db-bootstrap/`) per `apps/cli/CLAUDE.md`'s + * "Hoist Before You Duplicate" rule: Realtime, PostgREST, and Storage's own + * container-spec builders (`start/services/*.service.ts`) all need this + * exact host/port/password derivation, and `db start` (a different command + * family, `commands/db/start/`) needs it too since CLI-1954. */ /** Go's `dbConfig.Port` literal (`start.go:68`) — always 5432, never the configurable `db.port`. */ diff --git a/apps/cli/src/legacy/commands/start/lib/internal-db-connection.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/internal-db-connection.unit.test.ts similarity index 100% rename from apps/cli/src/legacy/commands/start/lib/internal-db-connection.unit.test.ts rename to apps/cli/src/legacy/shared/db-bootstrap/internal-db-connection.unit.test.ts 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 new file mode 100644 index 0000000000..cfe3b83b92 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/local-db-running.ts @@ -0,0 +1,127 @@ +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 { legacyReadDbToml } from "../legacy-db-config.toml-read.ts"; +import { legacyResolveLocalProjectId, localDbContainerId } from "../legacy-docker-ids.ts"; +import { + LEGACY_SUGGEST_DOCKER_INSTALL, + legacyIsDockerDaemonUnreachable, +} from "../legacy-docker-suggest.ts"; + +type Spawner = ChildProcessSpawner["Service"]; + +/** `docker container inspect` failed for a reason other than "the container doesn't exist". */ +export class LegacyLocalDbRunningError extends Data.TaggedError("LegacyLocalDbRunningError")<{ + readonly message: string; + /** Set when the failure is a daemon-connection error, mirroring `utils.CmdSuggestion`. */ + readonly suggestion?: string; +}> {} + +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); +}; + +/** + * Port of Go's `utils.AssertSupabaseDbIsRunning` (`internal/utils/misc.go:144`): + * inspect the local Postgres container. Resolves `true` when it exists (the + * stack is up) and `false` when the container-CLI reports "No such container" or + * "No such object" (the same pair handled in `shared/functions/serve.ts`) — + * Go's `ErrNotRunning`. Any other inspect failure (e.g. the Docker daemon is + * unreachable) fails with {@link LegacyLocalDbRunningError} instead of being + * treated as "not running", matching Go, which returns the wrapped inspect + * 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. + * + * `resolveDbToml` mirrors the seam's own best-effort read: the caller has + * already run Go's `LoadConfig` validation before reaching this check, so here + * we only want the resolved `projectId` and tolerate falling back to the + * workdir basename on an unreadable `.env` rather than re-throwing. + */ +export function legacyIsLocalDbRunning( + spawner: Spawner, + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + configuredProjectId: string | undefined, +): Effect.Effect { + return Effect.scoped( + Effect.gen(function* () { + const tomlProjectId = yield* legacyReadDbToml(fs, path, workdir, undefined, { + validate: false, + }).pipe( + Effect.map((toml) => toml.projectId), + Effect.orElseSucceed(() => Option.none()), + ); + const projectId = legacyResolveLocalProjectId( + configuredProjectId, + Option.getOrUndefined(tomlProjectId), + workdir, + ); + const containerId = localDbContainerId(projectId); + // Discard stdout (the inspect JSON) so the unconsumed pipe can never + // deadlock; only the exit code + stderr matter. + const child = yield* spawnContainerCli(spawner, ["container", "inspect", containerId], { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + extendEnv: true, + }).pipe( + Effect.mapError( + () => new LegacyLocalDbRunningError({ message: "failed to inspect service" }), + ), + ); + const stderrChunks: Array = []; + yield* Stream.runForEach(child.stderr, (chunk) => + Effect.sync(() => { + stderrChunks.push(chunk); + }), + ).pipe( + Effect.mapError( + () => new LegacyLocalDbRunningError({ message: "failed to inspect service" }), + ), + ); + const inspectExit = yield* child.exitCode.pipe( + Effect.map(Number), + Effect.mapError( + () => new LegacyLocalDbRunningError({ message: "failed to inspect service" }), + ), + ); + if (inspectExit === 0) return true; // container exists ⇒ running + + 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")) { + // 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. + return yield* Effect.fail( + new LegacyLocalDbRunningError({ + message: + stderr.length > 0 + ? `failed to inspect service: ${stderr}` + : "failed to inspect service", + ...(legacyIsDockerDaemonUnreachable(stderr) + ? { suggestion: LEGACY_SUGGEST_DOCKER_INSTALL } + : {}), + }), + ); + } + return false; + }), + ); +} diff --git a/apps/cli/src/legacy/shared/db-bootstrap/messages.ts b/apps/cli/src/legacy/shared/db-bootstrap/messages.ts new file mode 100644 index 0000000000..4178c053e1 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/messages.ts @@ -0,0 +1,32 @@ +/** + * Pure text formatters for the Postgres container bring-up's stderr progress lines — shared by + * `supabase start` and `db start`'s native container bootstrap. Hoisted here (was defined in + * `commands/start/start.format.ts`, which still holds every OTHER `start`-only progress/status + * message) once `db start`'s own bootstrap became a second caller — see + * `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" rule. + */ + +/** + * Go's `fmt.Fprintln(w, "Starting database...")` + * (`apps/cli-go/internal/db/start/start.go:165-175`) — printed right before + * the Postgres container itself is created/started, when the pre-create + * volume-existence check finds no existing volume (a brand-new, first-ever + * start). + */ +export const LEGACY_START_STARTING_DATABASE_MESSAGE = "Starting database...\n"; + +/** + * Go's `fmt.Fprintln(w, "Starting database from backup...")` + * (`apps/cli-go/internal/db/start/start.go:165-175`) — printed instead of + * {@link LEGACY_START_STARTING_DATABASE_MESSAGE} when the pre-create + * volume-existence check finds an EXISTING volume (a restart reusing the + * already-persisted Postgres data). Despite the wording, this has nothing to + * do with any `--from-backup` file-restore flag — Go's own `fromBackup` + * parameter is always empty for a plain `supabase start`, so this is the only + * branch that command path ever reaches; `db start` DOES have a real + * `--from-backup` flag, but this message is still only about the + * volume-already-exists case, not that flag (see `db/start/start.handler.ts`'s + * own message-selection logic). + */ +export const LEGACY_START_STARTING_DATABASE_FROM_BACKUP_MESSAGE = + "Starting database from backup...\n"; diff --git a/apps/cli/src/legacy/shared/db-bootstrap/messages.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/messages.unit.test.ts new file mode 100644 index 0000000000..da9452c7af --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/messages.unit.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { + LEGACY_START_STARTING_DATABASE_FROM_BACKUP_MESSAGE, + LEGACY_START_STARTING_DATABASE_MESSAGE, +} from "./messages.ts"; + +describe("LEGACY_START_STARTING_DATABASE_MESSAGE", () => { + it("matches Go's exact stderr line, with a single trailing newline", () => { + expect(LEGACY_START_STARTING_DATABASE_MESSAGE).toBe("Starting database...\n"); + }); +}); + +describe("LEGACY_START_STARTING_DATABASE_FROM_BACKUP_MESSAGE", () => { + it("matches Go's exact stderr line, with a single trailing newline", () => { + expect(LEGACY_START_STARTING_DATABASE_FROM_BACKUP_MESSAGE).toBe( + "Starting database from backup...\n", + ); + }); +}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.ts b/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.ts new file mode 100644 index 0000000000..89b70d18f1 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/pinned-image.ts @@ -0,0 +1,30 @@ +import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; +import { + replaceImageTag, + type LocalServiceVersionName, + type LocalServiceVersionOverrides, +} from "../../../shared/services/services.shared.ts"; + +/** + * The embedded Dockerfile default image for `alias`, with its tag replaced by + * `serviceVersions`' pin for `localServiceName` when one is present — Go's + * `Config.Load` rewriting `c.Auth.Image`/etc. from `supabase/.temp/*-version` + * (`pkg/config/config.go:827-863`) before `start`/`db start` ever read them. + * Reused by `start.gates.ts`'s own `legacyResolveStartImagePlan` AND by both + * `supabase start` and `db start`'s fresh-DB one-shot setup jobs + * (`realtime`/`storage`/`auth`), which Go runs regardless of `--exclude` and + * therefore can't go through `legacyResolveStartImagePlan`'s gate-filtered + * plan — hoisted here (was defined directly in `start.gates.ts`) once `db + * start`'s own native container bootstrap became a second caller across the + * `start`/`db` family boundary, see `apps/cli/CLAUDE.md`'s "Hoist Before You + * Duplicate" rule. + */ +export function legacyResolvePinnedImage( + alias: string, + localServiceName: LocalServiceVersionName, + serviceVersions: LocalServiceVersionOverrides, +): string { + const baseImage = dockerfileServiceImage(alias); + const pinnedVersion = serviceVersions[localServiceName]; + return pinnedVersion === undefined ? baseImage : replaceImageTag(baseImage, pinnedVersion); +} diff --git a/apps/cli/src/legacy/commands/start/services/postgres.service.ts b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts similarity index 72% rename from apps/cli/src/legacy/commands/start/services/postgres.service.ts rename to apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts index 5d008d6502..297292f114 100644 --- a/apps/cli/src/legacy/commands/start/services/postgres.service.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts @@ -1,30 +1,33 @@ /** * Port of Go's `NewContainerConfig`/`NewHostConfig` - * (`apps/cli-go/internal/db/start/start.go:63-131`): builds the - * {@link LegacyStartContainerSpec} for `supabase start`'s Postgres container. + * (`apps/cli-go/internal/db/start/start.go:63-131`), plus `StartDatabase`'s + * `fromBackup` entrypoint/bind override (`start.go:143-164`): builds the + * {@link LegacyStartContainerSpec} for both `supabase start`'s Postgres + * container (always `fromBackup: undefined`, matching `apps/cli-go/internal/ + * start/start.go:295`'s always-empty `fromBackup` call) and `db start`'s own + * native container bootstrap, which is the only real caller of the + * `fromBackup` branch. * * Deliberately out of scope, per the approved start-port plan: - * - `StartDatabase`'s `fromBackup` restore branch (`start.go:143-164`, - * `templates/restore.sh`) — `supabase start` always calls `StartDatabase` - * with an empty `fromBackup` (`apps/cli-go/internal/start/start.go:295`), - * so that whole branch is dead code on this path. * - `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} (`../lib/container-lifecycle.ts`) - * and {@link legacyWaitForHealthyServices} (`../lib/health-check.ts`), wired - * up by a later `start.handler.ts` task. + * healthy — that's {@link legacyStartContainer} (`./container-lifecycle.ts`) + * and {@link legacyWaitForHealthyServices} (`./health-check.ts`), wired + * up by each caller's own handler. */ import type { ProjectConfig } from "@supabase/config"; -import { localDbContainerId } from "../../../shared/legacy-docker-ids.ts"; -import { encodeToml } from "../../../shared/legacy-go-output.encoders.ts"; -import { LEGACY_POSTGRES_DEFAULT_ROOT_KEY } from "../../../shared/legacy-local-config-values.ts"; -import type { LegacyStartContainerSpec } from "../lib/docker-create-args.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"; -import { LEGACY_START_DB_WEBHOOK_SQL } from "../templates/db-webhook.sql.ts"; +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 { 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"; +import { LEGACY_START_DB_WEBHOOK_SQL } from "./templates/db-webhook.sql.ts"; /** Go's `Db.Password` default (`pkg/config/config.go:459`). `db.password` has no * config.toml field (`toml:"-"`, `pkg/config/db.go:88`), so this is the only value @@ -66,7 +69,7 @@ export interface LegacyPostgresStartServiceInput { readonly projectId: string; /** `utils.NetId` — the local stack's docker network id. */ readonly networkId: string; - /** `utils.Config.Db.Image`, already resolved/pulled (see `../lib/image-prepull.ts`) — the container's own image. */ + /** `utils.Config.Db.Image`, already resolved/pulled (see `./image-prepull.ts`) — the container's own image. */ readonly image: string; /** * `utils.Config.Db.Image` BEFORE registry resolution — Go's @@ -81,6 +84,18 @@ export interface LegacyPostgresStartServiceInput { readonly configImage: string; /** Already-resolved `db.root_key` value. Defaults to {@link LEGACY_POSTGRES_DEFAULT_ROOT_KEY} when omitted — see that constant's doc comment for why. */ readonly rootKey?: string; + /** + * Absolute host path to a `--from-backup` logical-dump file, already resolved against the + * caller's cwd (Go's `filepath.Join(utils.CurrentDirAbs, fromBackup)`, `start.go:160-161`) — + * `db start`'s ONLY caller. When set, switches to a THIRD entrypoint variant + * ({@link legacyPostgresEntrypointScriptRestore}) regardless of `db.major_version` (Go's + * `StartDatabase` override applies unconditionally, `start.go:143-159`) and appends the + * `:/etc/backup.sql:ro` bind Go's own `StartDatabase` appends + * (`start.go:163`, via `utils.ToDockerPath` — {@link legacyToDockerPath} here). `undefined` for + * `supabase start`, which always calls `StartDatabase` with an empty `fromBackup` + * (`apps/cli-go/internal/start/start.go:295`). + */ + readonly fromBackup?: string; } /** @@ -281,8 +296,41 @@ function legacyPostgresEntrypointScriptPg14(postgresConfig: string): string { } /** - * Builds the {@link LegacyStartContainerSpec} for `supabase start`'s Postgres - * container — see this module's header for what's deliberately out of scope. + * `--from-backup` entrypoint (`StartDatabase`'s unconditional `Entrypoint` override, + * `start.go:143-159`) — applies regardless of `db.major_version`, unlike the two scripts above. + * Three heredocs, not four: unlike Go's literal script (which heredocs the pgsodium root key + * inline), this port always carries the root key via {@link LegacyStartContainerSpec.secretFiles} + * instead (see {@link legacyBuildPostgresStartContainerSpec}'s call site) — an intentional, + * pre-existing TS divergence for every entrypoint variant, not something to "fix toward Go" here. + * Schema heredoc is `initialSchema + _supabaseSchema` — deliberately NO `webhookSchema` (present in + * {@link legacyPostgresEntrypointScriptPg15}, absent here, matching Go's own + * `` ` + initialSchema + ` ` + _supabaseSchema + ` `` with no `webhookSchema` splice in the + * `fromBackup` branch). Postgres config gets one extra literal line appended, + * `cron.launch_active_jobs = off`, matching Go's own trailing append. + */ +function legacyPostgresEntrypointScriptRestore(postgresConfig: string): string { + return ( + "\n" + + "cat <<'EOF' > /etc/postgresql.schema.sql && \\\n" + + "cat <<'EOF' > /docker-entrypoint-initdb.d/migrate.sh && \\\n" + + "cat <<'EOF' >> /etc/postgresql/postgresql.conf && \\\n" + + "docker-entrypoint.sh postgres -D /etc/postgresql\n" + + `${LEGACY_START_DB_SCHEMA_SQL}\n` + + `${LEGACY_START_DB_SUPABASE_SQL}\n` + + "EOF\n" + + `${LEGACY_START_DB_RESTORE_SH}\n` + + "EOF\n" + + `${postgresConfig}\n` + + "cron.launch_active_jobs = off\n" + + "EOF" + ); +} + +/** + * Builds the {@link LegacyStartContainerSpec} for the Postgres container — shared by `supabase + * start` (always {@link LegacyPostgresStartServiceInput.fromBackup} `undefined`) and `db start`'s + * native bootstrap (the only caller that ever sets it) — see this module's header for what's + * deliberately out of scope. */ export function legacyBuildPostgresStartContainerSpec( input: LegacyPostgresStartServiceInput, @@ -291,6 +339,7 @@ export function legacyBuildPostgresStartContainerSpec( const rootKeyValue = input.rootKey ?? LEGACY_POSTGRES_DEFAULT_ROOT_KEY; const postgresConfig = legacyPostgresSettingsToPostgresConfig(input.db.settings); const isPg14OrEarlier = input.db.major_version <= 14; + const isRestore = input.fromBackup !== undefined; const env: Record = { POSTGRES_PASSWORD: LEGACY_POSTGRES_PASSWORD, @@ -300,9 +349,11 @@ export function legacyBuildPostgresStartContainerSpec( ...legacyPostgresExtraEnv(input.experimental, input.configImage), }; - const script = isPg14OrEarlier - ? legacyPostgresEntrypointScriptPg14(postgresConfig) - : legacyPostgresEntrypointScriptPg15(postgresConfig); + const script = isRestore + ? legacyPostgresEntrypointScriptRestore(postgresConfig) + : isPg14OrEarlier + ? legacyPostgresEntrypointScriptPg14(postgresConfig) + : legacyPostgresEntrypointScriptPg15(postgresConfig); return { image: input.image, @@ -310,9 +361,23 @@ export function legacyBuildPostgresStartContainerSpec( env, entrypoint: "sh", cmd: ["-c", script], - binds: [`${containerName}:/var/lib/postgresql/data`], + binds: [ + `${containerName}:/var/lib/postgresql/data`, + // Go's `StartDatabase` (`start.go:163`) appends this bind ONLY on the `fromBackup` branch — + // `hostConfig.Binds` is otherwise built solely from `NewHostConfig()`'s own volume bind above. + ...(input.fromBackup === undefined + ? [] + : [`${legacyToDockerPath(input.fromBackup)}:/etc/backup.sql:ro`]), + ], + // Go's `NewHostConfig()` sets `Tmpfs` purely off `db.major_version` (`start.go:127-129`) — that + // check is NOT part of `StartDatabase`'s `fromBackup` override, so this stays keyed on + // `isPg14OrEarlier` alone, independent of `isRestore`. ...(isPg14OrEarlier ? { tmpfs: { "/docker-entrypoint-initdb.d": "" } } : {}), - ...(isPg14OrEarlier + // The pgsodium root key heredoc/bind is present whenever the ACTUAL entrypoint in use embeds + // it: both `legacyPostgresEntrypointScriptPg15` and `legacyPostgresEntrypointScriptRestore` do + // (Go's `fromBackup` override always re-adds its own root-key heredoc, `start.go:147,155`, + // regardless of major version); only the PG<=14 script never references it. + ...(isPg14OrEarlier && !isRestore ? {} : { secretFiles: [ diff --git a/apps/cli/src/legacy/commands/start/services/postgres.service.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts similarity index 79% rename from apps/cli/src/legacy/commands/start/services/postgres.service.unit.test.ts rename to apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts index b651045860..3b88393d98 100644 --- a/apps/cli/src/legacy/commands/start/services/postgres.service.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts @@ -1,10 +1,11 @@ import type { ProjectConfig } from "@supabase/config"; import { describe, expect, test } from "vitest"; -import { LEGACY_START_DB_SCHEMA_SQL } from "../templates/db-schema.sql.ts"; -import { LEGACY_START_DB_SUPABASE_SQL } from "../templates/db-supabase.sql.ts"; -import { LEGACY_START_DB_WEBHOOK_SQL } from "../templates/db-webhook.sql.ts"; -import { LEGACY_POSTGRES_DEFAULT_ROOT_KEY } from "../../../shared/legacy-local-config-values.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"; +import { LEGACY_START_DB_WEBHOOK_SQL } from "./templates/db-webhook.sql.ts"; +import { LEGACY_POSTGRES_DEFAULT_ROOT_KEY } from "../legacy-local-config-values.ts"; import { legacyBuildPostgresStartContainerSpec, legacyPostgresImageVersionTag, @@ -236,6 +237,69 @@ describe("legacyBuildPostgresStartContainerSpec", () => { expect(spec.binds).toEqual(["supabase_db_my_project_:/var/lib/postgresql/data"]); }); + test("--from-backup: PG >= 15 uses the restore entrypoint (schema.sql + _supabase.sql, no webhook.sql), appends migrate.sh/postgresql.conf heredocs and cron.launch_active_jobs=off, and still carries the root key as a secretFile", () => { + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ db: baseDb({ major_version: 17 }), fromBackup: "/abs/host/backup.sql" }), + ); + + expect(spec.entrypoint).toBe("sh"); + const script = spec.cmd?.[1]; + expect(script).toBe( + "\n" + + "cat <<'EOF' > /etc/postgresql.schema.sql && \\\n" + + "cat <<'EOF' > /docker-entrypoint-initdb.d/migrate.sh && \\\n" + + "cat <<'EOF' >> /etc/postgresql/postgresql.conf && \\\n" + + "docker-entrypoint.sh postgres -D /etc/postgresql\n" + + `${LEGACY_START_DB_SCHEMA_SQL}\n` + + `${LEGACY_START_DB_SUPABASE_SQL}\n` + + "EOF\n" + + `${LEGACY_START_DB_RESTORE_SH}\n` + + "EOF\n" + + `${POSTGRES_CONFIG_HEADER}\n` + + "cron.launch_active_jobs = off\n" + + "EOF", + ); + expect(script).not.toContain(LEGACY_START_DB_WEBHOOK_SQL); + expect(script).not.toContain(LEGACY_POSTGRES_DEFAULT_ROOT_KEY); + expect(spec.secretFiles).toEqual([ + { + containerPath: "/etc/postgresql-custom/pgsodium_root.key", + content: LEGACY_POSTGRES_DEFAULT_ROOT_KEY, + }, + ]); + expect(spec.binds).toEqual([ + "supabase_db_myproj:/var/lib/postgresql/data", + "/abs/host/backup.sql:/etc/backup.sql:ro", + ]); + }); + + test("--from-backup: PG <= 14 still uses the restore entrypoint (unconditional override) but keeps the PG<=14 initdb tmpfs mount", () => { + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ db: baseDb({ major_version: 14 }), fromBackup: "/abs/host/backup.sql" }), + ); + + expect(spec.cmd?.[1]).toContain("/docker-entrypoint-initdb.d/migrate.sh"); + expect(spec.tmpfs).toEqual({ "/docker-entrypoint-initdb.d": "" }); + expect(spec.secretFiles).toEqual([ + { + containerPath: "/etc/postgresql-custom/pgsodium_root.key", + content: LEGACY_POSTGRES_DEFAULT_ROOT_KEY, + }, + ]); + }); + + test("--from-backup: converts a Windows-style host path through legacyToDockerPath for the backup bind", () => { + const spec = legacyBuildPostgresStartContainerSpec( + baseInput({ fromBackup: "C:\\Users\\me\\backup.sql" }), + ); + expect(spec.binds).toContain("/Users/me/backup.sql:/etc/backup.sql:ro"); + }); + + test("no --from-backup: binds only the data volume, matching the pre-existing behavior", () => { + const spec = legacyBuildPostgresStartContainerSpec(baseInput()); + expect(spec.binds).toEqual(["supabase_db_myproj:/var/lib/postgresql/data"]); + }); + test("network id, aliases, restart policy, and image pass through unchanged", () => { const spec = legacyBuildPostgresStartContainerSpec( baseInput({ networkId: "supabase_network_myproj", image: "some/resolved-image:17.4.1.030" }), diff --git a/apps/cli/src/legacy/shared/db-bootstrap/realtime-env.ts b/apps/cli/src/legacy/shared/db-bootstrap/realtime-env.ts new file mode 100644 index 0000000000..8210ec20b4 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/realtime-env.ts @@ -0,0 +1,89 @@ +/** + * Realtime's env-var literal (Go's `start.go:909-929` `Env` slice) — shared by TWO Go call + * sites, and therefore two TS ports: the long-running Realtime container + * (`commands/start/services/realtime.service.ts`'s `legacyBuildRealtimeContainerSpec`) and the + * PG15+ fresh-DB one-shot `initRealtimeJob` (`db-setup.ts`'s `legacyStartInitSchema15`, run by + * BOTH `supabase start` and `db start`). Hoisted here (was defined directly in + * `realtime.service.ts`, which still re-exports {@link LEGACY_REALTIME_TENANT_ID}/ + * {@link legacyBuildRealtimeEnv} for its own existing callers) once `db-setup.ts` itself moved + * to `legacy/shared/` and needed this reachable across the `start`/`db` family boundary — see + * `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" rule. + */ + +import type { ProjectConfig } from "@supabase/config"; + +import { + LEGACY_START_INTERNAL_DB_NAME, + LEGACY_START_INTERNAL_DB_PORT, +} from "./internal-db-connection.ts"; + +/** + * Go's `utils.SUPERUSER_ROLE` (`apps/cli-go/internal/utils/connect.go:338`) — + * Realtime's fixed `DB_USER` (`start.go:913`). Unrelated to the per-service + * role each OTHER container's own DB connection string uses (PostgREST's + * `authenticator`, Storage's `supabase_storage_admin`), so it is not hoisted + * alongside `legacyStartInternalDbUrl`. + */ +const LEGACY_REALTIME_DB_USER = "supabase_admin"; + +/** + * Go's `realtime.TenantId` default (`pkg/config/config.go:481`) — `toml:"-"` + * (`config.go:254`), so never configurable via `config.toml` or a + * `SUPABASE_*` override; always this literal. Exported: `kong.service.ts`'s + * `kong.yml` template needs this exact same value for its `RealtimeId` field + * (Go's `Config.Realtime.TenantId`, `start.go:492` — NOT Realtime's own + * container name/id, see that module's `realtimeTenantId` doc comment). + */ +export const LEGACY_REALTIME_TENANT_ID = "realtime-dev"; + +/** Go's `realtime.EncryptionKey` default (`pkg/config/config.go:482`) — `toml:"-"`, never configurable. */ +const LEGACY_REALTIME_ENCRYPTION_KEY = "supabaserealtime"; + +/** Go's `realtime.SecretKeyBase` default (`pkg/config/config.go:483`) — `toml:"-"`, never configurable. */ +const LEGACY_REALTIME_SECRET_KEY_BASE = + "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG"; + +export interface LegacyRealtimeEnvInput { + /** `config.realtime.ip_version` — feeds `utils.ToRealtimeEnv` (`utils/config.go:209-214`). */ + readonly ipVersion: ProjectConfig["realtime"]["ip_version"]; + /** `config.realtime.max_header_length`. */ + readonly maxHeaderLength: ProjectConfig["realtime"]["max_header_length"]; + /** The `db` container's own Docker name (`legacyServiceContainerName("db", projectId)`). */ + readonly dbHost: string; + /** See {@link legacyStartInternalDbPassword}. */ + readonly dbPassword: string; + /** `LegacyLocalConfigValues.jwtSecret` — feeds both `API_JWT_SECRET` and `METRICS_JWT_SECRET`. */ + readonly jwtSecret: string; + /** `legacyResolveLocalJwks`'s resolved JWKS JSON string — feeds `API_JWT_JWKS`. */ + readonly jwks: string; +} + +/** + * Pure env-var builder, split out from `legacyBuildRealtimeContainerSpec` + * so the full Go `Env` literal (`start.go:909-929`) is unit-testable without + * constructing a whole container spec. + */ +export function legacyBuildRealtimeEnv(input: LegacyRealtimeEnvInput): Record { + return { + PORT: "4000", + DB_HOST: input.dbHost, + DB_PORT: String(LEGACY_START_INTERNAL_DB_PORT), + DB_USER: LEGACY_REALTIME_DB_USER, + DB_PASSWORD: input.dbPassword, + DB_NAME: LEGACY_START_INTERNAL_DB_NAME, + DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime", + DB_ENC_KEY: LEGACY_REALTIME_ENCRYPTION_KEY, + API_JWT_SECRET: input.jwtSecret, + API_JWT_JWKS: input.jwks, + METRICS_JWT_SECRET: input.jwtSecret, + APP_NAME: "realtime", + SECRET_KEY_BASE: LEGACY_REALTIME_SECRET_KEY_BASE, + ERL_AFLAGS: input.ipVersion === "IPv6" ? "-proto_dist inet6_tcp" : "-proto_dist inet_tcp", + // Two literal single-quote characters, exactly like Go's `"DNS_NODES=''"` (`start.go:924`). + DNS_NODES: "''", + RLIMIT_NOFILE: "", + SEED_SELF_HOST: "true", + RUN_JANITOR: "true", + MAX_HEADER_LENGTH: String(input.maxHeaderLength), + }; +} diff --git a/apps/cli/src/legacy/shared/db-bootstrap/realtime-env.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/realtime-env.unit.test.ts new file mode 100644 index 0000000000..7a588eecd4 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/realtime-env.unit.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "vitest"; + +import { legacyBuildRealtimeEnv } from "./realtime-env.ts"; + +describe("legacyBuildRealtimeEnv", () => { + const base = { + ipVersion: "IPv4" as const, + maxHeaderLength: 4096, + dbHost: "supabase_db_proj", + dbPassword: "postgres", + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwks: '{"keys":[]}', + }; + + test("wires the fixed internal DB address, JWT secret, and JWKS", () => { + const env = legacyBuildRealtimeEnv(base); + expect(env["DB_HOST"]).toBe("supabase_db_proj"); + expect(env["DB_PORT"]).toBe("5432"); + expect(env["DB_USER"]).toBe("supabase_admin"); + expect(env["DB_PASSWORD"]).toBe("postgres"); + expect(env["DB_NAME"]).toBe("postgres"); + expect(env["API_JWT_SECRET"]).toBe(base.jwtSecret); + expect(env["METRICS_JWT_SECRET"]).toBe(base.jwtSecret); + expect(env["API_JWT_JWKS"]).toBe(base.jwks); + }); + + test("matches Go's remaining static env values", () => { + const env = legacyBuildRealtimeEnv(base); + expect(env).toMatchObject({ + PORT: "4000", + DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime", + DB_ENC_KEY: "supabaserealtime", + APP_NAME: "realtime", + SECRET_KEY_BASE: "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", + DNS_NODES: "''", + RLIMIT_NOFILE: "", + SEED_SELF_HOST: "true", + RUN_JANITOR: "true", + MAX_HEADER_LENGTH: "4096", + }); + }); + + test("selects inet_tcp for IPv4", () => { + expect(legacyBuildRealtimeEnv({ ...base, ipVersion: "IPv4" })["ERL_AFLAGS"]).toBe( + "-proto_dist inet_tcp", + ); + }); + + test("selects inet6_tcp for IPv6", () => { + expect(legacyBuildRealtimeEnv({ ...base, ipVersion: "IPv6" })["ERL_AFLAGS"]).toBe( + "-proto_dist inet6_tcp", + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/start.rollback.ts b/apps/cli/src/legacy/shared/db-bootstrap/rollback.ts similarity index 89% rename from apps/cli/src/legacy/commands/start/start.rollback.ts rename to apps/cli/src/legacy/shared/db-bootstrap/rollback.ts index b46956059c..83186f2c64 100644 --- a/apps/cli/src/legacy/commands/start/start.rollback.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/rollback.ts @@ -1,10 +1,10 @@ import { Effect } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; -import type { LegacyContainerIdName } from "../../shared/legacy-docker-lifecycle.ts"; -import { legacyDockerRemoveAll } from "../../shared/legacy-docker-remove-all.ts"; -import { legacyCleanupStartSecrets } from "../../shared/legacy-start-secrets-cleanup.ts"; -import { LegacyHealthCheckTimeoutError } from "./lib/health-check.ts"; +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"; type Spawner = ChildProcessSpawner["Service"]; @@ -14,7 +14,7 @@ type Spawner = ChildProcessSpawner["Service"]; * multi-error, which is exactly the shape `WaitForHealthyService` produces on * timeout and nothing else in `run()` ever produces. This port's equivalent * "only the health-check timeout produces this shape" failure is - * {@link LegacyHealthCheckTimeoutError} (`lib/health-check.ts`), so the + * {@link LegacyHealthCheckTimeoutError} (`./health-check.ts`), so the * classification collapses to an `instanceof` check against that one class — * the caller (`start.handler.ts`) uses this to decide whether * `--ignore-health-check` should downgrade a failure to a warning instead of diff --git a/apps/cli/src/legacy/commands/start/start.rollback.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/rollback.unit.test.ts similarity index 98% rename from apps/cli/src/legacy/commands/start/start.rollback.unit.test.ts rename to apps/cli/src/legacy/shared/db-bootstrap/rollback.unit.test.ts index bff777ba6f..de172b4b46 100644 --- a/apps/cli/src/legacy/commands/start/start.rollback.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/rollback.unit.test.ts @@ -2,8 +2,8 @@ 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 "./lib/health-check.ts"; -import { legacyIsUnhealthyStartError, legacyRollbackStart } from "./start.rollback.ts"; +import { LegacyHealthCheckTimeoutError } from "./health-check.ts"; +import { legacyIsUnhealthyStartError, legacyRollbackStart } from "./rollback.ts"; function captureStderr() { return vi.spyOn(process.stderr, "write").mockImplementation(() => true); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts new file mode 100644 index 0000000000..00e70f4444 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts @@ -0,0 +1,351 @@ +/** + * Strict 1:1 port of Go's `StartDatabase` (`apps/cli-go/internal/db/start/start.go:133-190`) — + * the ONE function both `supabase start` (`commands/start/start.handler.ts`) and `db start` + * (`commands/db/start/start.handler.ts`) call to bring up the local Postgres container. Hoisted + * here as CLI-1954's own follow-up fix: both callers used to run their own independently-typed + * ~200-line copy of this exact sequence, with no test comparing them — the highest-drift-risk + * shape available in a codebase whose whole contract is byte-level Go parity. A future change to + * Go's `StartDatabase` now only has one TS home to update. + * + * Exact Go call order: network ensure -> pre-create volume-existence probe (+ the + * `fromBackup`-on-an-existing-volume guard) -> Postgres container create+start -> health wait + * (swallowed ONLY when `fromBackup` is set — "restoring a large backup may take longer than 2 + * minutes") -> the fresh-volume `SetupLocalDatabase`-equivalent pipeline (skipped IN FULL when + * `fromBackup` is set) -> `initCurrentBranch`, unconditionally (the LAST line of `StartDatabase`, + * reached on every path that doesn't already return/fail above). + * + * Deliberately has ZERO knowledge of `--ignore-health-check` — matching Go exactly: that flag is + * `internal/start/start.go`'s `Run()`'s own concern, entirely OUTSIDE `StartDatabase` (Go's + * `StartDatabase` has no `ignoreHealthCheck` parameter at all). `supabase start`'s own caller + * wraps the WHOLE call to {@link legacyStartDatabase} in its own `Effect.result` and decides + * whether to downgrade an unhealthy-Postgres failure to a warning and continue with the REST of + * its own bring-up (the other ~13 services) — this function only ever propagates that failure + * bare, exactly like Go's `StartDatabase` returning it to `run()` unfiltered. Rollback + * (`legacyRollbackStart`) is ALSO the caller's own concern, not this function's — matching Go, + * where `DockerRemoveAll` lives in `Run()` (both `db/start/start.go`'s own `Run` and + * `internal/start/start.go`'s `Run`), never inside `StartDatabase` itself. + * + * Two inputs are caller-supplied `Effect`s rather than plain values, because their TIMING + * relative to this function's own body genuinely differs between callers (not just a stylistic + * choice — see each field's own doc comment below for the Go citation): + * - `resolvePostgresImage` — `db start` has no pre-pull pass at all (Go's `db start` binary has + * none either), so it resolves the registry candidate lazily, right here, exactly where Go's + * `DockerStart` would; `supabase start` already resolved it as part of its own batched + * `ensureImagesCached` pre-pull, before bring-up even starts, and just threads that value + * through. + * - `setup.jwks` — `db start` has no earlier use for JWKS at all, so it resolves it lazily, + * conditionally (only when reached AND `realtime.enabled`), matching Go's own `initSchema15`- + * local `ResolveJWKS` call (`internal/db/start/start.go:337-341`) exactly; `supabase start` + * resolves JWKS once, unconditionally, near the top of its OWN prelude (feeding its + * long-running Realtime/GoTrue/PostgREST containers too — `internal/start/start.go:274-277`) + * and reuses that SAME already-resolved value here rather than re-resolving (a second resolve + * could re-sign an asymmetric JWT with a different `exp`, disagreeing with the value already + * baked into those containers' envs). + */ + +import { Data, Effect, type FileSystem, type Path, Result } from "effect"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +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, + LEGACY_COMPOSE_PROJECT_LABEL, + type LegacyStartContainerCreateError, + type LegacyStartContainerOpts, + type LegacyStartContainerStartError, + type LegacyStartNetworkCreateError, + type LegacyStartVolumeCreateError, + type LegacyStartVolumeInspectError, +} from "./container-lifecycle.ts"; +import { + legacyStartInitCurrentBranch, + legacyStartSetupLocalDatabase, + type LegacyStartDbSetupImages, + type LegacyStartSetupLocalDatabaseError, + type LegacyStartSetupLocalDatabaseInput, +} from "./db-setup.ts"; +import { legacyEnsureImagesCached, type LegacyImagePrepullError } from "./image-prepull.ts"; +import { + legacyWaitForHealthyServices, + type LegacyHealthCheckTimeoutError, +} from "./health-check.ts"; +import { legacyStartInternalDbPassword } from "./internal-db-connection.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, +} from "./postgres.service.ts"; + +type Spawner = ChildProcessSpawner["Service"]; + +/** + * Go's `StartDatabase` `fromBackup` guard (`start.go:170-172`): the local Postgres volume + * already exists AND `fromBackup` was passed. Restoring into an already-provisioned volume + * would silently no-op (or worse, mix a restored dump with whatever data the volume already + * has) — Go refuses outright rather than guessing which the caller wants. Raised BEFORE any + * container is created (no `docker create`/`docker start` happens on this path). Only ever + * reachable via `db start` (the sole caller that ever sets `postgresSpec.fromBackup`). + * Not exported outside this module — callers only ever observe it through the + * {@link LegacyStartDatabaseError} union and its `_tag`, never by importing the class. + */ +class LegacyStartBackupVolumeExistsError extends Data.TaggedError( + "LegacyStartBackupVolumeExistsError", +)<{ + readonly message: string; + readonly suggestion?: string; +}> {} + +/** Every failure {@link legacyStartDatabase} itself can produce, independent of the caller's own `E`. */ +export type LegacyStartDatabaseError = + | LegacyStartNetworkCreateError + | LegacyStartVolumeInspectError + | LegacyStartBackupVolumeExistsError + | LegacyStartVolumeCreateError + | LegacyStartContainerCreateError + | LegacyStartContainerStartError + | 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"]; + 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; + readonly workdir: string; + readonly projectId: string; + readonly networkId: string; + readonly hostname: string; + /** `localDbContainerId(projectId)` — also the connect-target host inside the local Postgres session below. */ + readonly dbContainerId: string; + readonly dbPort: number; + readonly containerOpts: LegacyStartContainerOpts; + /** Fed straight to `legacyBuildPostgresStartContainerSpec` — `fromBackup` (if set) drives BOTH the restore-entrypoint variant and the backup-volume-exists guard below. */ + readonly postgresSpec: Omit; + /** + * Lazy — evaluated right where Go's `DockerStart` would resolve it. See this module's header. + * Fixed to `LegacyImagePrepullError` (not generic `E`): both callers' real implementations + * either never fail (`supabase start`'s already-resolved `Effect.succeed`) or fail with exactly + * this error (`db start`'s own `legacyEnsureImagesCached` call) — already part of this + * function's own fixed {@link LegacyStartDatabaseError} union. + */ + readonly resolvePostgresImage: Effect.Effect; + readonly dbHealthTimeoutSeconds: number; + readonly setup: LegacyStartDatabaseSetupInput; + /** + * 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 + * the caller's OWN `legacyRollbackStart` (which this function does NOT call itself — see this + * module's header) even when this function fails partway through, after the probe. + */ + readonly onFreshVolumeResolved: (isFreshVolume: boolean) => void; +} + +/** + * Runs the exact Go `StartDatabase` sequence — see this module's header for the full call order + * and for why `resolvePostgresImage`/`setup.jwks` are caller-supplied `Effect`s rather than plain + * values. + */ +export const legacyStartDatabase = ( + spawner: Spawner, + input: LegacyStartDatabaseInput, +): Effect.Effect< + void, + LegacyStartDatabaseError | E, + Output | LegacyDbConnection | LegacyDockerRun | RuntimeInfo | HttpClient.HttpClient +> => + Effect.gen(function* () { + const output = yield* Output; + const dbConnection = yield* LegacyDbConnection; + + yield* legacyEnsureStartNetwork(spawner, input.networkId, { + [LEGACY_CLI_PROJECT_LABEL]: input.projectId, + [LEGACY_COMPOSE_PROJECT_LABEL]: input.projectId, + }); + + // 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)); + input.onFreshVolumeResolved(isFreshVolume); + + const fromBackup = input.postgresSpec.fromBackup; + if (!isFreshVolume && fromBackup !== undefined) { + // Go's `StartDatabase` (`start.go:170-172`): a `--from-backup` restore into an + // already-provisioned volume is refused outright, BEFORE any container is created. + return yield* Effect.fail( + new LegacyStartBackupVolumeExistsError({ + message: "backup volume already exists", + suggestion: `Run ${legacyAqua("supabase stop --no-backup")} to remove existing docker volumes.`, + }), + ); + } + + if (output.format === "text") { + yield* output.raw( + isFreshVolume + ? LEGACY_START_STARTING_DATABASE_MESSAGE + : LEGACY_START_STARTING_DATABASE_FROM_BACKUP_MESSAGE, + "stderr", + ); + } + + const resolvedPostgresImage = yield* input.resolvePostgresImage; + const postgresSpec = legacyBuildPostgresStartContainerSpec({ + ...input.postgresSpec, + image: resolvedPostgresImage, + }); + yield* legacyStartContainer(spawner, postgresSpec, input.containerOpts); + + const postgresHealthResult = yield* legacyWaitForHealthyServices( + spawner, + [postgresSpec.containerName], + { + timeoutSeconds: input.dbHealthTimeoutSeconds, + images: new Map([[postgresSpec.containerName, resolvedPostgresImage]]), + }, + ).pipe(Effect.result); + if (Result.isFailure(postgresHealthResult)) { + // Go's `StartDatabase` (`start.go:179-181`): `WaitForHealthyService`'s error is discarded + // ONLY when `len(fromBackup) > 0` — the log dump to stderr already happened inside + // `legacyWaitForHealthyServices` regardless of this branch. Any OTHER failure propagates + // BARE — this function has no `--ignore-health-check` knowledge at all, see this module's + // header for why that's entirely the caller's concern. + if (fromBackup === undefined) { + return yield* Effect.fail(postgresHealthResult.failure); + } + } + + // Go's `if utils.NoBackupVolume && len(fromBackup) == 0 { SetupLocalDatabase(...) }` + // (`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, + 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, + }); + }), + ); + } + + // Go's `initCurrentBranch` (`db/start/start.go:189`) — the LAST line of `StartDatabase`, + // reached on every path that doesn't already return/fail above: a fresh volume, a non-fresh + // restart, AND a swallowed `fromBackup` health-check timeout. + yield* legacyStartInitCurrentBranch(input.fs, input.path, input.workdir); + }); diff --git a/apps/cli/src/legacy/commands/start/templates/db-globals.sql.ts b/apps/cli/src/legacy/shared/db-bootstrap/templates/db-globals.sql.ts similarity index 100% rename from apps/cli/src/legacy/commands/start/templates/db-globals.sql.ts rename to apps/cli/src/legacy/shared/db-bootstrap/templates/db-globals.sql.ts diff --git a/apps/cli/src/legacy/commands/start/templates/db-initial-schema-13.sql.ts b/apps/cli/src/legacy/shared/db-bootstrap/templates/db-initial-schema-13.sql.ts similarity index 100% rename from apps/cli/src/legacy/commands/start/templates/db-initial-schema-13.sql.ts rename to apps/cli/src/legacy/shared/db-bootstrap/templates/db-initial-schema-13.sql.ts diff --git a/apps/cli/src/legacy/commands/start/templates/db-initial-schema-14.sql.ts b/apps/cli/src/legacy/shared/db-bootstrap/templates/db-initial-schema-14.sql.ts similarity index 100% rename from apps/cli/src/legacy/commands/start/templates/db-initial-schema-14.sql.ts rename to apps/cli/src/legacy/shared/db-bootstrap/templates/db-initial-schema-14.sql.ts diff --git a/apps/cli/src/legacy/shared/db-bootstrap/templates/db-restore.sh.ts b/apps/cli/src/legacy/shared/db-bootstrap/templates/db-restore.sh.ts new file mode 100644 index 0000000000..71fb37935c --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/templates/db-restore.sh.ts @@ -0,0 +1,57 @@ +/** + * Transcribed verbatim from `apps/cli-go/internal/db/start/templates/restore.sh` + * (Go `//go:embed templates/restore.sh`, `apps/cli-go/internal/db/start/start.go:40-41`, + * exported as `restoreScript`). Heredoc'd into `/docker-entrypoint-initdb.d/migrate.sh` + * by the Postgres container's entrypoint ONLY when `--from-backup` is set + * (`StartDatabase`, `apps/cli-go/internal/db/start/start.go:143-159`) — restores roles + * then schema from the bind-mounted `/etc/backup.sql`, then runs + * `/etc/postgresql.schema.sql` (the initial schema, written by the same entrypoint) as a + * post-init step so a restored database still gets Supabase's roles/passwords applied. + * Not a Go `text/template`. Do not hand-edit — re-transcribe from the Go source if it + * changes. + */ +export const LEGACY_START_DB_RESTORE_SH = `#!/bin/sh +set -eu + +####################################### +# Used by both ami and docker builds to initialise database schema. +# Env vars: +# POSTGRES_DB defaults to postgres +# POSTGRES_HOST defaults to localhost +# POSTGRES_PORT defaults to 5432 +# POSTGRES_PASSWORD defaults to "" +# USE_DBMATE defaults to "" +# Exit code: +# 0 if migration succeeds, non-zero on error. +####################################### + +export PGDATABASE="\${POSTGRES_DB:-postgres}" +export PGHOST="\${POSTGRES_HOST:-localhost}" +export PGPORT="\${POSTGRES_PORT:-5432}" +export PGPASSWORD="\${POSTGRES_PASSWORD:-}" + +echo "$0: restoring roles" +cat "/etc/backup.sql" \\ +| grep 'CREATE ROLE' \\ +| grep -v 'supabase_admin' \\ +| sed -E 's/^(CREATE ROLE postgres);/\\1 WITH SUPERUSER;/' \\ +| psql -v ON_ERROR_STOP=1 --no-password --no-psqlrc -U supabase_admin + +echo "$0: restoring schema" +cat "/etc/backup.sql" \\ +| sed -E 's/^\\\\(un)?restrict .*$/-- &/' \\ +| sed -E 's/^CREATE VIEW /CREATE OR REPLACE VIEW /' \\ +| sed -E 's/^CREATE FUNCTION /CREATE OR REPLACE FUNCTION /' \\ +| sed -E 's/^CREATE TRIGGER /CREATE OR REPLACE TRIGGER /' \\ +| sed -E 's/^GRANT ALL ON FUNCTION graphql_public\\./-- &/' \\ +| sed -E 's/^CREATE ROLE /-- &/' \\ +| sed -e '/ALTER ROLE postgres WITH / { h; $p; d; }' -e '$G' \\ +| psql -v ON_ERROR_STOP=1 --no-password --no-psqlrc -U supabase_admin + +# run any post migration script to update role passwords +postinit="/etc/postgresql.schema.sql" +if [ -e "$postinit" ]; then + echo "$0: running $postinit" + psql -v ON_ERROR_STOP=1 --no-password --no-psqlrc -U supabase_admin -f "$postinit" +fi +`; diff --git a/apps/cli/src/legacy/commands/start/templates/db-schema.sql.ts b/apps/cli/src/legacy/shared/db-bootstrap/templates/db-schema.sql.ts similarity index 100% rename from apps/cli/src/legacy/commands/start/templates/db-schema.sql.ts rename to apps/cli/src/legacy/shared/db-bootstrap/templates/db-schema.sql.ts diff --git a/apps/cli/src/legacy/commands/start/templates/db-supabase.sql.ts b/apps/cli/src/legacy/shared/db-bootstrap/templates/db-supabase.sql.ts similarity index 100% rename from apps/cli/src/legacy/commands/start/templates/db-supabase.sql.ts rename to apps/cli/src/legacy/shared/db-bootstrap/templates/db-supabase.sql.ts diff --git a/apps/cli/src/legacy/commands/start/templates/db-webhook.sql.ts b/apps/cli/src/legacy/shared/db-bootstrap/templates/db-webhook.sql.ts similarity index 100% rename from apps/cli/src/legacy/commands/start/templates/db-webhook.sql.ts rename to apps/cli/src/legacy/shared/db-bootstrap/templates/db-webhook.sql.ts diff --git a/apps/cli/src/legacy/shared/legacy-bitbucket-pipeline.ts b/apps/cli/src/legacy/shared/legacy-bitbucket-pipeline.ts index 3fcdcc96bd..9cee8f5dad 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 (`commands/start/lib/container-lifecycle.ts`). + * creation (`legacy/shared/db-bootstrap/container-lifecycle.ts`). */ export function legacyIsBitbucketPipeline(): boolean { const value = globalThis.process.env["BITBUCKET_CLONE_DIR"]; 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 e811f7c17c..7c1e5f0bc2 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 - * `start/lib/docker-create-args.ts` (`docker create`) — shares one + * `legacy/shared/db-bootstrap/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-remove-all.ts b/apps/cli/src/legacy/shared/legacy-docker-remove-all.ts index ae4e8bd59a..b89753617c 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-remove-all.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-remove-all.ts @@ -17,7 +17,7 @@ type Spawner = ChildProcessSpawner["Service"]; * Failure taxonomy for {@link legacyDockerRemoveAll}. Each variant is a neutral, stage-tagged * cause carrying only a `.message` — same generalization pattern as `legacy-docker-lifecycle.ts`'s * `LegacyDockerLifecycleListError`/`LegacyDockerLifecycleInspectError`. Callers (`stop.handler.ts` - * via `Effect.catchTags`; `start.rollback.ts` via a blanket swallow) discriminate/consume these by + * via `Effect.catchTags`; `legacy/shared/db-bootstrap/rollback.ts` via a blanket swallow) discriminate/consume these by * their string `_tag`, never by importing the classes themselves, so only the union below is * exported — matching every constructor's actual usage (confirmed via `knip`). */ @@ -68,7 +68,7 @@ export type LegacyDockerRemoveAllError = * removal step below) has EXITED SUCCESSFULLY — not at the initial listing, and not before * containers are even stopped — with the exact containers that listing found, id/name/workdir * together. A TS-port-only hook with no Go equivalent, for callers (`stop.handler.ts`, - * `start.rollback.ts`) that need those same containers for {@link legacyCleanupStartSecrets} + * `legacy/shared/db-bootstrap/rollback.ts`) that need those same containers for {@link legacyCleanupStartSecrets} * (Go itself doesn't stage host-disk secrets, so it has no reason to know them). It exists so * those callers get this data from THIS function's own single `docker ps` listing instead of * issuing a second, separately-formatted `docker ps` call, which would double the real Docker diff --git a/apps/cli/src/legacy/shared/legacy-go-duration.ts b/apps/cli/src/legacy/shared/legacy-go-duration.ts index 8fbee02b37..628563c8af 100644 --- a/apps/cli/src/legacy/shared/legacy-go-duration.ts +++ b/apps/cli/src/legacy/shared/legacy-go-duration.ts @@ -209,3 +209,25 @@ export function legacyFormatGoDuration(nanoseconds: number): string { function formatFraction(totalNs: number, unitNs: number): string { return (totalNs / unitNs).toFixed(9).replace(/0+$/, "").replace(/\.$/, ""); } + +/** + * Go's `Db.HealthTimeout` (`apps/cli-go/internal/db/start/start.go:180`) — a duration STRING + * (`"2m"` default, `packages/config/src/db.ts`) decoded via `mapstructure. + * StringToTimeDurationHookFunc()` inside the same `v.UnmarshalExact` call every `SUPABASE_*` + * override goes through (`pkg/config/config.go:749-756,775-784`) — a malformed value hard-fails + * `Config.Load` (`"failed to parse config: %w"`) before either `start`/`db start` ever runs; it is + * never silently replaced with a default. A valid-but-degenerate value (e.g. `"0s"`) isn't + * special-cased either: Go's backoff policy computes `uint64(timeout.Seconds())` as the retry + * count (`internal/db/start/start.go:192-198`), and the backoff library returns `Stop` immediately + * when that count is `0` — i.e. exactly one immediate health probe with no wait, not a 30s + * fallback. Throws on a malformed value, matching `legacyParseGoDuration`; the caller wraps that + * into its own typed config-load-failure error so rollback/cleanup still fires (a plain throw here + * would surface as an Effect defect instead of a typed failure). + * + * Hoisted here (was private to `commands/start/start.handler.ts`) once + * `commands/db/start/start.handler.ts`'s own native container bootstrap became a second caller — + * see `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" rule. + */ +export function legacyResolveHealthTimeoutSeconds(healthTimeout: string): number { + return Math.trunc(legacyParseGoDuration(healthTimeout) / 1_000_000_000); +} diff --git a/apps/cli/src/legacy/shared/legacy-kong-auth.ts b/apps/cli/src/legacy/shared/legacy-kong-auth.ts index 6f41ef48a6..4b92aa18cb 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 - * (`commands/start/lib/health-check.ts`). + * (`legacy/shared/db-bootstrap/health-check.ts`). */ export function legacyKongAuthHeaders(apiKey: string): Readonly> { const isOpaqueServiceKey = apiKey.startsWith("sb_"); diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index d519c85cd6..60051ae5f2 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -1458,6 +1458,29 @@ function asRecord(value: unknown): Record | undefined { : undefined; } +/** + * `auth.external_url` isn't modeled in `@supabase/config`'s schema, so it's read off the raw + * document — same presence-based pattern as passkey/webauthn/external. Go's `auth.GetExternalURL` + * (`pkg/config/auth.go:401-405`) prefers this explicit value over deriving from `apiUrl`, and feeds + * it into `API_EXTERNAL_URL`, the mailer verify URL, the default JWT issuer, and OAuth redirect-URI + * fallbacks for `supabase start`'s long-running GoTrue container AND `db start`'s/`supabase + * start`'s fresh-DB one-shot auth migration job — every caller must resolve the SAME value, hence + * this single standalone helper instead of independent per-caller derivations. Hoisted here (was + * private to `start/start.handler.ts`) once `db/start/start.handler.ts`'s own native container + * bootstrap became a third caller — see `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" rule. + */ +export function legacyResolveAuthExternalUrl( + document: Readonly> | undefined, + projectEnvValues: Readonly> | undefined, +): string | undefined { + const rawAuthExternalUrl = asRecord(document?.["auth"])?.["external_url"]; + return legacyEnvOverride( + "SUPABASE_AUTH_EXTERNAL_URL", + typeof rawAuthExternalUrl === "string" ? rawAuthExternalUrl : undefined, + projectEnvValues, + ); +} + /** Go's `hook.validate()` hook-type iteration order (`pkg/config/config.go:1453-1485`), used * only to build {@link legacyResolveLocalConfigValues}'s `hooks` input in the right order — * the actual per-hook validation now lives in `legacyValidateResolvedConfig`. */ 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 bff5bee23f..f0511423d5 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/commands/start/lib/container-lifecycle.ts`) per-container + * (`legacy/shared/db-bootstrap/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 @@ -18,7 +18,7 @@ import type { LegacyContainerIdName } from "./legacy-docker-lifecycle.ts"; * Docker Engine API) — this is a TS-port-only hygiene fix. * * Hoisted here (`legacy/shared/`) per `apps/cli/CLAUDE.md`'s "Hoist Before - * You Duplicate" rule: both `start`'s own rollback (`start.rollback.ts`) and + * You Duplicate" rule: both `start`'s own rollback (`legacy/shared/db-bootstrap/rollback.ts`) and * `stop` (`stop.handler.ts`) need this same cleanup. * * Each container's own directory is resolved as `/supabase/.temp/ diff --git a/apps/cli/src/shared/cli/code-structure.unit.test.ts b/apps/cli/src/shared/cli/code-structure.unit.test.ts index 88d0d4d43c..6fb2c68da1 100644 --- a/apps/cli/src/shared/cli/code-structure.unit.test.ts +++ b/apps/cli/src/shared/cli/code-structure.unit.test.ts @@ -9,6 +9,7 @@ const legacyDir = path.join(srcDir, "legacy"); const sharedDir = path.join(srcDir, "shared"); const nextCommandsDir = path.join(nextDir, "commands"); const legacyCommandsDir = path.join(legacyDir, "commands"); +const legacyDbBootstrapDir = path.join(legacyDir, "shared", "db-bootstrap"); const nextCliDir = path.join(nextDir, "cli"); const legacyCliDir = path.join(legacyDir, "cli"); const nextDocsDir = path.join(nextDir, "docs"); @@ -146,6 +147,21 @@ describe("code structure", () => { expect(violations).toEqual([]); }); + it("keeps legacy/shared/db-bootstrap independent from legacy commands", () => { + const violations: Array = []; + + for (const filePath of walk(legacyDbBootstrapDir).filter(isSourceFile)) { + for (const specifier of extractRelativeImports(filePath)) { + const resolved = resolveImport(filePath, specifier); + if (resolved.startsWith(legacyCommandsDir)) { + violations.push(`${path.relative(srcDir, filePath)} -> ${specifier}`); + } + } + } + + expect(violations).toEqual([]); + }); + it("prevents next and legacy from importing each other", () => { const violations: Array = []; From 6953d0367adb646e3f888f4b32e2c2f02fc63d4d Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 31 Jul 2026 21:26:05 +0100 Subject: [PATCH 02/48] fix(cli): honor --experimental declarative schema files in db start bootstrap (review: PRRT_kwDOErm0O86VhJWm) Go's apply.MigrateAndSeed (internal/migration/apply/apply.go:16-26) applies db.migrations.schema_paths instead of migration files when --experimental is set, version is empty, and pg-delta is disabled. legacyMigrateAndSeed never ported that branch because its only prior caller (migration down) always passes a concrete version, making it provably unreachable there. CLI-1954's db-setup.ts is a new caller with version: "", making the branch reachable for both db start and (since the two share this helper) supabase start. Threads experimental through db-setup.ts -> start-database.ts -> both handlers, and ports Go's Glob.SQLFiles (directory expansion, sort, dedup) via a new legacyResolveSchemaPathFiles, reusing the fs.Glob port [db.seed] sql_paths already has (hoisted to legacy-glob.ts). --- .../legacy/commands/db/start/start.handler.ts | 10 +- .../db/start/start.integration.test.ts | 10 +- .../commands/migration/down/down.handler.ts | 7 + .../legacy/commands/start/start.handler.ts | 12 +- .../commands/start/start.integration.test.ts | 4 + .../legacy/shared/db-bootstrap/db-setup.ts | 15 +- .../shared/db-bootstrap/db-setup.unit.test.ts | 1 + .../shared/db-bootstrap/start-database.ts | 3 + apps/cli/src/legacy/shared/legacy-glob.ts | 71 +++++ .../legacy/shared/legacy-migrate-and-seed.ts | 154 ++++++++- .../legacy-migrate-and-seed.unit.test.ts | 294 ++++++++++++++++++ apps/cli/src/legacy/shared/legacy-seed.ts | 81 +---- 12 files changed, 582 insertions(+), 80 deletions(-) create mode 100644 apps/cli/src/legacy/shared/legacy-glob.ts create mode 100644 apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts 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 338c97234c..2c095544a1 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,10 @@ 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 { + LegacyNetworkIdFlag, + legacyResolveExperimentalWithProjectEnv, +} 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"; @@ -120,6 +123,10 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega (message) => new LegacyDbConfigLoadError({ message }), ); 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: () => @@ -229,6 +236,7 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega dbHealthTimeoutSeconds: bootstrapConfig.dbHealthTimeoutSeconds, setup: { majorVersion: bootstrapConfig.majorVersion, + experimental, config: { ...config, realtime: { 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 2de9e54886..6c6a6c3a62 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 @@ -18,7 +18,11 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; -import { LegacyNetworkIdFlag } from "../../../../shared/legacy/global-flags.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { + LegacyExperimentalFlag, + LegacyNetworkIdFlag, +} from "../../../../shared/legacy/global-flags.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; import { LegacyDbConnection, @@ -256,6 +260,8 @@ interface SetupOpts { readonly cwd?: string; readonly platform?: NodeJS.Platform; readonly networkId?: string; + /** `--experimental`/`SUPABASE_EXPERIMENTAL`. Defaults to `false`. */ + readonly experimental?: boolean; } function setup(opts: SetupOpts = {}) { @@ -294,6 +300,8 @@ function setup(opts: SetupOpts = {}) { LegacyNetworkIdFlag, opts.networkId === undefined ? Option.none() : Option.some(opts.networkId), ), + Layer.succeed(CliArgs, { args: ["db", "start"] }), + Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? false), ); return { layer, out, telemetry, child, dbSession }; } diff --git a/apps/cli/src/legacy/commands/migration/down/down.handler.ts b/apps/cli/src/legacy/commands/migration/down/down.handler.ts index 69c7fc46a0..5c386e6ef5 100644 --- a/apps/cli/src/legacy/commands/migration/down/down.handler.ts +++ b/apps/cli/src/legacy/commands/migration/down/down.handler.ts @@ -150,6 +150,13 @@ const runDown = Effect.fnUntraced(function* ( yield* legacyMigrateAndSeed(session, fs, path, cliConfig.workdir, version, { migrationsEnabled: toml.migrationsEnabled, seed: toml.seed, + // `version` is always non-empty here (`migration down` reverts to a concrete + // target) — Go's `len(version) == 0` half of `legacyMigrateAndSeed`'s declarative + // branch gate is therefore always false on this call site regardless of these + // three values, matching the file's own doc comment. + experimental: false, + pgDeltaEnabled: false, + schemaPaths: [], }); if (output.format !== "text") { diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index eed7293370..a845227551 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -16,7 +16,11 @@ import { toPlainFunctionRecord, type StartedRuntime, } from "../../../shared/functions/serve.ts"; -import { LegacyDebugFlag, LegacyNetworkIdFlag } from "../../../shared/legacy/global-flags.ts"; +import { + LegacyDebugFlag, + LegacyNetworkIdFlag, + legacyResolveExperimentalWithProjectEnv, +} from "../../../shared/legacy/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { Analytics } from "../../../shared/telemetry/analytics.service.ts"; @@ -683,6 +687,11 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta }), }); const { config, projectId, projectEnvValues } = 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 + // to `legacyStartDatabase`'s own `setup.experimental` below. + const experimental = yield* legacyResolveExperimentalWithProjectEnv(projectEnvValues); // Single source resolved once, fed to both Kong's template mounts and GoTrue's env builder — // see {@link legacyResolveAuthEmail}'s doc comment. const resolvedEmail = yield* Effect.try({ @@ -1828,6 +1837,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta dbHealthTimeoutSeconds, setup: { majorVersion, + experimental, // Go's `initSchema15`'s per-job gates read `utils.Config.{Realtime,Storage,Auth}. // Enabled` — the EFFECTIVE, env-overridden value (Viper's `AutomaticEnv` already // folds any `SUPABASE_*_ENABLED` override into the single global `Config`), NOT 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 7016f07643..26cf7e2510 100644 --- a/apps/cli/src/legacy/commands/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts @@ -26,6 +26,7 @@ import { import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { LegacyDebugFlag, + LegacyExperimentalFlag, LegacyNetworkIdFlag, LegacyYesFlag, } from "../../../shared/legacy/global-flags.ts"; @@ -355,6 +356,8 @@ interface SetupOpts { readonly workdir?: string; /** `--network-id` override. Defaults to unset (the generated `supabase_network_` name applies). */ readonly networkId?: Option.Option; + /** `--experimental`/`SUPABASE_EXPERIMENTAL`. Defaults to `false`. */ + readonly experimental?: boolean; } function setup(opts: SetupOpts = {}) { @@ -405,6 +408,7 @@ function setup(opts: SetupOpts = {}) { Layer.succeed(CliArgs, { args: ["start"] }), Layer.succeed(LegacyDebugFlag, false), Layer.succeed(LegacyYesFlag, false), + Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? false), Layer.succeed(LegacyNetworkIdFlag, opts.networkId ?? Option.none()), mockTty({ stdinIsTty: false }), mockStdin(false), 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 305b9c1f67..aa4f21fd9c 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -163,6 +163,13 @@ export interface LegacyStartSetupLocalDatabaseInput { readonly majorVersion: number; /** Go's `Config.ProjectId`, already sanitized (`legacySanitizeProjectId`) — derives the `db` container's internal Docker name for the PG15+ one-shot jobs (`legacyServiceContainerName("db", projectId)`, Go's `utils.DbId`). */ readonly projectId: string; + /** + * `--experimental`/`SUPABASE_EXPERIMENTAL`, resolved by the caller (Go's + * `viper.GetBool("EXPERIMENTAL")`) — threaded straight into + * {@link legacyMigrateAndSeed}'s own `experimental` gate (`internal/migration/apply/ + * apply.go:19`); this module has no other use for it. + */ + readonly experimental: boolean; /** The `start` run's Docker network id (Go's `utils.NetId` or the `--network-id` override) — every PG15+ one-shot job joins it, matching `DockerStart`'s own default (`docker.go:379-383`). */ readonly networkId: string; /** `LegacyLocalConfigValues.dbUrl` — reused (not recomputed) to derive the internal DB password via `legacyStartInternalDbPassword`, matching every other `start/services/*.service.ts` builder. */ @@ -626,10 +633,16 @@ export const legacyStartSetupLocalDatabase = ( // apply.MigrateAndSeed(ctx, "", conn, fsys) — empty version = every pending // migration, matching `SetupLocalDatabase`'s own call in the `start` context - // (start.go:368). + // (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, "", { migrationsEnabled: toml.migrationsEnabled, seed: toml.seed, + experimental: input.experimental, + pgDeltaEnabled: toml.pgDelta.enabled, + schemaPaths: input.config.db.migrations.schema_paths, }); // Go's best-effort pgcache catalog warning (`pgcache.TryCacheMigrationsCatalog`, 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 7c5cfbcb88..a9d7ae3757 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 @@ -107,6 +107,7 @@ function baseInput( session, workdir, config: defaultConfig, + experimental: false, majorVersion: 17, projectId: "proj", networkId: "supabase_network_proj", 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 00e70f4444..07aa056a63 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts @@ -132,6 +132,8 @@ 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. */ @@ -326,6 +328,7 @@ export const legacyStartDatabase = ( path: input.path, workdir: input.workdir, config: setup.config, + experimental: setup.experimental, majorVersion: setup.majorVersion, projectId: input.projectId, networkId: input.networkId, diff --git a/apps/cli/src/legacy/shared/legacy-glob.ts b/apps/cli/src/legacy/shared/legacy-glob.ts new file mode 100644 index 0000000000..22ee7a1756 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-glob.ts @@ -0,0 +1,71 @@ +import { Effect, type FileSystem, type Path } from "effect"; + +import { legacyPathMatch } from "./legacy-path-match.ts"; + +/** + * Hoisted `Config.Glob` filesystem-matching primitives — Go's `io/fs.Glob` (per-pattern + * matching) plus the workdir-relative-vs-absolute resolution `pkg/config/config.go`'s + * loader applies to every glob-shaped config field. Originally private to + * `legacy-seed.ts` (the first caller, `[db.seed] sql_paths`); hoisted here once + * `legacy-migrate-and-seed.ts`'s declarative-schema-files branch (`[db.migrations] + * schema_paths`, Go's `Glob.SQLFiles`) became a second caller — see `apps/cli/CLAUDE.md`'s + * "Hoist Before You Duplicate". + */ + +// Go's `io/fs.hasMeta` (`glob.go`): the magic-character set is `*`, `?`, `[`, and `\` +// (escape) — `\` counts so a pattern whose only glob syntax is a backslash escape (e.g. +// `foo\.sql`) is globbed via `legacyPathMatch` (which handles the escape) instead of being +// treated as a literal filename and missing the real file. Go applies `filepath.ToSlash` +// before globbing, so a `\` here is always a glob escape, never a path separator. +const legacyHasGlobMeta = (pattern: string): boolean => /[*?[\\]/u.test(pattern); + +// Go globs/reads glob-config paths through an OS-root-rooted `afero.NewOsFs`, where the +// CLI's "workdir" is just `os.Chdir(workdir)` (`internal/utils/misc.go`) — which only +// affects RELATIVE paths. An absolute glob-config entry, preserved verbatim by the config +// loader (`pkg/config/config.go`, gated on `!filepath.IsAbs`), therefore resolves at the OS +// root, never under the workdir. Mirror that: only join under the workdir when the path is +// relative (`path.join` would otherwise collapse `/repo` + `/tmp/seed.sql` to +// `/repo/tmp/seed.sql`). +export const legacyResolveUnderWorkdir = (path: Path.Path, workdir: string, p: string): string => + path.isAbsolute(p) ? p : path.join(workdir, p); + +/** + * Resolves a single glob pattern against the workdir, returning the matched paths RELATIVE + * to the workdir (so callers stay Go-compatible, e.g. `seed_files.path`). Mirrors Go's + * `fs.Glob`: a literal pattern (no glob metacharacter anywhere) returns itself iff it + * exists; a pattern with metacharacters lists each parent directory and matches per segment + * via `legacyPathMatch` (Go's `path.Match`). The caller validates the whole pattern up + * front (`legacyPathMatch(pattern, "").badPattern`), so a malformed class never reaches + * here. + */ +export const legacyGlobPattern = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + pattern: string, +): Effect.Effect> => + Effect.gen(function* () { + if (!legacyHasGlobMeta(pattern)) { + const exists = yield* fs + .exists(legacyResolveUnderWorkdir(path, workdir, pattern)) + .pipe(Effect.orElseSucceed(() => false)); + return exists ? [pattern] : []; + } + const slash = pattern.lastIndexOf("/"); + const dirPattern = slash === -1 ? "" : pattern.slice(0, slash); + const filePattern = slash === -1 ? pattern : pattern.slice(slash + 1); + const dirs = legacyHasGlobMeta(dirPattern) + ? yield* legacyGlobPattern(fs, path, workdir, dirPattern) + : [dirPattern]; + const result: Array = []; + for (const dir of dirs) { + const absDir = dir.length === 0 ? workdir : legacyResolveUnderWorkdir(path, workdir, dir); + const names = yield* fs.readDirectory(absDir).pipe(Effect.orElseSucceed(() => [])); + for (const name of names) { + if (legacyPathMatch(filePattern, name).matched) { + result.push(dir.length === 0 ? name : `${dir}/${name}`); + } + } + } + return result; + }); diff --git a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts index 809af32b95..4aef0c1256 100644 --- a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts +++ b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts @@ -1,24 +1,160 @@ -import { Effect, type FileSystem, type Path } from "effect"; +import { Effect, type FileSystem, type Path, Result } from "effect"; import { Output } from "../../shared/output/output.service.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; -import { LegacyMigrationApplyError, legacyApplyMigrationFile } from "./legacy-migration-apply.ts"; +import { legacyGlobPattern, legacyResolveUnderWorkdir } from "./legacy-glob.ts"; +import { + LegacyMigrationApplyError, + legacyApplyMigrationFile, + legacyExecSqlFile, +} from "./legacy-migration-apply.ts"; import { legacyLoadPartialMigrations } from "./legacy-migration-history.ts"; +import { LEGACY_BAD_PATTERN_MESSAGE, legacyPathMatch } from "./legacy-path-match.ts"; import { legacyApplySeedFiles, type LegacySeedConfig } from "./legacy-seed.ts"; /** Config consumed by `legacyMigrateAndSeed`. */ export interface LegacyMigrateAndSeedConfig { readonly migrationsEnabled: boolean; readonly seed: LegacySeedConfig; + /** + * `--experimental`/`SUPABASE_EXPERIMENTAL` (Go's `viper.GetBool("EXPERIMENTAL")`, + * `internal/migration/apply/apply.go:19`) — together with an empty `version` and + * `pgDeltaEnabled === false`, switches the branch below from applying migration files to + * applying `schemaPaths`'s declarative schema files instead. `migration down` (the other + * caller of this function) always passes a concrete `version`, so Go's `len(version) == 0` + * half of the same condition is already false there regardless of this field — see that + * call site's own comment for why a static value is safe. + */ + readonly experimental: boolean; + /** `[experimental.pgdelta] enabled` — Go's `utils.IsPgDeltaEnabled()`. See `experimental` above. */ + readonly pgDeltaEnabled: boolean; + /** `db.migrations.schema_paths` — Go's `Config.Db.Migrations.SchemaPaths`. Only read by the declarative branch above. */ + readonly schemaPaths: ReadonlyArray; } +/** + * Port of Go's `Glob.SQLFiles` (`pkg/config/config.go:122-128` → `files`/`walkMatchedDir`), + * called with zero `GlobOption`s exactly like `applySchemaFiles`'s own call + * (`internal/migration/apply/apply.go:52`): each `schemaPaths` pattern is glob-matched, in + * declared order, via {@link legacyGlobPattern} — the same `fs.Glob` port `[db.seed] + * sql_paths` already uses. A matched directory is expanded to its `.sql` regular files, + * recursively, sorted; a matched plain file is kept as-is — even a non-`.sql` one, since + * Go's `expandDir` callback only ever runs on `IsDir()` matches, never on an + * explicitly-matched file. Results are deduplicated across ALL patterns (first occurrence + * wins), preserving pattern declaration order. + * + * A pattern matching nothing is an error, but — mirroring `applySchemaFiles`'s `if + * len(declared) == 0 { return err }` — that error (and any stat/walk failure) is discarded + * outright whenever the combined result ends up non-empty regardless; it only surfaces when + * NO pattern matched anything at all. + */ +const legacyResolveSchemaPathFiles = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + patterns: ReadonlyArray, +): Effect.Effect, LegacyMigrationApplyError> => + Effect.gen(function* () { + const seen = new Set(); + const result: Array = []; + const problems: Array = []; + + for (const rawPattern of patterns) { + // Go's config loader resolves a relative `schema_paths` entry against the + // `supabase/` directory, not the project root (`pkg/config/config.go:976-978`, + // `path.Join(builder.SupabaseDirPath, pattern)`) — `@supabase/config`'s decoder + // doesn't perform this itself (unlike Go's own loader), so it happens here, the + // only current TS reader of this field that needs real filesystem paths. + const pattern = path.isAbsolute(rawPattern) ? rawPattern : `supabase/${rawPattern}`; + if (legacyPathMatch(pattern, "").badPattern) { + problems.push(`failed to glob files: ${LEGACY_BAD_PATTERN_MESSAGE}`); + continue; + } + const matches = [...(yield* legacyGlobPattern(fs, path, workdir, pattern))].sort(); + if (matches.length === 0) { + problems.push(`no files matched pattern: ${rawPattern}`); + continue; + } + for (const match of matches) { + const absMatch = legacyResolveUnderWorkdir(path, workdir, match); + const statResult = yield* fs.stat(absMatch).pipe(Effect.result); + if (Result.isFailure(statResult)) { + problems.push(`failed to stat matched file: ${match}`); + continue; + } + if (statResult.success.type !== "Directory") { + if (!seen.has(match)) { + seen.add(match); + result.push(match); + } + continue; + } + // Go's `walkMatchedDir`: recursively list the matched directory, keep only regular + // `.sql` files, sorted (a global sort over the full relative-to-fsys-root path, not + // per-directory — matches `sort.Strings(files)` running once after the whole walk). + const names = yield* fs + .readDirectory(absMatch, { recursive: true }) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + const sqlRelative = names + .map((name) => name.replaceAll("\\", "/")) + .filter((name) => name.endsWith(".sql")) + .sort(); + for (const relative of sqlRelative) { + const relativeToWorkdir = `${match}/${relative}`; + const absEntry = legacyResolveUnderWorkdir(path, workdir, relativeToWorkdir); + const entryStat = yield* fs.stat(absEntry).pipe(Effect.orElseSucceed(() => undefined)); + if (entryStat?.type !== "File") continue; + if (!seen.has(relativeToWorkdir)) { + seen.add(relativeToWorkdir); + result.push(relativeToWorkdir); + } + } + } + } + + if (result.length === 0 && problems.length > 0) { + return yield* Effect.fail(new LegacyMigrationApplyError({ message: problems.join("\n") })); + } + return result; + }); + +/** + * Port of Go's `applySchemaFiles` (`internal/migration/apply/apply.go:50-61`): applies + * every file resolved by {@link legacyResolveSchemaPathFiles} directly, in order, WITHOUT + * inserting a migration-history row (Go sets `schema.Version = ""` before `ExecBatch`) and + * WITHOUT creating the history table or resetting connection state first (`applySchemaFiles` + * calls `ExecBatch` directly on each file, unlike `applyMigrationFiles`'s + * `migration.ApplyMigrations`) — `legacyExecSqlFile` already has exactly this shape. + */ +const legacyApplySchemaFiles = ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + schemaPaths: ReadonlyArray, +) => + Effect.gen(function* () { + const declared = yield* legacyResolveSchemaPathFiles(fs, path, workdir, schemaPaths); + for (const relativePath of declared) { + const absPath = legacyResolveUnderWorkdir(path, workdir, relativePath); + yield* legacyExecSqlFile( + session, + fs, + path, + absPath, + (message) => new LegacyMigrationApplyError({ message }), + ); + } + }); + /** * Reapplies local migrations up to `version`, then runs seed files. Port of Go's - * `apply.MigrateAndSeed` (`internal/migration/apply/apply.go:16`) for the - * `version`-set path (the EXPERIMENTAL declarative `applySchemaFiles` branch is - * unreachable from `migration down`, which always passes a concrete version, so - * it is intentionally not ported). Migration apply is gated on - * `db.migrations.enabled`; seeding on `db.seed.enabled` (inside the seed helper). + * `apply.MigrateAndSeed` (`internal/migration/apply/apply.go:16-26`): when `experimental` is + * set, `version` is empty, and `pgDeltaEnabled` is false, the declarative `schemaPaths` + * files are applied INSTEAD of migration files (bypassing `migrationsEnabled` entirely — Go's + * `applySchemaFiles` has no such gate, only `applyMigrationFiles` does); otherwise migration + * apply is gated on `db.migrations.enabled` as before. Seeding (`db.seed.enabled`, inside the + * seed helper) always runs, on either branch. */ export const legacyMigrateAndSeed = ( session: LegacyDbSession, @@ -30,7 +166,9 @@ export const legacyMigrateAndSeed = ( ) => Effect.gen(function* () { const output = yield* Output; - if (config.migrationsEnabled) { + if (config.experimental && version.length === 0 && !config.pgDeltaEnabled) { + yield* legacyApplySchemaFiles(session, fs, path, workdir, config.schemaPaths); + } else if (config.migrationsEnabled) { const migrationsDir = path.join(workdir, "supabase", "migrations"); const pending = yield* legacyLoadPartialMigrations(fs, path, migrationsDir, version).pipe( Effect.mapError((cause) => new LegacyMigrationApplyError({ message: cause.message })), diff --git a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts new file mode 100644 index 0000000000..08f8a6493d --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts @@ -0,0 +1,294 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path } from "effect"; + +import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; +import { + legacyMigrateAndSeed, + type LegacyMigrateAndSeedConfig, +} from "./legacy-migrate-and-seed.ts"; + +function fakeSession() { + const execs: Array = []; + const session: LegacyDbSession = { + exec: (sql) => + Effect.sync(() => { + execs.push(sql); + }), + query: () => Effect.succeed([]), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }; + return { session, execs }; +} + +function makeWorkdir(): string { + return mkdtempSync(join(tmpdir(), "legacy-migrate-and-seed-")); +} + +function writeFile(workdir: string, relativePath: string, content: string): void { + const fullPath = join(workdir, relativePath); + mkdirSync(join(fullPath, ".."), { recursive: true }); + writeFileSync(fullPath, content); +} + +const baseConfig: LegacyMigrateAndSeedConfig = { + migrationsEnabled: true, + seed: { enabled: false, sqlPaths: [] }, + experimental: false, + pgDeltaEnabled: false, + schemaPaths: [], +}; + +const run = ( + workdir: string, + version: string, + config: LegacyMigrateAndSeedConfig, + session: LegacyDbSession, + out: ReturnType, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacyMigrateAndSeed(session, fs, path, workdir, version, config); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, out.layer))); + +describe("legacyMigrateAndSeed experimental declarative-schema branch", () => { + it.effect( + "applies schema_paths files instead of migrations when experimental is on, pg-delta is off, and version is empty", + () => { + const workdir = makeWorkdir(); + writeFile(workdir, "supabase/schemas/a.sql", "create table schema_marker ();"); + writeFile( + workdir, + "supabase/migrations/20240101000000_x.sql", + "create table migration_marker ();", + ); + const { session, execs } = fakeSession(); + const out = mockOutput(); + return run( + workdir, + "", + { + ...baseConfig, + experimental: true, + pgDeltaEnabled: false, + schemaPaths: ["schemas/a.sql"], + }, + session, + out, + ).pipe( + Effect.tap(() => + Effect.sync(() => { + expect(execs).toContain("create table schema_marker ()"); + expect(execs).not.toContain("create table migration_marker ()"); + // Go's `applyMigrationFiles` prints "Applying migration ...", which + // `applySchemaFiles` never does — confirms the migration branch didn't run too. + expect(out.rawChunks.map((c) => c.text).join("")).not.toContain("Applying migration"); + rmSync(workdir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "falls back to migration files when pg-delta is enabled, even with experimental on and an empty version", + () => { + const workdir = makeWorkdir(); + writeFile(workdir, "supabase/schemas/a.sql", "create table schema_marker ();"); + writeFile( + workdir, + "supabase/migrations/20240101000000_x.sql", + "create table migration_marker ();", + ); + const { session, execs } = fakeSession(); + const out = mockOutput(); + return run( + workdir, + "", + { + ...baseConfig, + experimental: true, + pgDeltaEnabled: true, + schemaPaths: ["schemas/a.sql"], + }, + session, + out, + ).pipe( + Effect.tap(() => + Effect.sync(() => { + expect(execs).toContain("create table migration_marker ()"); + expect(execs).not.toContain("create table schema_marker ()"); + rmSync(workdir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect("falls back to migration files when experimental is off", () => { + const workdir = makeWorkdir(); + writeFile(workdir, "supabase/schemas/a.sql", "create table schema_marker ();"); + writeFile( + workdir, + "supabase/migrations/20240101000000_x.sql", + "create table migration_marker ();", + ); + const { session, execs } = fakeSession(); + const out = mockOutput(); + return run( + workdir, + "", + { + ...baseConfig, + experimental: false, + pgDeltaEnabled: false, + schemaPaths: ["schemas/a.sql"], + }, + session, + out, + ).pipe( + Effect.tap(() => + Effect.sync(() => { + expect(execs).toContain("create table migration_marker ()"); + expect(execs).not.toContain("create table schema_marker ()"); + rmSync(workdir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect( + "falls back to migration files when a concrete version is passed, even with experimental on", + () => { + const workdir = makeWorkdir(); + writeFile(workdir, "supabase/schemas/a.sql", "create table schema_marker ();"); + writeFile( + workdir, + "supabase/migrations/20240101000000_x.sql", + "create table migration_marker ();", + ); + const { session, execs } = fakeSession(); + const out = mockOutput(); + return run( + workdir, + "20240101000000", + { + ...baseConfig, + experimental: true, + pgDeltaEnabled: false, + schemaPaths: ["schemas/a.sql"], + }, + session, + out, + ).pipe( + Effect.tap(() => + Effect.sync(() => { + expect(execs).toContain("create table migration_marker ()"); + expect(execs).not.toContain("create table schema_marker ()"); + rmSync(workdir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect("still seeds after the declarative-schema branch runs", () => { + const workdir = makeWorkdir(); + writeFile(workdir, "supabase/schemas/a.sql", "create table schema_marker ();"); + writeFile(workdir, "supabase/seed.sql", "insert into schema_marker default values;"); + const { session, execs } = fakeSession(); + const out = mockOutput(); + return run( + workdir, + "", + { + ...baseConfig, + experimental: true, + pgDeltaEnabled: false, + schemaPaths: ["schemas/a.sql"], + // Unlike `schemaPaths` (resolved against `supabase/` inside `legacyMigrateAndSeed` + // itself), `LegacySeedConfig.sqlPaths` is already `supabase/`-prefixed by its real + // caller (`legacy-db-config.toml-read.ts`) — see its own doc comment. + seed: { enabled: true, sqlPaths: ["supabase/seed.sql"] }, + }, + session, + out, + ).pipe( + Effect.tap(() => + Effect.sync(() => { + expect(execs).toContain("create table schema_marker ()"); + expect(execs).toContain("insert into schema_marker default values"); + rmSync(workdir, { recursive: true, force: true }); + }), + ), + ); + }); + + // Go's `TestGlobSQLFiles` (`pkg/config/config_test.go`) — same two scenarios, ported. + it.effect( + "expands a directory schema_paths entry to its .sql files, recursively, in declared order", + () => { + const workdir = makeWorkdir(); + writeFile(workdir, "supabase/schemas/z_function.sql", "select 1;"); + writeFile(workdir, "supabase/schemas/tables/a_table.sql", "select 2;"); + writeFile(workdir, "supabase/schemas/tables/nested/b_table.sql", "select 3;"); + writeFile(workdir, "supabase/schemas/tables/readme.md", "ignored"); + const { session, execs } = fakeSession(); + const out = mockOutput(); + return run( + workdir, + "", + { + ...baseConfig, + experimental: true, + schemaPaths: ["schemas/z_function.sql", "schemas/tables"], + }, + session, + out, + ).pipe( + Effect.tap(() => + Effect.sync(() => { + const order = execs.filter((sql) => sql.startsWith("select ")); + expect(order).toEqual(["select 1", "select 2", "select 3"]); + rmSync(workdir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect("deduplicates an explicit file also matched by a directory/glob pattern", () => { + const workdir = makeWorkdir(); + writeFile(workdir, "supabase/database/a.sql", "select 10;"); + writeFile(workdir, "supabase/database/b.sql", "select 20;"); + const { session, execs } = fakeSession(); + const out = mockOutput(); + return run( + workdir, + "", + { + ...baseConfig, + experimental: true, + schemaPaths: ["database/a.sql", "database", "database/*.sql"], + }, + session, + out, + ).pipe( + Effect.tap(() => + Effect.sync(() => { + const order = execs.filter((sql) => sql.startsWith("select ")); + // Each file applied exactly once, in sorted order — not once per matching pattern. + expect(order).toEqual(["select 10", "select 20"]); + rmSync(workdir, { recursive: true, force: true }); + }), + ), + ); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-seed.ts b/apps/cli/src/legacy/shared/legacy-seed.ts index dc67437c48..6bc7805a75 100644 --- a/apps/cli/src/legacy/shared/legacy-seed.ts +++ b/apps/cli/src/legacy/shared/legacy-seed.ts @@ -3,6 +3,7 @@ import { Data, Effect, FileSystem, Path } from "effect"; import { Output } from "../../shared/output/output.service.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; +import { legacyGlobPattern, legacyResolveUnderWorkdir } from "./legacy-glob.ts"; import { legacyCreateSeedTable, legacyReadSeedTable, @@ -33,64 +34,6 @@ interface LegacyPendingSeed { readonly dirty: boolean; } -// Go's `io/fs.hasMeta` magic-character set is `*`, `?`, `[`, and `\` (escape) — -// `glob.go` `hasMeta`. `\` must count so a pattern whose only meta syntax is a -// backslash escape (e.g. `foo\.sql`, `seed\*.sql`) is globbed via `legacyPathMatch` -// (which handles the escape) instead of being treated as a literal filename and -// missing the real file. Go applies `filepath.ToSlash` before globbing, so a `\` -// here is always a glob escape, never a path separator. -const hasMeta = (pattern: string): boolean => /[*?[\\]/u.test(pattern); - -// Go globs/reads seed paths through an OS-root-rooted `afero.NewOsFs`, where the -// CLI's "workdir" is just `os.Chdir(workdir)` (`internal/utils/misc.go`) — which -// only affects RELATIVE paths. An absolute `[db.seed].sql_paths` entry, preserved -// verbatim by the config loader (`pkg/config/config.go`, gated on `!filepath.IsAbs`), -// therefore resolves at the OS root, never under the workdir. Mirror that: only -// join under the workdir when the path is relative (`path.join` would otherwise -// collapse `/repo` + `/tmp/seed.sql` to `/repo/tmp/seed.sql`). -const resolveUnderWorkdir = (path: Path.Path, workdir: string, p: string): string => - path.isAbsolute(p) ? p : path.join(workdir, p); - -/** - * Resolves a single glob pattern against the workdir, returning the matched - * paths RELATIVE to the workdir (so `seed_files.path` stays Go-compatible). - * Mirrors Go's `fs.Glob`: a literal pattern returns itself iff it exists; a - * pattern with metacharacters lists each parent directory and matches per - * segment via `legacyPathMatch` (Go's `path.Match`). The caller validates the - * whole pattern up front, so a malformed class never reaches here. - */ -const globPattern = ( - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, - pattern: string, -): Effect.Effect> => - Effect.gen(function* () { - if (!hasMeta(pattern)) { - const exists = yield* fs - .exists(resolveUnderWorkdir(path, workdir, pattern)) - .pipe(Effect.orElseSucceed(() => false)); - return exists ? [pattern] : []; - } - const slash = pattern.lastIndexOf("/"); - const dirPattern = slash === -1 ? "" : pattern.slice(0, slash); - const filePattern = slash === -1 ? pattern : pattern.slice(slash + 1); - const dirs = hasMeta(dirPattern) - ? yield* globPattern(fs, path, workdir, dirPattern) - : [dirPattern]; - const result: Array = []; - for (const dir of dirs) { - const absDir = dir.length === 0 ? workdir : resolveUnderWorkdir(path, workdir, dir); - const names = yield* fs.readDirectory(absDir).pipe(Effect.orElseSucceed(() => [])); - for (const name of names) { - if (legacyPathMatch(filePattern, name).matched) { - result.push(dir.length === 0 ? name : `${dir}/${name}`); - } - } - } - return result; - }); - /** Go's `config.Glob.Files`: glob each pattern, sort, dedup; warn on bad/no-match. */ const resolveSeedFiles = ( fs: FileSystem.FileSystem, @@ -111,7 +54,7 @@ const resolveSeedFiles = ( unmatched.push(`failed to glob files: ${LEGACY_BAD_PATTERN_MESSAGE}`); continue; } - const matches = [...(yield* globPattern(fs, path, workdir, pattern))].sort(); + const matches = [...(yield* legacyGlobPattern(fs, path, workdir, pattern))].sort(); if (matches.length === 0) unmatched.push(`no files matched pattern: ${pattern}`); for (const match of matches) { if (!seen.has(match)) { @@ -155,14 +98,16 @@ export const legacyApplySeedFiles = ( const pending: Array = []; for (const relativePath of locals) { - const content = yield* fs.readFile(resolveUnderWorkdir(path, workdir, relativePath)).pipe( - Effect.mapError( - (cause) => - new LegacyMigrationSeedError({ - message: `failed to open seed file: ${cause.message}`, - }), - ), - ); + const content = yield* fs + .readFile(legacyResolveUnderWorkdir(path, workdir, relativePath)) + .pipe( + Effect.mapError( + (cause) => + new LegacyMigrationSeedError({ + message: `failed to open seed file: ${cause.message}`, + }), + ), + ); const hash = createHash("sha256").update(content).digest("hex"); const previous = applied.get(relativePath); if (previous === hash) continue; // unchanged → skip entirely @@ -201,7 +146,7 @@ export const legacyApplySeedFiles = ( ? [] : legacySplitAndTrim( new TextDecoder().decode( - yield* fs.readFile(resolveUnderWorkdir(path, workdir, seed.path)).pipe( + yield* fs.readFile(legacyResolveUnderWorkdir(path, workdir, seed.path)).pipe( Effect.mapError( (cause) => new LegacyMigrationSeedError({ From 0646831b9b22081b9716e78a898da8946835c72f Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 31 Jul 2026 21:26:14 +0100 Subject: [PATCH 03/48] fix(cli): route SIGINT/SIGTERM through the global handler for db start (review: PRRT_kwDOErm0O86VhJWp) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ["db", "start"] stayed in run.ts's selfManagedSignalCommands from when it delegated to the hidden `db __db-bootstrap --mode start` Go seam, which held SIGINT/SIGTERM itself. CLI-1954 removes that delegation, but the native legacyDbStart/legacyStartDatabase installs no signal handling of its own — leaving the exemption in place meant Ctrl-C mid-bring-up hard-killed the process, skipping legacyRollbackStart entirely. Same fix top-level `start` already got when it went native: rely on the global signal-interrupt wrapper's Fiber.interrupt, which drives the same Effect.onError(() => legacyRollbackStart(...)) wrapper both callers of legacyStartDatabase already use. --- apps/cli/src/shared/cli/run.ts | 10 +++++++++- apps/cli/src/shared/cli/run.unit.test.ts | 4 ++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index 0ee530f2d3..55bb3ae6cf 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -64,8 +64,16 @@ const globalFlagsWithValues = new Set([ // (a completely different command tree that happens to share the literal path `["start"]`) // needs its OWN exemption, passed via `RunCliOptions.additionalSelfManagedSignalCommands` from // `next/cli/main.ts` — see that call site's comment for why. +// +// `["db", "start"]` (top-level `db start`) is ALSO deliberately not listed here, for the exact +// same reason as `start` above: it used to proxy container bootstrap to the hidden Go +// `db __db-bootstrap --mode start` seam, which held SIGINT/SIGTERM itself, but CLI-1954's +// native port (`legacy/commands/db/start/start.handler.ts` -> `legacyStartDatabase`) installs +// no signal handling of its own — it relies on the SAME `Effect.onError(() => +// 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", "start"], // `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. diff --git a/apps/cli/src/shared/cli/run.unit.test.ts b/apps/cli/src/shared/cli/run.unit.test.ts index e337427026..0189a5f5c4 100644 --- a/apps/cli/src/shared/cli/run.unit.test.ts +++ b/apps/cli/src/shared/cli/run.unit.test.ts @@ -47,7 +47,6 @@ describe("extractCommandPath", () => { describe("shouldUseGlobalSignalInterrupt", () => { it("opts out for self-managed signal commands, even behind global flags", () => { expect(shouldUseGlobalSignalInterrupt(["functions", "serve"])).toBe(false); - expect(shouldUseGlobalSignalInterrupt(["db", "start"])).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); @@ -56,11 +55,12 @@ describe("shouldUseGlobalSignalInterrupt", () => { ).toBe(false); }); - it("opts in for ordinary commands, including native start (it 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 (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)", () => { 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); expect(shouldUseGlobalSignalInterrupt([])).toBe(true); }); From 4a0f16a2e0690f6ebb22f54d47a8b7b3beee3f7c Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 31 Jul 2026 21:26:26 +0100 Subject: [PATCH 04/48] docs(cli): document the remote-Docker-daemon caveat of secretFiles bind mounts (review: PRRT_kwDOErm0O86VhJWs) secretFiles stages a secret to a HOST temp file and bind-mounts it into the container (avoiding a docker-create-argv exposure problem, CWE-214/522) - already the mechanism supabase start's PG15+ path, kong.service.ts, and supavisor.service.ts all share since before CLI-1954. Docker resolves a bind mount's source against the daemon host, not the client, so a remote DOCKER_HOST/context (which legacyGetHostname elsewhere in this codebase explicitly supports) would see a missing path, unlike Go's own heredoc/Cmd- embed delivery (no host path at all). Fixing this for real means changing how every secretFiles caller creates its container (e.g. docker cp into a created-but-not-started container instead of a bind mount) - a cross-service redesign out of scope for db start's own bootstrap port. Documenting the trade-off explicitly here so it is a tracked, deliberate limitation rather than a silent one. --- .../shared/db-bootstrap/docker-create-args.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts b/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts index 349fb8e901..5e3d18fd53 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts @@ -165,6 +165,25 @@ export interface LegacyStartContainerSpec { * then removes the temp file/directory once the container is created and * started. Generic by design — any future service's spec can set this, not * just the three call sites that need it today. + * + * Known, accepted limitation (pre-existing — not introduced by CLI-1954's `db start` + * port, which only extends the SAME already-shared mechanism to one more Postgres + * entrypoint variant): the generated bind mount's host-side path must be visible to + * whichever machine the DOCKER DAEMON itself runs on, not just this CLI process — + * Docker's bind mounts are resolved daemon-side + * (https://docs.docker.com/engine/storage/bind-mounts/#considerations-and-constraints). + * A `DOCKER_HOST`/Docker-context pointing at a remote daemon (a scenario this codebase + * otherwise explicitly supports — see `legacy-hostname.ts`'s `legacyGetHostname`) would + * see a missing or wrong path there, even though the daemon itself is reachable. Go's + * own heredoc/`Cmd`-embed delivery has no such requirement (the content travels inside + * the container-create request itself, over the Engine API), so this is a genuine, + * Go-parity-relevant gap for that scenario — not merely a stylistic difference. A fix + * (e.g. `docker cp`-ing the secret into a created-but-not-yet-started container instead + * of bind-mounting a host path — `docker cp` streams file content over the same + * connection, so it works against a remote daemon too) would need to change how EVERY + * `secretFiles` caller's container gets created, not just Postgres's — out of scope for + * a single command's bootstrap port; tracked as a known gap here rather than fixed + * silently or left undocumented. */ readonly secretFiles?: ReadonlyArray; /** From 2fcadb53b5f7e33599ec18e9f51c473d9e87a085 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 31 Jul 2026 21:29:56 +0100 Subject: [PATCH 05/48] docs(cli): document the --experimental declarative schema branch in SIDE_EFFECTS.md Follow-up to the legacyMigrateAndSeed fix (review: PRRT_kwDOErm0O86VhJWm): both db start's and supabase start's SIDE_EFFECTS.md were missing the new observable behavior (schema_paths files read/applied instead of migrations, and the SUPABASE_EXPERIMENTAL/--experimental env dependency) per this repo's side-effect documentation requirement. --- apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md | 9 +++++++-- apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md | 8 +++++++- 2 files changed, 14 insertions(+), 3 deletions(-) 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 00f9ec0cf6..4a3c753cc0 100644 --- a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md @@ -42,8 +42,11 @@ primitives (the same ones `supabase start` uses for its own Postgres bring-up): pipeline (`legacy/shared/db-bootstrap/db-setup.ts`) — initial schema (PG<=14: SQL over a direct `LegacyDbConnection`; PG>=15: up to three one-shot `docker run --rm` migrate jobs for realtime/storage/auth, each gated on its own `enabled` flag), API-privilege - revocation, `[db.vault]` secret upsert, `supabase/roles.sql` seed, and every pending - migration + seed. Skipped IN FULL when `--from-backup` is set (not merely reduced). + revocation, `[db.vault]` secret upsert, `supabase/roles.sql` seed, and finally either every + pending migration + seed, OR — when `--experimental`/`SUPABASE_EXPERIMENTAL` is set AND + `[experimental.pgdelta] enabled` is false — every `db.migrations.schema_paths` file + (declarative schema files) instead of migrations, followed by seed either way (Go's + `apply.MigrateAndSeed`). Skipped IN FULL when `--from-backup` is set (not merely reduced). 8. Write `supabase/.branches/_current_branch` = `"main"` if absent — runs on EVERY path that reaches this point (fresh volume, existing volume, and a swallowed `--from-backup` health-check timeout), but NOT on the already-running short-circuit or @@ -67,6 +70,7 @@ on any `StartDatabase` failure. | `/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 | @@ -122,6 +126,7 @@ native container command in this codebase — never `supabase-go`. | `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`) forces every created container/network onto that Docker network instead of the generated diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md index 4c535a2df2..8cef17a6b9 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -42,7 +42,11 @@ session; PG>=15: runs three one-shot `LegacyDockerRun` jobs instead, gated indep seeds `supabase/roles.sql`: matching Go's own print-before-read ordering (`pkg/migration/seed.go:88`), the `Seeding globals from roles.sql...` stderr line always prints, whether or not the file exists — a missing file is silently tolerated (no SQL runs), -any other read/exec error still fails the run. Finally runs every pending migration + seed. +any other read/exec error still fails the run. Finally runs every pending migration + seed — +UNLESS `--experimental`/`SUPABASE_EXPERIMENTAL` is set and `[experimental.pgdelta] enabled` +is false, in which case `db.migrations.schema_paths` files are applied INSTEAD of +`migrations/*.sql` (Go's `apply.MigrateAndSeed`, `internal/migration/apply/apply.go:19-26`); +seed still runs either way. A failure at any step rolls back the whole `start` run (same as any other bring-up failure). `legacyStartInitCurrentBranch` (writes `supabase/.branches/_current_branch` = `"main"` if @@ -88,6 +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/.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 | @@ -159,6 +164,7 @@ not implemented. | 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 | From e7d0d95e85362d49684c109863f7d055e8fb98de Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 31 Jul 2026 22:09:36 +0100 Subject: [PATCH 06/48] fix(cli): resolve db.migrations.schema_paths via the toml reader to match Go parity (review: PRRT_kwDOErm0O86Vh_lq, PRRT_kwDOErm0O86Vh_ly, PRRT_kwDOErm0O86Vh_lu) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire `schema_paths` through `legacyCheckDbToml`/`legacy-db-config.toml-read.ts` the same way `db.seed.sql_paths` already is, instead of reading the raw, unresolved `ProjectConfig` value in db-setup.ts: - Honor `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` (Go's viper AutomaticEnv, config.go:494-498) and the matched `[remotes.*]` override tier, matching every sibling `db.migrations`/`db.seed` field. - Resolve each relative pattern with Go's `path.Join(builder.SupabaseDirPath, pattern)` semantics (config.go:976-978), which cleans `.`/`..` segments — `legacyResolveSchemaPathFiles` no longer does its own naive `supabase/${pattern}` string-prefixing, so `./schemas/a.sql` and `schemas/a.sql` now collapse to the same glob pattern instead of aliasing as two different ones and applying the file twice. - Propagate a declarative-directory read/walk failure as a `problems` entry (Go's `walkMatchedDir`'s "failed to walk matched directory: %w") instead of silently treating an unreadable matched directory as empty — a fresh `db start` could previously report success while skipping an intended schema directory entirely. --- .../legacy/shared/db-bootstrap/db-setup.ts | 8 +- .../shared/legacy-db-config.toml-read.ts | 48 +++++++++- .../legacy-db-config.toml-read.unit.test.ts | 94 +++++++++++++++++++ .../legacy/shared/legacy-migrate-and-seed.ts | 47 ++++++---- .../legacy-migrate-and-seed.unit.test.ts | 66 ++++++++++--- 5 files changed, 223 insertions(+), 40 deletions(-) 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..27fe52c5b2 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -635,14 +635,16 @@ export const legacyStartSetupLocalDatabase = ( // 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. + // doc comment; `toml.pgDelta.enabled` and `toml.schemaPaths` are this module's own + // already-loaded config (the latter already resolved + `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` + // env-overridden by `legacyCheckDbToml`, `legacy-db-config.toml-read.ts`), not re-read from + // the caller's raw, unresolved `ProjectConfig`. yield* legacyMigrateAndSeed(session, fs, path, workdir, "", { migrationsEnabled: toml.migrationsEnabled, seed: toml.seed, experimental: input.experimental, pgDeltaEnabled: toml.pgDelta.enabled, - schemaPaths: input.config.db.migrations.schema_paths, + schemaPaths: toml.schemaPaths, }); // Go's best-effort pgcache catalog warning (`pgcache.TryCacheMigrationsCatalog`, diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index 77c95dd3b0..2ee909ae3f 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -98,6 +98,13 @@ interface LegacyDbTomlValues { readonly baseline: LegacyBaselineTomlConfig; /** `[db.migrations] enabled` (default true) — gates `up`/`down` migration apply. */ readonly migrationsEnabled: boolean; + /** + * `[db.migrations] schema_paths`, default `[]` — resolved (supabase-prefixed when + * relative, Go's `path.Join`/`path.Clean`) and `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` + * env-overridable exactly like `seed.sqlPaths` below. Only consumed by the + * `--experimental` declarative-schema-files branch of `legacyMigrateAndSeed`. + */ + readonly schemaPaths: ReadonlyArray; /** `[db.seed]` enabled + supabase-prefixed `sql_paths` globs — used by `down`. */ readonly seed: LegacyDbSeedTomlConfig; /** `[db.vault]` secrets (name → resolved value) — upserted by `up`/`down`. */ @@ -174,6 +181,8 @@ const DEFAULT_SHADOW_PORT = 54320; const DEFAULT_MAJOR_VERSION = 17; const DEFAULT_PASSWORD = "postgres"; const DEFAULT_API_SCHEMAS = ["public", "graphql_public"] as const; +/** `[db.migrations] schema_paths` default — Go's `Glob` zero value (`pkg/config/db.go:101`). */ +const DEFAULT_SCHEMA_PATHS: ReadonlyArray = []; /** `[edge_runtime] deno_version` default (`config.toml` template). 2 → the current edge-runtime image. */ const DEFAULT_DENO_VERSION = 2; @@ -283,6 +292,7 @@ const LEGACY_ENV_OVERRIDABLE_KEYS: ReadonlyArray = [ "db.shadow_port", "db.major_version", "db.migrations.enabled", + "db.migrations.schema_paths", "db.seed.enabled", "db.seed.sql_paths", "auth.enabled", @@ -516,11 +526,14 @@ function legacyJoinSupabaseSeedPath(pattern: string): string { } /** - * Resolves a single seed `sql_paths` entry to Go's config-load form: a relative - * pattern is joined under `supabase/` (Go's `path.Join`, `config.go:918-921`); an - * absolute (or empty) pattern is returned verbatim. Used by the reader for - * `[db.seed].sql_paths` and by `db reset` for its `--sql-paths` override (Go's - * `resolveSeedSqlPaths`, `cmd/db.go`) so both feed the glob the same resolved paths. + * Resolves a single seed/schema-paths entry to Go's config-load form: a relative + * pattern is joined under `supabase/` (Go's `path.Join`, `config.go:918-921` for + * `db.seed.sql_paths`, `config.go:976-978` for `db.migrations.schema_paths` — both + * fields go through the identical `path.Join(builder.SupabaseDirPath, pattern)` + * call); an absolute (or empty) pattern is returned verbatim. Used by the reader for + * `[db.seed].sql_paths` and `[db.migrations].schema_paths`, and by `db reset` for its + * `--sql-paths` override (Go's `resolveSeedSqlPaths`, `cmd/db.go`) — all three feed + * the glob the same resolved paths. */ export const legacyResolveSeedSqlPath = (pathSvc: Path.Path, pattern: string): string => pattern.length === 0 || pathSvc.isAbsolute(pattern) @@ -1804,6 +1817,30 @@ const readDbTomlCore = Effect.fnUntraced(function* ( ? undefined : envOverride("SUPABASE_DB_MIGRATIONS_ENABLED"), ); + // `[db.migrations] schema_paths` — Go default `[]`; overridable by + // `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` via viper AutomaticEnv (`config.go:494-498`) — EXCEPT + // when the matched remote block explicitly set it, same tiering as every other field in + // `LEGACY_ENV_OVERRIDABLE_KEYS`. A STRING value (the env override, or a TOML string) is + // env-expanded then comma-split; a TOML ARRAY is expanded element-by-element with no + // re-split (`resolveStringSlice`, shared with `api.schemas`). Each resulting pattern is then + // resolved to Go's config-load form (`path.Join(builder.SupabaseDirPath, pattern)`, + // `config.go:976-978`) via the same `legacyResolveSeedSqlPath` helper `db.seed.sql_paths` uses + // below — this is the only current TS reader of this field that needs real, Go-path-cleaned + // filesystem paths, so resolution happens here rather than in the declarative-schema-files + // consumer (`legacy-migrate-and-seed.ts`), matching where `seedSqlPaths` is resolved. + const rawSchemaPaths = + (remoteOverrideKeys.has("db.migrations.schema_paths") + ? undefined + : envOverride("SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS")) ?? migrationsRaw?.["schema_paths"]; + const schemaPathPatterns = resolveStringSlice(rawSchemaPaths, DEFAULT_SCHEMA_PATHS, lookup); + if (schemaPathPatterns === undefined) { + return yield* Effect.fail( + new LegacyDbConfigLoadError({ + message: "failed to parse config: invalid db.migrations.schema_paths.", + }), + ); + } + const schemaPaths = schemaPathPatterns.map((pattern) => legacyResolveSeedSqlPath(path, pattern)); // `[db.seed]` — Go defaults enabled true, sql_paths ["seed.sql"]; relative // patterns are supabase-prefixed (`config.go:801-806`). `db.seed.enabled` is @@ -1942,6 +1979,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( vaultNames, }, migrationsEnabled, + schemaPaths, seed: { enabled: seedEnabled, sqlPaths: seedSqlPaths }, vault, appliedRemote, diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts index 17a6d1c4f9..ca170d27d8 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts @@ -398,6 +398,100 @@ describe("legacyReadDbToml", () => { ); }); + it.effect("collapses . and .. in relative db.migrations.schema_paths like Go's path.Join", () => { + // Go prefixes each relative pattern with `path.Join("supabase", pattern)` + // (`config.go:976-978`), which runs `path.Clean` — same helper `db.seed.sql_paths` + // uses above (`legacyResolveSeedSqlPath`), so `./schemas/a.sql` and `schemas/a.sql` + // resolve to the identical string instead of aliasing as two different glob patterns. + const dir = withConfig( + [ + "[db.migrations]", + 'schema_paths = ["../schema.sql", "sub/../other.sql", "./schemas/a.sql"]', + "", + ].join("\n"), + ); + return read(dir).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.schemaPaths).toEqual([ + "schema.sql", + "supabase/other.sql", + "supabase/schemas/a.sql", + ]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect( + "honors SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS over the TOML array (comma split, no trim)", + () => { + const previous = process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"]; + process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"] = "a.sql, b.sql"; + const dir = withConfig(["[db.migrations]", 'schema_paths = ["ignored.sql"]', ""].join("\n")); + return read(dir).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.schemaPaths).toEqual(["supabase/a.sql", "supabase/ b.sql"]); + }), + ), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"]; + else process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"] = previous; + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect("defaults db.migrations.schema_paths to [] when absent (Go's Glob zero value)", () => { + const dir = withConfig(["[db]", "port = 54322", ""].join("\n")); + return read(dir).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.schemaPaths).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect( + "an explicit remote db.migrations.schema_paths beats SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS", + () => { + // Same override-tier precedence as db.migrations.enabled above (config.go:635-637). + const ref = "abcdefghijklmnopqrst"; + const previous = process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"]; + process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"] = "env-wins.sql"; + const dir = withConfig( + [ + "[remotes.prod]", + `project_id = "${ref}"`, + "[remotes.prod.db.migrations]", + 'schema_paths = ["remote-wins.sql"]', + "", + ].join("\n"), + ); + return readRef(dir, ref).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.schemaPaths).toEqual(["supabase/remote-wins.sql"]); + }), + ), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"]; + else process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"] = previous; + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + it.effect("an explicit remote experimental.pgdelta.enabled beats its SUPABASE_* env var", () => { // Go's mergeRemoteConfig applies EVERY matched-block key via v.Set (above AutomaticEnv, // config.go:635-637), not just db/seed — so a remote experimental.pgdelta.enabled wins diff --git a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts index 4aef0c1256..306f839a32 100644 --- a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts +++ b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts @@ -37,16 +37,22 @@ export interface LegacyMigrateAndSeedConfig { * called with zero `GlobOption`s exactly like `applySchemaFiles`'s own call * (`internal/migration/apply/apply.go:52`): each `schemaPaths` pattern is glob-matched, in * declared order, via {@link legacyGlobPattern} — the same `fs.Glob` port `[db.seed] - * sql_paths` already uses. A matched directory is expanded to its `.sql` regular files, - * recursively, sorted; a matched plain file is kept as-is — even a non-`.sql` one, since - * Go's `expandDir` callback only ever runs on `IsDir()` matches, never on an - * explicitly-matched file. Results are deduplicated across ALL patterns (first occurrence - * wins), preserving pattern declaration order. + * sql_paths` already uses. Patterns arrive already resolved to Go's config-load form + * (supabase-prefixed and `path.Clean`-ed when relative) — `legacyCheckDbToml` + * (`legacy-db-config.toml-read.ts`) does that once, at config-load time, the same place + * `db.seed.sql_paths` is resolved, so this function (unlike an earlier version of this + * comment) does no path-shape work of its own. A matched directory is expanded to its + * `.sql` regular files, recursively, sorted; a matched plain file is kept as-is — even a + * non-`.sql` one, since Go's `expandDir` callback only ever runs on `IsDir()` matches, + * never on an explicitly-matched file. Results are deduplicated across ALL patterns + * (first occurrence wins), preserving pattern declaration order. * - * A pattern matching nothing is an error, but — mirroring `applySchemaFiles`'s `if - * len(declared) == 0 { return err }` — that error (and any stat/walk failure) is discarded - * outright whenever the combined result ends up non-empty regardless; it only surfaces when - * NO pattern matched anything at all. + * A pattern matching nothing, a stat failure, or a directory-walk failure is an error, + * but — mirroring `applySchemaFiles`'s `if len(declared) == 0 { return err }` (the error + * `Glob.SQLFiles` returns alongside a non-empty `declared` is joined from every + * problem, including `walkMatchedDir`'s) — every such problem is discarded outright + * whenever the combined result ends up non-empty regardless; they only surface when NO + * pattern matched anything at all. */ const legacyResolveSchemaPathFiles = ( fs: FileSystem.FileSystem, @@ -59,20 +65,14 @@ const legacyResolveSchemaPathFiles = ( const result: Array = []; const problems: Array = []; - for (const rawPattern of patterns) { - // Go's config loader resolves a relative `schema_paths` entry against the - // `supabase/` directory, not the project root (`pkg/config/config.go:976-978`, - // `path.Join(builder.SupabaseDirPath, pattern)`) — `@supabase/config`'s decoder - // doesn't perform this itself (unlike Go's own loader), so it happens here, the - // only current TS reader of this field that needs real filesystem paths. - const pattern = path.isAbsolute(rawPattern) ? rawPattern : `supabase/${rawPattern}`; + for (const pattern of patterns) { if (legacyPathMatch(pattern, "").badPattern) { problems.push(`failed to glob files: ${LEGACY_BAD_PATTERN_MESSAGE}`); continue; } const matches = [...(yield* legacyGlobPattern(fs, path, workdir, pattern))].sort(); if (matches.length === 0) { - problems.push(`no files matched pattern: ${rawPattern}`); + problems.push(`no files matched pattern: ${pattern}`); continue; } for (const match of matches) { @@ -92,10 +92,17 @@ const legacyResolveSchemaPathFiles = ( // Go's `walkMatchedDir`: recursively list the matched directory, keep only regular // `.sql` files, sorted (a global sort over the full relative-to-fsys-root path, not // per-directory — matches `sort.Strings(files)` running once after the whole walk). - const names = yield* fs + // A read/walk failure is Go's `failed to walk matched directory: %w` — recorded as a + // problem (not silently treated as an empty directory) so it surfaces exactly like + // Go's joined error does whenever nothing else matched anything either. + const namesResult = yield* fs .readDirectory(absMatch, { recursive: true }) - .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); - const sqlRelative = names + .pipe(Effect.result); + if (Result.isFailure(namesResult)) { + problems.push(`failed to walk matched directory: ${match}`); + continue; + } + const sqlRelative = namesResult.success .map((name) => name.replaceAll("\\", "/")) .filter((name) => name.endsWith(".sql")) .sort(); diff --git a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts index 08f8a6493d..9f079d64f4 100644 --- a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts @@ -1,9 +1,9 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path } from "effect"; +import { Effect, Exit, FileSystem, Layer, Path } from "effect"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; @@ -12,6 +12,9 @@ import { type LegacyMigrateAndSeedConfig, } from "./legacy-migrate-and-seed.ts"; +// Root bypasses POSIX permission bits, so chmod 000 wouldn't block readdir() there. +const isRoot = typeof process.getuid === "function" && process.getuid() === 0; + function fakeSession() { const execs: Array = []; const session: LegacyDbSession = { @@ -78,7 +81,7 @@ describe("legacyMigrateAndSeed experimental declarative-schema branch", () => { ...baseConfig, experimental: true, pgDeltaEnabled: false, - schemaPaths: ["schemas/a.sql"], + schemaPaths: ["supabase/schemas/a.sql"], }, session, out, @@ -116,7 +119,7 @@ describe("legacyMigrateAndSeed experimental declarative-schema branch", () => { ...baseConfig, experimental: true, pgDeltaEnabled: true, - schemaPaths: ["schemas/a.sql"], + schemaPaths: ["supabase/schemas/a.sql"], }, session, out, @@ -149,7 +152,7 @@ describe("legacyMigrateAndSeed experimental declarative-schema branch", () => { ...baseConfig, experimental: false, pgDeltaEnabled: false, - schemaPaths: ["schemas/a.sql"], + schemaPaths: ["supabase/schemas/a.sql"], }, session, out, @@ -183,7 +186,7 @@ describe("legacyMigrateAndSeed experimental declarative-schema branch", () => { ...baseConfig, experimental: true, pgDeltaEnabled: false, - schemaPaths: ["schemas/a.sql"], + schemaPaths: ["supabase/schemas/a.sql"], }, session, out, @@ -212,10 +215,10 @@ describe("legacyMigrateAndSeed experimental declarative-schema branch", () => { ...baseConfig, experimental: true, pgDeltaEnabled: false, - schemaPaths: ["schemas/a.sql"], - // Unlike `schemaPaths` (resolved against `supabase/` inside `legacyMigrateAndSeed` - // itself), `LegacySeedConfig.sqlPaths` is already `supabase/`-prefixed by its real - // caller (`legacy-db-config.toml-read.ts`) — see its own doc comment. + schemaPaths: ["supabase/schemas/a.sql"], + // Both `schemaPaths` and `LegacySeedConfig.sqlPaths` arrive already + // `supabase/`-prefixed by their real caller (`legacy-db-config.toml-read.ts`) — see + // its own doc comment. Neither field does its own path-shape work anymore. seed: { enabled: true, sqlPaths: ["supabase/seed.sql"] }, }, session, @@ -248,7 +251,7 @@ describe("legacyMigrateAndSeed experimental declarative-schema branch", () => { { ...baseConfig, experimental: true, - schemaPaths: ["schemas/z_function.sql", "schemas/tables"], + schemaPaths: ["supabase/schemas/z_function.sql", "supabase/schemas/tables"], }, session, out, @@ -276,7 +279,7 @@ describe("legacyMigrateAndSeed experimental declarative-schema branch", () => { { ...baseConfig, experimental: true, - schemaPaths: ["database/a.sql", "database", "database/*.sql"], + schemaPaths: ["supabase/database/a.sql", "supabase/database", "supabase/database/*.sql"], }, session, out, @@ -291,4 +294,43 @@ describe("legacyMigrateAndSeed experimental declarative-schema branch", () => { ), ); }); + + it.effect.skipIf(isRoot)( + "fails a matched schema_paths directory that cannot be traversed, instead of treating it as empty", + () => { + // Go's `walkMatchedDir` returns `failed to walk matched directory: %w` on a read + // error; `applySchemaFiles` propagates it when nothing else matched either. Mode + // 000 makes `stat` (parent-directory lookup) succeed but `readdir` fail with EACCES. + const workdir = makeWorkdir(); + const lockedDir = join(workdir, "supabase", "schemas", "locked"); + mkdirSync(lockedDir, { recursive: true }); + writeFileSync(join(lockedDir, "b.sql"), "select 1;"); + chmodSync(lockedDir, 0o000); + const { session } = fakeSession(); + const out = mockOutput(); + return run( + workdir, + "", + { + ...baseConfig, + experimental: true, + schemaPaths: ["supabase/schemas/locked"], + }, + session, + out, + ).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to walk matched directory"); + } + chmodSync(lockedDir, 0o755); + rmSync(workdir, { recursive: true, force: true }); + }), + ), + ); + }, + ); }); From 05e5e91a4932c7bc899c4cf9da5e37bf8b3337a8 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 31 Jul 2026 22:10:27 +0100 Subject: [PATCH 07/48] fix(cli): attach the failing schema file as Go's CmdSuggestion on declarative apply failure (review: PRRT_kwDOErm0O86Vh_lz) Go's `applySchemaFiles` sets `utils.CmdSuggestion = "See schema file: "` immediately after a failing `ExecBatch` (apply.go:57), which root.go prints verbatim on stderr and which suppresses the generic "--debug" fallback suggestion. The native declarative-schema-files branch only carried the raw database error message, dropping this hint. `LegacyMigrationApplyError` now carries an optional `suggestion`, populated by `legacyApplySchemaFiles` for this one call site; the existing generic `normalizeCliError` fallback already surfaces any error's `suggestion` field, so no output-layer changes are needed. --- .../legacy/shared/legacy-migrate-and-seed.ts | 13 ++++- .../legacy-migrate-and-seed.unit.test.ts | 55 +++++++++++++++++++ .../legacy/shared/legacy-migration-apply.ts | 5 ++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts index 306f839a32..8f3a66d8c8 100644 --- a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts +++ b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts @@ -1,6 +1,7 @@ import { Effect, type FileSystem, type Path, Result } from "effect"; import { Output } from "../../shared/output/output.service.ts"; +import { legacyBold } from "./legacy-colors.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; import { legacyGlobPattern, legacyResolveUnderWorkdir } from "./legacy-glob.ts"; import { @@ -131,7 +132,11 @@ const legacyResolveSchemaPathFiles = ( * inserting a migration-history row (Go sets `schema.Version = ""` before `ExecBatch`) and * WITHOUT creating the history table or resetting connection state first (`applySchemaFiles` * calls `ExecBatch` directly on each file, unlike `applyMigrationFiles`'s - * `migration.ApplyMigrations`) — `legacyExecSqlFile` already has exactly this shape. + * `migration.ApplyMigrations`) — `legacyExecSqlFile` already has exactly this shape. A failed + * `ExecBatch` sets `utils.CmdSuggestion = "See schema file: "` (`apply.go:57`, `fp` bolded) + * immediately, so the failing file is attached as the error's `suggestion` here too — the + * generic `normalizeCliError` fallback (`shared/output/normalize-error.ts`) surfaces any + * error's `suggestion` field verbatim, matching root.go's plain `CmdSuggestion` stderr line. */ const legacyApplySchemaFiles = ( session: LegacyDbSession, @@ -149,7 +154,11 @@ const legacyApplySchemaFiles = ( fs, path, absPath, - (message) => new LegacyMigrationApplyError({ message }), + (message) => + new LegacyMigrationApplyError({ + message, + suggestion: `See schema file: ${legacyBold(relativePath)}`, + }), ); } }); diff --git a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts index 9f079d64f4..791462fb0d 100644 --- a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts @@ -5,8 +5,11 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, FileSystem, Layer, Path } from "effect"; +import { stripAnsi } from "../../../tests/helpers/ansi.ts"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { LegacyDbExecError } from "./legacy-db-connection.errors.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; +import { LegacyMigrationApplyError } from "./legacy-migration-apply.ts"; import { legacyMigrateAndSeed, type LegacyMigrateAndSeedConfig, @@ -30,6 +33,25 @@ function fakeSession() { return { session, execs }; } +/** Every statement fails except the `BEGIN`/`COMMIT`/`ROLLBACK` transaction control Go wraps it in. */ +function failingExecSession(): { session: LegacyDbSession; execs: Array } { + const execs: Array = []; + const TRANSACTION_CONTROL = new Set(["BEGIN", "COMMIT", "ROLLBACK"]); + const session: LegacyDbSession = { + exec: (sql) => { + execs.push(sql); + return TRANSACTION_CONTROL.has(sql) + ? Effect.sync(() => {}) + : Effect.fail(new LegacyDbExecError({ message: "syntax error" })); + }, + query: () => Effect.succeed([]), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }; + return { session, execs }; +} + function makeWorkdir(): string { return mkdtempSync(join(tmpdir(), "legacy-migrate-and-seed-")); } @@ -333,4 +355,37 @@ describe("legacyMigrateAndSeed experimental declarative-schema branch", () => { ); }, ); + + it.effect( + "attaches the failing schema file as Go's CmdSuggestion (See schema file: )", + () => { + const workdir = makeWorkdir(); + const schemaPath = "supabase/schemas/broken.sql"; + writeFile(workdir, schemaPath, "totally not valid sql;"); + const { session } = failingExecSession(); + const out = mockOutput(); + return run( + workdir, + "", + { + ...baseConfig, + experimental: true, + schemaPaths: [schemaPath], + }, + session, + out, + ).pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error).toBeInstanceOf(LegacyMigrationApplyError); + const suggestion = (error as LegacyMigrationApplyError).suggestion; + expect(suggestion).toBeDefined(); + expect(stripAnsi(suggestion ?? "")).toBe(`See schema file: ${schemaPath}`); + rmSync(workdir, { recursive: true, force: true }); + }), + ), + ); + }, + ); }); diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index 046efa4b0e..983eb07606 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -14,9 +14,14 @@ import { legacySplitAndTrim } from "./legacy-sql-split.ts"; * Applying a migration file failed (Go's `ApplyMigrations` / `ExecBatch` error). * Used by `migration up` and `migration down`'s migrate-and-seed step. The * declarative sync handler maps its own error type instead. + * + * `suggestion` carries Go's `utils.CmdSuggestion` when a caller sets one — currently + * only `legacyApplySchemaFiles`'s "See schema file: " (`apply.go:57`); every other + * caller leaves it unset, matching Go leaving `CmdSuggestion` empty on those paths. */ export class LegacyMigrationApplyError extends Data.TaggedError("LegacyMigrationApplyError")<{ readonly message: string; + readonly suggestion?: string; }> {} // Byte order mark (U+FEFF) — stripped from the head of a statement like Go does. From c734713f95127ab07439fea6dff7ada73bba6cbc Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 31 Jul 2026 23:33:29 +0100 Subject: [PATCH 08/48] fix(cli): exclude symlinks when expanding schema_paths directories (review: PRRT_kwDOErm0O86Vii6t) Go's walkMatchedDir (pkg/config/config.go:194-207) never follows a symlinked DirEntry from fs.WalkDir: entry.Type().IsRegular() is false for a symlink regardless of target, and WalkDir never descends into a symlinked subdirectory either. The port's recursive readDirectory + follow-symlinks fs.stat replicated neither half, so a symlinked .sql file (or an entire symlinked subdirectory's contents) could be applied on the --experimental declarative schema-files bootstrap path. The FileSystem service has no non-following lstat, so legacyWalkSqlFiles manually walks each directory and probes every entry via fs.readLink (succeeding = symlink) before deciding whether to recurse or include it, mirroring WalkDir's behavior with only the primitives the service already exposes. The top-level match's own fs.stat is unchanged, since Go's top-level fs.Stat on a Glob match also follows symlinks - only the walk inside a matched directory needed the fix. --- .../legacy/shared/legacy-migrate-and-seed.ts | 53 +++++++++++++++---- .../legacy-migrate-and-seed.unit.test.ts | 45 +++++++++++++++- 2 files changed, 87 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts index 8f3a66d8c8..ce3fad0905 100644 --- a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts +++ b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts @@ -1,4 +1,5 @@ import { Effect, type FileSystem, type Path, Result } from "effect"; +import type { PlatformError } from "effect/PlatformError"; import { Output } from "../../shared/output/output.service.ts"; import { legacyBold } from "./legacy-colors.ts"; @@ -33,6 +34,46 @@ export interface LegacyMigrateAndSeedConfig { readonly schemaPaths: ReadonlyArray; } +/** + * Port of Go's `walkMatchedDir` (`pkg/config/config.go:194-207`, called by `Glob.SQLFiles` on + * every directory match): a manual, non-recursing-through-`{recursive: true}` walk, because + * Go's `fs.WalkDir` never follows a symlinked `DirEntry` — its `IsDir()` is false for a + * symlink regardless of target, so `WalkDir` neither descends into a symlinked subdirectory + * nor lets `entry.Type().IsRegular()` (the `.sql`-file inclusion check) pass a symlinked file. + * The `FileSystem` service exposes no non-following `lstat`; `fs.readLink` succeeding on a + * path IS Effect's only non-following "is this a symlink" primitive, so it stands in for that + * check at each level, both for recursion (a symlinked directory is skipped, not walked) and + * for file inclusion (a symlinked `.sql` file is skipped, not applied) — using `fs.stat` + * (which follows) here instead would silently include a symlink's target, unlike Go. Returns + * paths relative to `dir`; the caller does the single final sort over the whole aggregate, + * matching Go's one `sort.Strings(files)` after the complete walk rather than per-directory. + */ +const legacyWalkSqlFiles = ( + fs: FileSystem.FileSystem, + dir: string, + relativePrefix: string, +): Effect.Effect, PlatformError> => + Effect.gen(function* () { + const names = yield* fs.readDirectory(dir); + const files: Array = []; + for (const name of names) { + const absChild = `${dir}/${name}`; + const relChild = relativePrefix.length === 0 ? name : `${relativePrefix}/${name}`; + const isSymlink = yield* fs.readLink(absChild).pipe( + Effect.map(() => true), + Effect.orElseSucceed(() => false), + ); + if (isSymlink) continue; + const info = yield* fs.stat(absChild).pipe(Effect.orElseSucceed(() => undefined)); + if (info?.type === "Directory") { + files.push(...(yield* legacyWalkSqlFiles(fs, absChild, relChild))); + } else if (info?.type === "File" && relChild.endsWith(".sql")) { + files.push(relChild); + } + } + return files; + }); + /** * Port of Go's `Glob.SQLFiles` (`pkg/config/config.go:122-128` → `files`/`walkMatchedDir`), * called with zero `GlobOption`s exactly like `applySchemaFiles`'s own call @@ -96,22 +137,14 @@ const legacyResolveSchemaPathFiles = ( // A read/walk failure is Go's `failed to walk matched directory: %w` — recorded as a // problem (not silently treated as an empty directory) so it surfaces exactly like // Go's joined error does whenever nothing else matched anything either. - const namesResult = yield* fs - .readDirectory(absMatch, { recursive: true }) - .pipe(Effect.result); + const namesResult = yield* legacyWalkSqlFiles(fs, absMatch, "").pipe(Effect.result); if (Result.isFailure(namesResult)) { problems.push(`failed to walk matched directory: ${match}`); continue; } - const sqlRelative = namesResult.success - .map((name) => name.replaceAll("\\", "/")) - .filter((name) => name.endsWith(".sql")) - .sort(); + const sqlRelative = [...namesResult.success].sort(); for (const relative of sqlRelative) { const relativeToWorkdir = `${match}/${relative}`; - const absEntry = legacyResolveUnderWorkdir(path, workdir, relativeToWorkdir); - const entryStat = yield* fs.stat(absEntry).pipe(Effect.orElseSucceed(() => undefined)); - if (entryStat?.type !== "File") continue; if (!seen.has(relativeToWorkdir)) { seen.add(relativeToWorkdir); result.push(relativeToWorkdir); diff --git a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts index 791462fb0d..d89c197e81 100644 --- a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts @@ -1,4 +1,4 @@ -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; @@ -356,6 +356,49 @@ describe("legacyMigrateAndSeed experimental declarative-schema branch", () => { }, ); + it.effect( + "skips a symlinked .sql file and an entire symlinked subdirectory inside a matched schema_paths directory", + () => { + // Go's `walkMatchedDir` (`fs.WalkDir` + `entry.Type().IsRegular()`) never follows a + // symlinked `DirEntry` — a symlinked `.sql` file is excluded regardless of target, and a + // symlinked subdirectory is never even descended into. Both live OUTSIDE the matched + // directory here, so applying either would mean executing SQL Go would never touch. + const workdir = makeWorkdir(); + const outsideDir = mkdtempSync(join(tmpdir(), "legacy-migrate-and-seed-outside-")); + writeFileSync(join(outsideDir, "escaped.sql"), "select 999;"); + writeFileSync(join(outsideDir, "linked-target.sql"), "select 888;"); + writeFile(workdir, "supabase/schemas/real.sql", "select 1;"); + symlinkSync( + join(outsideDir, "linked-target.sql"), + join(workdir, "supabase", "schemas", "link-to-file.sql"), + ); + symlinkSync(outsideDir, join(workdir, "supabase", "schemas", "link-to-dir")); + const { session, execs } = fakeSession(); + const out = mockOutput(); + return run( + workdir, + "", + { + ...baseConfig, + experimental: true, + schemaPaths: ["supabase/schemas"], + }, + session, + out, + ).pipe( + Effect.tap(() => + Effect.sync(() => { + expect(execs).toContain("select 1"); + expect(execs).not.toContain("select 888"); + expect(execs).not.toContain("select 999"); + rmSync(workdir, { recursive: true, force: true }); + rmSync(outsideDir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + it.effect( "attaches the failing schema file as Go's CmdSuggestion (See schema file: )", () => { From fea3be9f2af230a62b9382933b51944bedd3001e Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 31 Jul 2026 23:33:39 +0100 Subject: [PATCH 09/48] fix(cli): validate auth duration config fields before native db start bootstrap (review: PRRT_kwDOErm0O86Vii6v) Go's Config.Load (flags.LoadConfig) decodes every time.Duration config field and runs (s *sms) validate() unconditionally, for every command that loads config - including db start, even though db start never starts GoTrue itself. Before this PR removed the Go container-bootstrap delegation, that validation happened for free (the subprocess loaded config the same way any Go command does); the native path dropped it, so a malformed auth.email.max_frequency (for example) would no longer fail db start before Docker work, unlike Go. Added the same eager validation commands/start/start.handler.ts already performs for this exact reason: auth.email.max_frequency, auth.sms.max_frequency (+ the SMS-disabled warning), auth.sessions.{timebox,inactivity_timeout}, and auth.mfa.phone.max_frequency, reusing the already-hoisted legacyResolveAuthEmail/legacyResolveAuthSms/legacyResolveAuthMfa. Hoisted resolveGotrueSessions (previously private to commands/start/start.handler.ts) into legacy-local-config-values.ts as legacyResolveGotrueSessions since it now has a second caller, per apps/cli/CLAUDE.md's "Hoist Before You Duplicate". --- .../legacy/commands/db/start/start.handler.ts | 94 +++++++++++++++++++ .../db/start/start.integration.test.ts | 45 +++++++++ .../legacy/commands/start/start.handler.ts | 32 +------ .../shared/legacy-local-config-values.ts | 37 ++++++++ 4 files changed, 177 insertions(+), 31 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 2c095544a1..a9e8b0a659 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -18,11 +18,17 @@ import { localNetworkId, } from "../../../shared/legacy-docker-ids.ts"; import { + legacyEnvOverrideBool, + legacyResolveAuthEmail, legacyResolveAuthExternalUrl, + legacyResolveAuthMfa, + legacyResolveAuthSms, legacyResolveDbSettingsEnvOverrides, + legacyResolveGotrueSessions, legacyResolveLocalConfigValues, legacyResolveLocalJwks, } from "../../../shared/legacy-local-config-values.ts"; +import { legacyParseGoDuration } from "../../../shared/legacy-go-duration.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"; @@ -32,6 +38,31 @@ import { legacyStartDatabase } from "../../../shared/db-bootstrap/start-database import type { LegacyStartContainerOpts } from "../../../shared/db-bootstrap/container-lifecycle.ts"; import type { LegacyDbStartFlags } from "./start.command.ts"; +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +/** + * Wraps a synchronous resolver/parser that throws on a malformed config value into a typed + * `LegacyDbConfigLoadError` failure — mirrors `commands/start/start.handler.ts`'s identical + * `wrapConfigOverride`, matching Go's `Config.Load` hard-failing on a bad Viper decode + * (`pkg/config/config.go:749-756`) before any Docker work runs. + */ +function wrapDbConfigOverride( + dottedFieldPath: string, + thunk: () => T, +): Effect.Effect { + return Effect.try({ + try: thunk, + catch: (cause) => + new LegacyDbConfigLoadError({ + message: `invalid config for ${dottedFieldPath}: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + }); +} + /** * `supabase db start` — start the local Postgres database. * @@ -150,6 +181,69 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega (message) => new LegacyDbConfigLoadError({ message }), ); + // Go decodes every `time.Duration` config field — including these 5 — in the same single, + // unconditional `Config.Load` pass (`mapstructure.StringToTimeDurationHookFunc()`, + // `pkg/config/config.go:749-756,777`), before `db start` touches Docker at all + // (`internal/db/start/start.go:45`) — regardless of whether `db start` itself ever reads + // the field. `db start` never starts GoTrue (only `supabase start` does, whose OWN identical + // eager-validation block this mirrors — see `commands/start/start.handler.ts`'s + // `wrapConfigOverride` call sites), so nothing else in this handler ever parses + // `auth.email`/`auth.sms`/`auth.sessions`/`auth.mfa`'s duration fields — without this, a + // malformed value would be silently accepted here instead of failing the command, unlike + // Go. Discarding the parsed values: only the fail-fast behavior matters for this command. + const authDocForValidation = asRecord(loaded?.document?.["auth"]); + const resolvedEmailForValidation = yield* wrapDbConfigOverride("auth.email", () => + legacyResolveAuthEmail(config.auth.email, authDocForValidation, projectEnvValues), + ); + yield* wrapDbConfigOverride("auth.email.max_frequency", () => + legacyParseGoDuration(resolvedEmailForValidation.max_frequency), + ); + const smsForValidation = yield* wrapDbConfigOverride("auth.sms", () => + legacyResolveAuthSms(authDocForValidation, config.auth.sms, projectEnvValues), + ); + yield* wrapDbConfigOverride("auth.sms.max_frequency", () => + legacyParseGoDuration(smsForValidation.max_frequency), + ); + // Go's `(s *sms) validate()` (`config.go:1412-1415`) prints this and downgrades + // `EnableSignup` to `false` when no provider is enabled — `legacyResolveAuthSms` already + // applies the downgrade itself, so this only needs to detect whether that branch fired (the + // user configured `enable_signup = true` with every provider disabled) to reproduce the + // matching warning, same as `commands/start/start.handler.ts`'s identical check. + if ( + !smsForValidation.twilio.enabled && + !smsForValidation.twilio_verify.enabled && + !smsForValidation.messagebird.enabled && + !smsForValidation.textlocal.enabled && + !smsForValidation.vonage.enabled && + legacyEnvOverrideBool( + "SUPABASE_AUTH_SMS_ENABLE_SIGNUP", + config.auth.sms.enable_signup, + "auth.sms.enable_signup", + projectEnvValues, + ) + ) { + yield* output.raw("WARN: no SMS provider is enabled. Disabling phone login\n", "stderr"); + } + const gotrueSessionsForValidation = legacyResolveGotrueSessions( + config.auth.sessions, + projectEnvValues, + ); + if (gotrueSessionsForValidation?.timebox !== undefined) { + yield* wrapDbConfigOverride("auth.sessions.timebox", () => + legacyParseGoDuration(gotrueSessionsForValidation.timebox!), + ); + } + if (gotrueSessionsForValidation?.inactivity_timeout !== undefined) { + yield* wrapDbConfigOverride("auth.sessions.inactivity_timeout", () => + legacyParseGoDuration(gotrueSessionsForValidation.inactivity_timeout!), + ); + } + yield* wrapDbConfigOverride("auth.mfa.phone.max_frequency", () => + legacyParseGoDuration( + legacyResolveAuthMfa(config.auth.mfa, projectEnvValues).phone.max_frequency, + ), + ); + // 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`). 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..84b7f37c43 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 @@ -641,6 +641,51 @@ describe("legacy db start", () => { }, ); + // Go's `Config.Load` (`flags.LoadConfig`) decodes every `time.Duration` field unconditionally, + // for every command including `db start` — even though `db start` never starts GoTrue itself. + // Mirrors `commands/start/start.handler.ts`'s own identical eager-validation tests. + it.live.each([ + ["auth.email.max_frequency", '[auth.email]\nmax_frequency = "not-a-duration"\n'], + ["auth.sms.max_frequency", '[auth.sms]\nmax_frequency = "not-a-duration"\n'], + ["auth.sessions.timebox", '[auth.sessions]\ntimebox = "not-a-duration"\n'], + [ + "auth.sessions.inactivity_timeout", + '[auth.sessions]\ninactivity_timeout = "not-a-duration"\n', + ], + ["auth.mfa.phone.max_frequency", '[auth.mfa.phone]\nmax_frequency = "not-a-duration"\n'], + ] as const)( + "fails with a typed config error on a malformed %s, before any container is created", + ([dottedFieldPath, tomlFragment]) => { + const { layer, child } = setup({ + configContents: `project_id = "test"\n${tomlFragment}`, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const message = JSON.stringify(exit.cause); + expect(message).toContain("LegacyDbConfigLoadError"); + expect(message).toContain(dottedFieldPath); + } + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + }); + }, + ); + + it.live( + "warns when auth.sms.enable_signup is true but no SMS provider is enabled, matching Go's (s *sms) validate()", + () => { + const { layer, out } = setup({ + configContents: 'project_id = "test"\n[auth.sms]\nenable_signup = true\n', + route: freshVolumeRoute(defaultRoute()), + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("WARN: no SMS provider is enabled. Disabling phone login"); + }); + }, + ); + it.live( "does not add the Linux-only host.docker.internal extra host on a non-Linux platform", () => { diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index a845227551..2ef54f016e 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -83,6 +83,7 @@ import { legacyResolveConfiguredSigningKeys, legacyResolveAuthExternalUrl, legacyResolveDbSettingsEnvOverrides, + legacyResolveGotrueSessions as resolveGotrueSessions, legacyResolveLocalConfigValues, legacyResolveLocalJwks, legacyStrToArr, @@ -315,37 +316,6 @@ function resolveGotruePasskeyWebauthn( return { passkeyEnabled, webauthn }; } -/** - * Go's `Auth.Sessions` (`pkg/config/auth.go:330-333`) is a value-typed struct, - * always merged with a Viper default (empty durations) regardless of - * `[auth.sessions]` presence in config.toml — so - * `SUPABASE_AUTH_SESSIONS_{TIMEBOX,INACTIVITY_TIMEOUT}` overrides always apply - * before `start.go` builds `GOTRUE_SESSIONS_*`, no raw-document presence gate - * needed (same reasoning as {@link resolveGotrueRateLimit}/mfa below). - * `@supabase/config`'s `sessions` schema is `Schema.optionalKey` at the - * `auth` level though (`config.auth.sessions` can be `undefined`), unlike - * Go's always-present struct — an env override must still be able to - * introduce a value even when the section was never in config.toml at all, - * matching Go's real behavior. - */ -function resolveGotrueSessions( - sessions: ProjectConfig["auth"]["sessions"], - projectEnvValues: Readonly> | undefined, -): ProjectConfig["auth"]["sessions"] { - const timebox = legacyEnvOverride( - "SUPABASE_AUTH_SESSIONS_TIMEBOX", - sessions?.timebox, - projectEnvValues, - ); - const inactivityTimeout = legacyEnvOverride( - "SUPABASE_AUTH_SESSIONS_INACTIVITY_TIMEOUT", - sessions?.inactivity_timeout, - projectEnvValues, - ); - if (timebox === undefined && inactivityTimeout === undefined) return sessions; - return { timebox, inactivity_timeout: inactivityTimeout }; -} - /** * Go's `Auth.RateLimit` (`pkg/config/auth.go:200-208`) is a value-typed * struct of plain `uint`s, always Viper-bound regardless of `[auth.rate_ diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 60051ae5f2..c6950ed9df 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -1656,6 +1656,43 @@ export function legacyResolveAuthMfa( }; } +/** + * Go's `Auth.Sessions` (`pkg/config/auth.go:330-333`) is a value-typed struct, + * always merged with a Viper default (empty durations) regardless of + * `[auth.sessions]` presence in config.toml — so + * `SUPABASE_AUTH_SESSIONS_{TIMEBOX,INACTIVITY_TIMEOUT}` overrides always apply + * before `start.go` builds `GOTRUE_SESSIONS_*`, no raw-document presence gate + * needed (same reasoning as {@link legacyResolveAuthMfa} above). + * `@supabase/config`'s `sessions` schema is `Schema.optionalKey` at the + * `auth` level though (`config.auth.sessions` can be `undefined`), unlike + * Go's always-present struct — an env override must still be able to + * introduce a value even when the section was never in config.toml at all, + * matching Go's real behavior. + * + * Hoisted here (originally private to `commands/start/start.handler.ts`) once + * `commands/db/start/start.handler.ts` became a second caller — both need the + * same eager `auth.sessions.{timebox,inactivity_timeout}` resolution to + * reproduce Go's unconditional `Config.Load` duration decode, per + * `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate". + */ +export function legacyResolveGotrueSessions( + sessions: ProjectConfig["auth"]["sessions"], + projectEnvValues: Readonly> | undefined, +): ProjectConfig["auth"]["sessions"] { + const timebox = legacyEnvOverride( + "SUPABASE_AUTH_SESSIONS_TIMEBOX", + sessions?.timebox, + projectEnvValues, + ); + const inactivityTimeout = legacyEnvOverride( + "SUPABASE_AUTH_SESSIONS_INACTIVITY_TIMEOUT", + sessions?.inactivity_timeout, + projectEnvValues, + ); + if (timebox === undefined && inactivityTimeout === undefined) return sessions; + return { timebox, inactivity_timeout: inactivityTimeout }; +} + /** Go's `(s *sms) validate()` fixed provider priority (`pkg/config/config.go:1348-1410`) — a * `switch` that validates ONLY the first enabled provider in this order. */ const LEGACY_SMS_PROVIDER_ORDER = [ From 0e15da56044f17c10b028c4c1fa47ef10f91e3ca Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 31 Jul 2026 23:33:48 +0100 Subject: [PATCH 10/48] fix(cli): normalize Windows path separators before globbing schema/seed paths (review: PRRT_kwDOErm0O86Vii6w) Go's Glob.files calls fs.Glob(fsys, filepath.ToSlash(pattern)) (config.go:143-145) before any meta-detection or directory-splitting - a no-op on POSIX but on Windows it turns every backslash into a forward slash first. The port had no equivalent, so a Windows entry with backslashes (an absolute path is preserved verbatim by legacyResolveSeedSqlPath, but a relative one can carry them too) hit legacyHasGlobMeta's backslash branch and then found no "/" to split on, leaving dirPattern empty and the whole path as filePattern - silently resolving to nothing instead of the configured file. Added the same OS-gated normalization at the top of legacyGlobPattern, keyed on path.sep (mirrors Go's runtime.GOOS gate) rather than introducing a new dependency. This is shared by every legacyGlobPattern caller (db.seed.sql_paths too), not just schema_paths. --- apps/cli/src/legacy/shared/legacy-glob.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-glob.ts b/apps/cli/src/legacy/shared/legacy-glob.ts index 22ee7a1756..e987db72ba 100644 --- a/apps/cli/src/legacy/shared/legacy-glob.ts +++ b/apps/cli/src/legacy/shared/legacy-glob.ts @@ -45,15 +45,25 @@ export const legacyGlobPattern = ( pattern: string, ): Effect.Effect> => Effect.gen(function* () { - if (!legacyHasGlobMeta(pattern)) { + // Go's `Glob.files` calls `fs.Glob(fsys, filepath.ToSlash(pattern))` (`config.go:143-145`, + // comment: "Glob expects / as path separator on windows") — a no-op on POSIX, where + // `path.sep` is already `/`, but on Windows it replaces every `\` with `/` BEFORE any + // meta-detection or directory-splitting happens. Without this, a Windows entry with + // backslashes — an absolute one is preserved verbatim by `legacyResolveSeedSqlPath`, but + // even a relative one can carry them — never matches the `/`-only split below and + // `legacyHasGlobMeta` misreads a plain backslash as glob syntax, so a literal path like + // `schemas\foo.sql` or a real pattern like `schemas\*.sql` would silently resolve to + // nothing instead of the configured file. + const normalized = path.sep === "/" ? pattern : pattern.replaceAll("\\", "/"); + if (!legacyHasGlobMeta(normalized)) { const exists = yield* fs - .exists(legacyResolveUnderWorkdir(path, workdir, pattern)) + .exists(legacyResolveUnderWorkdir(path, workdir, normalized)) .pipe(Effect.orElseSucceed(() => false)); - return exists ? [pattern] : []; + return exists ? [normalized] : []; } - const slash = pattern.lastIndexOf("/"); - const dirPattern = slash === -1 ? "" : pattern.slice(0, slash); - const filePattern = slash === -1 ? pattern : pattern.slice(slash + 1); + const slash = normalized.lastIndexOf("/"); + const dirPattern = slash === -1 ? "" : normalized.slice(0, slash); + const filePattern = slash === -1 ? normalized : normalized.slice(slash + 1); const dirs = legacyHasGlobMeta(dirPattern) ? yield* legacyGlobPattern(fs, path, workdir, dirPattern) : [dirPattern]; From b7d2a3b38061f267393e56bfaca69331f9e12c21 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 31 Jul 2026 23:34:00 +0100 Subject: [PATCH 11/48] docs(cli): fix markdown table column alignment in start/db-start SIDE_EFFECTS.md Pre-existing oxfmt drift (table divider rows narrower than their header/ cell widths) surfaced by fmt:check while working this workspace; no content changed. --- .../legacy/commands/db/start/SIDE_EFFECTS.md | 60 +++++++++---------- .../src/legacy/commands/start/SIDE_EFFECTS.md | 22 +++---- 2 files changed, 41 insertions(+), 41 deletions(-) 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..75e780f87e 100644 --- a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md @@ -59,20 +59,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 +110,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`) 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`. From 89e7533e96e5b217f2ee35bbd650699d81f61727 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 00:40:38 +0100 Subject: [PATCH 12/48] fix(cli): validate config before the already-running short-circuit in db start (review: PRRT_kwDOErm0O86VjUtj) Go's start.Run calls flags.LoadConfig (full config load + validation, including the eager auth.*.max_frequency/timebox/inactivity_timeout duration parsing) before AssertSupabaseDbIsRunning (internal/db/start/start.go:45-47). The native db start port had this backwards: the duration-field validation added in fea3be9f ran after the already-running return, so a malformed auth.email.max_frequency (for example) exited 0 with "already running" instead of failing, whenever Postgres happened to already be up. Moved legacyLoadLocalProjectContext + the duration-field validation block above the running check, leaving the rest of db start's own prelude (experimental gate, legacyResolveLocalConfigValues, legacyResolveDbBootstrapConfig) after it, since those correspond to Go's StartDatabase bring-up (only reached on the not-running branch), not to LoadConfig itself. Added an integration test mirroring the existing "undecryptable secret even when already running" case for this exact scenario. --- .../legacy/commands/db/start/start.handler.ts | 150 ++++++++++-------- .../db/start/start.integration.test.ts | 22 +++ 2 files changed, 103 insertions(+), 69 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 a9e8b0a659..582f398915 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -110,8 +110,88 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega // broken config. yield* legacyCheckDbToml(fs, path, cliConfig.workdir); + // The rest of Go's `flags.LoadConfig` — full config decode/resolution + // (`legacyLoadLocalProjectContext`) plus the eager `time.Duration` field validation right + // below — ALSO runs before `AssertSupabaseDbIsRunning` in Go's `start.Run` + // (`internal/db/start/start.go:45-47`), so a malformed `auth.*` duration field must fail + // `db start` even when Postgres is already running, not just on a fresh start. Load it here, + // ahead of the already-running short-circuit below, instead of deferring it to the + // not-running branch (previously this ran after the short-circuit, so an "already running" + // db would mask the config error). + const context = yield* legacyLoadLocalProjectContext( + cliConfig.workdir, + (message) => new LegacyDbConfigLoadError({ message }), + ); + const { config, projectEnvValues, loaded, hostname, projectId } = context; + + // Go decodes every `time.Duration` config field — including these 5 — in the same single, + // unconditional `Config.Load` pass (`mapstructure.StringToTimeDurationHookFunc()`, + // `pkg/config/config.go:749-756,777`), before `db start` touches Docker (or even checks + // whether Postgres is already running) at all (`internal/db/start/start.go:45-47`) — + // regardless of whether `db start` itself ever reads the field. `db start` never starts + // GoTrue (only `supabase start` does, whose OWN identical eager-validation block this + // mirrors — see `commands/start/start.handler.ts`'s `wrapConfigOverride` call sites), so + // nothing else in this handler ever parses `auth.email`/`auth.sms`/`auth.sessions`/ + // `auth.mfa`'s duration fields — without this, a malformed value would be silently accepted + // here instead of failing the command, unlike Go. Discarding the parsed values: only the + // fail-fast behavior matters for this command. + const authDocForValidation = asRecord(loaded?.document?.["auth"]); + const resolvedEmailForValidation = yield* wrapDbConfigOverride("auth.email", () => + legacyResolveAuthEmail(config.auth.email, authDocForValidation, projectEnvValues), + ); + yield* wrapDbConfigOverride("auth.email.max_frequency", () => + legacyParseGoDuration(resolvedEmailForValidation.max_frequency), + ); + const smsForValidation = yield* wrapDbConfigOverride("auth.sms", () => + legacyResolveAuthSms(authDocForValidation, config.auth.sms, projectEnvValues), + ); + yield* wrapDbConfigOverride("auth.sms.max_frequency", () => + legacyParseGoDuration(smsForValidation.max_frequency), + ); + // Go's `(s *sms) validate()` (`config.go:1412-1415`) prints this and downgrades + // `EnableSignup` to `false` when no provider is enabled — `legacyResolveAuthSms` already + // applies the downgrade itself, so this only needs to detect whether that branch fired (the + // user configured `enable_signup = true` with every provider disabled) to reproduce the + // matching warning, same as `commands/start/start.handler.ts`'s identical check. + if ( + !smsForValidation.twilio.enabled && + !smsForValidation.twilio_verify.enabled && + !smsForValidation.messagebird.enabled && + !smsForValidation.textlocal.enabled && + !smsForValidation.vonage.enabled && + legacyEnvOverrideBool( + "SUPABASE_AUTH_SMS_ENABLE_SIGNUP", + config.auth.sms.enable_signup, + "auth.sms.enable_signup", + projectEnvValues, + ) + ) { + yield* output.raw("WARN: no SMS provider is enabled. Disabling phone login\n", "stderr"); + } + const gotrueSessionsForValidation = legacyResolveGotrueSessions( + config.auth.sessions, + projectEnvValues, + ); + if (gotrueSessionsForValidation?.timebox !== undefined) { + yield* wrapDbConfigOverride("auth.sessions.timebox", () => + legacyParseGoDuration(gotrueSessionsForValidation.timebox!), + ); + } + if (gotrueSessionsForValidation?.inactivity_timeout !== undefined) { + yield* wrapDbConfigOverride("auth.sessions.inactivity_timeout", () => + legacyParseGoDuration(gotrueSessionsForValidation.inactivity_timeout!), + ); + } + yield* wrapDbConfigOverride("auth.mfa.phone.max_frequency", () => + legacyParseGoDuration( + legacyResolveAuthMfa(config.auth.mfa, projectEnvValues).phone.max_frequency, + ), + ); + // Go's AssertSupabaseDbIsRunning: if the db container is already up, print to - // stderr and return nil (exit 0). Already native — see this module's header. + // stderr and return nil (exit 0). Already native — see this module's header. Runs AFTER + // the config load/validation above, matching Go's `start.Run` (`flags.LoadConfig` before + // `AssertSupabaseDbIsRunning`, `internal/db/start/start.go:45-47`). const running = yield* legacyIsLocalDbRunning( spawner, fs, @@ -149,11 +229,6 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega // 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( - cliConfig.workdir, - (message) => new LegacyDbConfigLoadError({ message }), - ); - 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. @@ -181,69 +256,6 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega (message) => new LegacyDbConfigLoadError({ message }), ); - // Go decodes every `time.Duration` config field — including these 5 — in the same single, - // unconditional `Config.Load` pass (`mapstructure.StringToTimeDurationHookFunc()`, - // `pkg/config/config.go:749-756,777`), before `db start` touches Docker at all - // (`internal/db/start/start.go:45`) — regardless of whether `db start` itself ever reads - // the field. `db start` never starts GoTrue (only `supabase start` does, whose OWN identical - // eager-validation block this mirrors — see `commands/start/start.handler.ts`'s - // `wrapConfigOverride` call sites), so nothing else in this handler ever parses - // `auth.email`/`auth.sms`/`auth.sessions`/`auth.mfa`'s duration fields — without this, a - // malformed value would be silently accepted here instead of failing the command, unlike - // Go. Discarding the parsed values: only the fail-fast behavior matters for this command. - const authDocForValidation = asRecord(loaded?.document?.["auth"]); - const resolvedEmailForValidation = yield* wrapDbConfigOverride("auth.email", () => - legacyResolveAuthEmail(config.auth.email, authDocForValidation, projectEnvValues), - ); - yield* wrapDbConfigOverride("auth.email.max_frequency", () => - legacyParseGoDuration(resolvedEmailForValidation.max_frequency), - ); - const smsForValidation = yield* wrapDbConfigOverride("auth.sms", () => - legacyResolveAuthSms(authDocForValidation, config.auth.sms, projectEnvValues), - ); - yield* wrapDbConfigOverride("auth.sms.max_frequency", () => - legacyParseGoDuration(smsForValidation.max_frequency), - ); - // Go's `(s *sms) validate()` (`config.go:1412-1415`) prints this and downgrades - // `EnableSignup` to `false` when no provider is enabled — `legacyResolveAuthSms` already - // applies the downgrade itself, so this only needs to detect whether that branch fired (the - // user configured `enable_signup = true` with every provider disabled) to reproduce the - // matching warning, same as `commands/start/start.handler.ts`'s identical check. - if ( - !smsForValidation.twilio.enabled && - !smsForValidation.twilio_verify.enabled && - !smsForValidation.messagebird.enabled && - !smsForValidation.textlocal.enabled && - !smsForValidation.vonage.enabled && - legacyEnvOverrideBool( - "SUPABASE_AUTH_SMS_ENABLE_SIGNUP", - config.auth.sms.enable_signup, - "auth.sms.enable_signup", - projectEnvValues, - ) - ) { - yield* output.raw("WARN: no SMS provider is enabled. Disabling phone login\n", "stderr"); - } - const gotrueSessionsForValidation = legacyResolveGotrueSessions( - config.auth.sessions, - projectEnvValues, - ); - if (gotrueSessionsForValidation?.timebox !== undefined) { - yield* wrapDbConfigOverride("auth.sessions.timebox", () => - legacyParseGoDuration(gotrueSessionsForValidation.timebox!), - ); - } - if (gotrueSessionsForValidation?.inactivity_timeout !== undefined) { - yield* wrapDbConfigOverride("auth.sessions.inactivity_timeout", () => - legacyParseGoDuration(gotrueSessionsForValidation.inactivity_timeout!), - ); - } - yield* wrapDbConfigOverride("auth.mfa.phone.max_frequency", () => - legacyParseGoDuration( - legacyResolveAuthMfa(config.auth.mfa, projectEnvValues).phone.max_frequency, - ), - ); - // 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`). 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 84b7f37c43..c9139497ff 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 @@ -672,6 +672,28 @@ describe("legacy db start", () => { }, ); + it.live("fails on a malformed auth duration field even when the db is already running", () => { + // Go's `flags.LoadConfig` (and therefore this eager duration validation) runs before + // `AssertSupabaseDbIsRunning` in `start.Run` (`internal/db/start/start.go:45-47`) — a + // malformed `auth.*` duration field must fail the command even when Postgres is already + // up, not be masked by the already-running short-circuit. Mirrors the sibling + // "undecryptable secret" already-running test above. + const { layer, out } = setup({ + configContents: 'project_id = "test"\n[auth.email]\nmax_frequency = "not-a-duration"\n', + running: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const message = JSON.stringify(exit.cause); + expect(message).toContain("LegacyDbConfigLoadError"); + expect(message).toContain("auth.email.max_frequency"); + } + expect(out.stderrText).not.toContain("already running"); + }); + }); + it.live( "warns when auth.sms.enable_signup is true but no SMS provider is enabled, matching Go's (s *sms) validate()", () => { From f7415c5ab2d91ec361205b88f4129f2b9c503dd5 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 00:40:53 +0100 Subject: [PATCH 13/48] fix(cli): preserve the filesystem root when globbing an absolute schema/seed pattern (review: PRRT_kwDOErm0O86VjUtk) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit legacyGlobPattern split a glob pattern's directory component by slicing before the last "/", collapsing a root-anchored absolute pattern like "/*.sql" to an empty dirPattern indistinguishable from the truly-relative no-slash case — so it globbed the workdir instead of the filesystem root, and any match would lose its leading "/". Verified against the real Go CLI's own io/fs.Glob (via a throwaway probe importing apps/cli-go/pkg/config directly, per go-removal-sweep/parity-verification.md): Glob{"/*"}.Files(fsys) against the real, unrooted afero.NewOsFs() the CLI actually uses lists the real filesystem root's entries, each still "/"-prefixed, not the process's cwd. Go's identical path.Split/cleanGlobPath split also reduces a Windows drive-root pattern (post filepath.ToSlash) to a bare "C:" directory, which legacyResolveUnderWorkdir's path.isAbsolute check alone doesn't recognize as "don't join under workdir" (Node's win32 isAbsolute requires the trailing separator) — gave that the same verbatim-passthrough treatment. Added apps/cli/src/legacy/shared/legacy-glob.unit.test.ts (previously untested) covering both the POSIX root case and the Windows drive-root case (via BunPath.layerWin32, deterministic regardless of host OS), plus the pre-existing relative-pattern behavior for regression coverage. --- apps/cli/src/legacy/shared/legacy-glob.ts | 30 ++++- .../legacy/shared/legacy-glob.unit.test.ts | 109 ++++++++++++++++++ 2 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 apps/cli/src/legacy/shared/legacy-glob.unit.test.ts diff --git a/apps/cli/src/legacy/shared/legacy-glob.ts b/apps/cli/src/legacy/shared/legacy-glob.ts index e987db72ba..bd83f6e39c 100644 --- a/apps/cli/src/legacy/shared/legacy-glob.ts +++ b/apps/cli/src/legacy/shared/legacy-glob.ts @@ -19,6 +19,16 @@ import { legacyPathMatch } from "./legacy-path-match.ts"; // before globbing, so a `\` here is always a glob escape, never a path separator. const legacyHasGlobMeta = (pattern: string): boolean => /[*?[\\]/u.test(pattern); +// Go's split (`path.Split` + `cleanGlobPath`, `io/fs/glob.go`) reduces a Windows drive-root +// pattern like `C:/*.sql` (post `filepath.ToSlash`) to a bare `C:` directory component — one +// level up from `legacyGlobPattern`'s own slash-split below — which is still part of the SAME +// already-absolute pattern's root, never something to join under the workdir, even though +// `path.isAbsolute("C:")` is `false` (Node's win32 rules require the trailing separator, +// `C:\`/`C:/`, to call a path absolute; a bare `C:` alone is technically "drive-relative"). +// Recognize that exact shape so it reaches `fs.readDirectory`/`fs.exists` verbatim, mirroring +// Go passing it straight through to `ReadDir`/`Stat` on the same real, unrooted `afero.NewOsFs`. +const legacyIsWindowsDriveRoot = (p: string): boolean => /^[A-Za-z]:$/.test(p); + // Go globs/reads glob-config paths through an OS-root-rooted `afero.NewOsFs`, where the // CLI's "workdir" is just `os.Chdir(workdir)` (`internal/utils/misc.go`) — which only // affects RELATIVE paths. An absolute glob-config entry, preserved verbatim by the config @@ -27,7 +37,7 @@ const legacyHasGlobMeta = (pattern: string): boolean => /[*?[\\]/u.test(pattern) // relative (`path.join` would otherwise collapse `/repo` + `/tmp/seed.sql` to // `/repo/tmp/seed.sql`). export const legacyResolveUnderWorkdir = (path: Path.Path, workdir: string, p: string): string => - path.isAbsolute(p) ? p : path.join(workdir, p); + path.isAbsolute(p) || legacyIsWindowsDriveRoot(p) ? p : path.join(workdir, p); /** * Resolves a single glob pattern against the workdir, returning the matched paths RELATIVE @@ -62,7 +72,17 @@ export const legacyGlobPattern = ( return exists ? [normalized] : []; } const slash = normalized.lastIndexOf("/"); - const dirPattern = slash === -1 ? "" : normalized.slice(0, slash); + // Go's `path.Split`/`cleanGlobPath` (`io/fs/glob.go`) keep a root-only directory distinct + // from "no directory at all": splitting a POSIX-root pattern like `/*.sql` yields a bare + // `/`, which Go still globs as the fsys root — NOT the workdir `afero.NewOsFs()` happens to + // have `chdir`-ed into — and every match it returns stays `/`-prefixed. Collapsing that to + // `""` here (indistinguishable from the truly relative no-slash case below, where `""` + // correctly means "resolve under workdir") would silently glob the workdir instead of the + // real root for a pattern whose ONLY slash is the leading one. Confirmed empirically + // against the real, unrooted `afero.NewOsFs()` Go itself globs through: `Glob{"/*"}. + // Files(fsys)` lists the actual filesystem root's entries, each still `/`-prefixed, not + // Go's cwd. + const dirPattern = slash === -1 ? "" : slash === 0 ? "/" : normalized.slice(0, slash); const filePattern = slash === -1 ? normalized : normalized.slice(slash + 1); const dirs = legacyHasGlobMeta(dirPattern) ? yield* legacyGlobPattern(fs, path, workdir, dirPattern) @@ -73,7 +93,11 @@ export const legacyGlobPattern = ( const names = yield* fs.readDirectory(absDir).pipe(Effect.orElseSucceed(() => [])); for (const name of names) { if (legacyPathMatch(filePattern, name).matched) { - result.push(dir.length === 0 ? name : `${dir}/${name}`); + // `dir` is already `/` for the bare-root case above — appending `/${name}` the same + // way every other (non-root) `dir` value does below would double the separator. + result.push( + dir.length === 0 ? name : dir.endsWith("/") ? `${dir}${name}` : `${dir}/${name}`, + ); } } } diff --git a/apps/cli/src/legacy/shared/legacy-glob.unit.test.ts b/apps/cli/src/legacy/shared/legacy-glob.unit.test.ts new file mode 100644 index 0000000000..b27aa8848b --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-glob.unit.test.ts @@ -0,0 +1,109 @@ +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path } from "effect"; + +import { legacyGlobPattern, legacyResolveUnderWorkdir } from "./legacy-glob.ts"; + +/** + * A `FileSystem.FileSystem` that answers `readDirectory` from a fixed map (keyed by the exact + * directory string `legacyGlobPattern` asks for) instead of touching the real filesystem — lets + * these tests assert Go's root-vs-workdir distinction (`Glob{"/*"}.Files(fsys)` reads the fsys + * root, not the cwd `afero.NewOsFs()` happens to be `chdir`-ed into) without depending on what's + * actually present at the real OS root. Every other `FileSystem` method delegates to the real + * Bun filesystem (unused by `legacyGlobPattern`'s glob-meta branch, which only calls + * `readDirectory`). + */ +function fakeReadDirFs(entries: Record>) { + const calls: Array = []; + const layer = Layer.effect( + FileSystem.FileSystem, + Effect.map(FileSystem.FileSystem, (real) => + FileSystem.FileSystem.of({ + ...real, + readDirectory: (dir) => { + calls.push(dir); + return Effect.succeed([...(entries[dir] ?? [])]); + }, + }), + ), + ).pipe(Layer.provide(BunFileSystem.layer)); + return { layer, calls }; +} + +describe("legacyGlobPattern", () => { + it.effect( + "globs a root-anchored absolute pattern (/*.sql) against the filesystem root, not the workdir", + () => { + // Go's `path.Split`/`cleanGlobPath` (`io/fs/glob.go`) reduce `/*.sql` to a bare `/` + // directory — confirmed empirically against the real Go CLI's own (unrooted) + // `afero.NewOsFs()`: `config.Glob{"/*"}.Files(fsys)` lists the actual filesystem root's + // entries, each still `/`-prefixed, never the process's cwd. + const { layer, calls } = fakeReadDirFs({ + "/": ["one.sql", "two.sql", "notes.txt"], + "/some/workdir": ["should-not-be-read.sql"], + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const matches = yield* legacyGlobPattern(fs, path, "/some/workdir", "/*.sql"); + expect([...matches].sort()).toEqual(["/one.sql", "/two.sql"]); + expect(calls).toEqual(["/"]); + }).pipe(Effect.provide(Layer.mergeAll(layer, Path.layer))); + }, + ); + + it.effect("resolves a plain relative pattern (*.sql) under the workdir, unaffected", () => { + const { layer, calls } = fakeReadDirFs({ + "/some/workdir": ["a.sql", "b.txt"], + "/": ["should-not-be-read.sql"], + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const matches = yield* legacyGlobPattern(fs, path, "/some/workdir", "*.sql"); + expect([...matches]).toEqual(["a.sql"]); + expect(calls).toEqual(["/some/workdir"]); + }).pipe(Effect.provide(Layer.mergeAll(layer, Path.layer))); + }); + + it.effect( + "globs a Windows drive-root pattern (C:\\*.sql) against the drive root, not the workdir", + () => { + // Mirrors the POSIX root case one level up: Go's split also collapses a drive-root + // pattern to a bare `C:` directory component (`filepath.ToSlash` turns `C:\*.sql` into + // `C:/*.sql` first, then the SAME `path.Split`/`cleanGlobPath` logic applies) — still + // part of the same already-absolute pattern, never something to join under the workdir. + // Uses the real Node win32 path module (via `BunPath.layerWin32`) so this is deterministic + // regardless of the host OS running the test. + const { layer, calls } = fakeReadDirFs({ + "C:": ["x.sql", "y.sql"], + "D:\\work": ["should-not-be-read.sql"], + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const matches = yield* legacyGlobPattern(fs, path, "D:\\work", "C:\\*.sql"); + expect([...matches].sort()).toEqual(["C:/x.sql", "C:/y.sql"]); + expect(calls).toEqual(["C:"]); + }).pipe(Effect.provide(Layer.mergeAll(layer, BunPath.layerWin32))); + }, + ); +}); + +describe("legacyResolveUnderWorkdir", () => { + it.effect( + "preserves a bare Windows drive-root component instead of joining it under workdir", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(legacyResolveUnderWorkdir(path, "D:\\work", "C:")).toBe("C:"); + }).pipe(Effect.provide(BunPath.layerWin32)), + ); + + it.effect("still joins an ordinary relative segment under workdir on win32", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(legacyResolveUnderWorkdir(path, "D:\\work", "schemas")).toBe("D:\\work\\schemas"); + }).pipe(Effect.provide(BunPath.layerWin32)), + ); +}); From 0a39ddaa87216cd0c5497461641518da4b37375a Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 02:01:48 +0100 Subject: [PATCH 14/48] fix(cli): restore Go's exact network/volume-probe order and JWKS majorVersion gate (review: PRRT_kwDOErm0O86VkCcD) legacyStartDatabase created the Docker network before the pre-create volume-existence probe and the --from-backup-on-an-existing-volume guard. Go's StartDatabase runs VolumeInspect and that guard strictly BEFORE DockerStart, which is the ONLY place Go ever creates the network (apps/cli-go/internal/utils/docker.go:363-386) - so an invalid/uncreatable --network-id could mask the "backup volume already exists" error and leave a stray network behind on a request Go would have rejected outright. Moved the network-ensure call to run after the volume probe/guard, right before the image is used to build the container spec. Also gates the lazy setup.jwks resolve on setup.majorVersion >= 15, not just realtimeEnabledForSetup: Go's initSchema (start.go:243-254) only ever reaches initSchema15's ResolveJWKS call on PG15+; the PG13/14 branch (InitSchema14) never touches JWKS at all, so a PG13/14 database with realtime enabled must not pay for (or fail on) an external JWKS fetch it will never use (review: PRRT_kwDOErm0O86VkCcE). Also stops batch-resolving the three PG15+ setup-job images upfront via legacyEnsureImagesCached and instead threads the raw, pin-rewritten image references straight through - db-setup.ts's own legacyRunStartMigrateJob now resolves each one individually, right before it runs (review: PRRT_kwDOErm0O86VkCcF). --- .../shared/db-bootstrap/start-database.ts | 102 ++++++++++-------- 1 file changed, 55 insertions(+), 47 deletions(-) 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..546c630c1d 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts @@ -7,12 +7,14 @@ * shape available in a codebase whose whole contract is byte-level Go parity. A future change to * Go's `StartDatabase` now only has one TS home to update. * - * Exact Go call order: network ensure -> pre-create volume-existence probe (+ the - * `fromBackup`-on-an-existing-volume guard) -> Postgres container create+start -> health wait - * (swallowed ONLY when `fromBackup` is set — "restoring a large backup may take longer than 2 - * minutes") -> the fresh-volume `SetupLocalDatabase`-equivalent pipeline (skipped IN FULL when - * `fromBackup` is set) -> `initCurrentBranch`, unconditionally (the LAST line of `StartDatabase`, - * reached on every path that doesn't already return/fail above). + * Exact Go call order: pre-create volume-existence probe (+ the `fromBackup`-on-an-existing-volume + * guard) -> image resolve + network ensure (Go's `DockerStart` resolves the image, THEN creates + * the network, both strictly ahead of container create — `docker.go:363-386` — so NEITHER one + * ever runs on a request the volume guard above already rejected) -> Postgres container + * create+start -> health wait (swallowed ONLY when `fromBackup` is set — "restoring a large + * backup may take longer than 2 minutes") -> the fresh-volume `SetupLocalDatabase`-equivalent + * pipeline (skipped IN FULL when `fromBackup` is set) -> `initCurrentBranch`, unconditionally (the + * LAST line of `StartDatabase`, reached on every path that doesn't already return/fail above). * * Deliberately has ZERO knowledge of `--ignore-health-check` — matching Go exactly: that flag is * `internal/start/start.go`'s `Run()`'s own concern, entirely OUTSIDE `StartDatabase` (Go's @@ -34,8 +36,10 @@ * `ensureImagesCached` pre-pull, before bring-up even starts, and just threads that value * through. * - `setup.jwks` — `db start` has no earlier use for JWKS at all, so it resolves it lazily, - * conditionally (only when reached AND `realtime.enabled`), matching Go's own `initSchema15`- - * local `ResolveJWKS` call (`internal/db/start/start.go:337-341`) exactly; `supabase start` + * conditionally (only when reached AND `majorVersion >= 15` AND `realtime.enabled` — Go's + * `initSchema`, `start.go:243-254`, only ever reaches `initSchema15`'s `ResolveJWKS` call on + * PG15+; the PG13/14 branch, `InitSchema14`, never touches JWKS at all), matching Go's own + * `initSchema15`-local `ResolveJWKS` call (`internal/db/start/start.go:337-341`) exactly; `supabase start` * resolves JWKS once, unconditionally, near the top of its OWN prelude (feeding its * long-running Realtime/GoTrue/PostgREST containers too — `internal/start/start.go:274-277`) * and reuses that SAME already-resolved value here rather than re-resolving (a second resolve @@ -74,7 +78,7 @@ import { type LegacyStartSetupLocalDatabaseError, type LegacyStartSetupLocalDatabaseInput, } from "./db-setup.ts"; -import { legacyEnsureImagesCached, type LegacyImagePrepullError } from "./image-prepull.ts"; +import { type LegacyImagePrepullError } from "./image-prepull.ts"; import { legacyWaitForHealthyServices, type LegacyHealthCheckTimeoutError, @@ -200,21 +204,21 @@ export const legacyStartDatabase = ( const output = yield* Output; const dbConnection = yield* LegacyDbConnection; - yield* legacyEnsureStartNetwork(spawner, input.networkId, { - [LEGACY_CLI_PROJECT_LABEL]: input.projectId, - [LEGACY_COMPOSE_PROJECT_LABEL]: input.projectId, - }); - // 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. + // before Postgres's own volume gets created below, AND before the network is created too: + // `docker volume create`/`docker network create` are both idempotent, so creating either + // first would make "did this volume already exist" unobservable, and would leave a Docker + // network behind even for a request the guard below is about to reject outright — Go's own + // `VolumeInspect` and the guard both run strictly BEFORE `DockerStart`, which is the ONLY + // place Go ever creates the network (`docker.go:363-386`). const isFreshVolume = !(yield* legacyStartVolumeExists(spawner, input.dbContainerId)); input.onFreshVolumeResolved(isFreshVolume); const fromBackup = input.postgresSpec.fromBackup; if (!isFreshVolume && fromBackup !== undefined) { // Go's `StartDatabase` (`start.go:170-172`): a `--from-backup` restore into an - // already-provisioned volume is refused outright, BEFORE any container is created. + // already-provisioned volume is refused outright, BEFORE any container or network is + // created. return yield* Effect.fail( new LegacyStartBackupVolumeExistsError({ message: "backup volume already exists", @@ -233,6 +237,17 @@ export const legacyStartDatabase = ( } const resolvedPostgresImage = yield* input.resolvePostgresImage; + + // Go's `DockerStart` (`docker.go:363-386`): image resolve, THEN network create, both + // strictly ahead of container create — hoisted here to run ONCE per `start` run instead of + // once per container (Go's own repeated per-container call is a no-op after the first, see + // `legacyEnsureStartNetwork`'s own doc comment), but kept in Go's own relative position: + // after the volume probe/guard above, never before it. + yield* legacyEnsureStartNetwork(spawner, input.networkId, { + [LEGACY_CLI_PROJECT_LABEL]: input.projectId, + [LEGACY_COMPOSE_PROJECT_LABEL]: input.projectId, + }); + const postgresSpec = legacyBuildPostgresStartContainerSpec({ ...input.postgresSpec, image: resolvedPostgresImage, @@ -279,17 +294,32 @@ export const legacyStartDatabase = ( // 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 : ""; + // call: only when reached AND `majorVersion >= 15` AND `Realtime.Enabled`. Go's + // `initSchema` (`start.go:243-254`) branches to `initSchema15` — the ONLY place + // `ResolveJWKS` is ever called — solely on `majorVersion >= 15`; the PG13/14 branch + // (`InitSchema14`) never touches JWKS, so a PG13/14 database with realtime enabled must + // not pay for (or fail on) an external JWKS fetch it will never use. + const jwks = + setup.majorVersion >= 15 && 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 = { + // project's version pins apply here too. Deliberately NOT resolved/pulled here as a + // batch: Go resolves (and pulls) each one-shot job's own image individually, + // sequentially, right before THAT job runs (`DockerRunJob` -> `DockerStart` -> + // `DockerResolveImageIfNotCached`, `start.go:334-355`, `docker.go:363-365`) — neither + // caller pre-pulls these three images as a batch ahead of time (see + // `commands/start/start.handler.ts`'s own `resolvedImages` comment and + // `commands/db/start/start.handler.ts`'s `resolvePostgresImage` comment, both of which + // explicitly exclude these from their own upfront pre-pulls). Batching the resolve here + // instead would mean one unreachable image (e.g. Storage's) fails the WHOLE setup + // before an earlier job (e.g. Realtime's) ever gets to run, even though Go would already + // have run it to completion by the time it reaches Storage's own resolve. + // `legacyRunStartMigrateJob` (`db-setup.ts`) resolves each of these lazily itself, right + // before running that job — see its own doc comment. + const dbSetupImages: LegacyStartDbSetupImages = { realtime: legacyResolvePinnedImage( "realtime", "realtime", @@ -298,31 +328,8 @@ export const legacyStartDatabase = ( 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({ + yield* legacyStartSetupLocalDatabase(spawner, { session, fs: input.fs, path: input.path, @@ -342,6 +349,7 @@ export const legacyStartDatabase = ( serviceRoleKey: setup.serviceRoleKey, storageTargetMigration: setup.storageTargetMigration, images: dbSetupImages, + projectEnvValues: setup.projectEnvValues, }); }), ); From 06391b495d74c9a1b54d59e3f81f5b753e6f7102 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 02:02:06 +0100 Subject: [PATCH 15/48] fix(cli): resolve PG15+ setup-job images per-job instead of batching upfront (review: PRRT_kwDOErm0O86VkCcF) legacyRunStartMigrateJob now resolves its own image individually, via legacyEnsureImagesCached, immediately before that specific job runs - matching Go's DockerRunJob -> DockerStart -> DockerResolveImageIfNotCached (docker.go:363-365), which resolves each one-shot migrate job's image sequentially, exactly where it's used. Previously start-database.ts batch-resolved all three (realtime/storage/auth) images upfront, so one unreachable image (e.g. Storage's) failed the whole fresh-volume setup before an earlier job (e.g. Realtime's) ever got to run, even though Go would already have run it to completion by the time it reaches Storage's own resolve. Threading projectEnvValues through this per-job resolve also preserves the existing project-dotenv-only registry-override behavior (legacyDockerRun.runCapture's own ambient resolver never sees it) - see "resolves an excluded service's migrate-job image through a project-dotenv-only registry override" in start.integration.test.ts. Also updates this module's header comment to accurately describe the still-unported pgcache.TryCacheMigrationsCatalog warm-up (start.go:371-379) as a real, tracked gap rather than a no-op divergence: the already-ported legacyTryCacheMigrationsCatalog would close it, but it needs LegacyEdgeRuntimeScript/LegacyPgDeltaSslProbe in its effect environment, which would widen legacyStartDatabase's (and both db start's and supabase start's) environment requirements across their entire call graph and test suites - deliberately deferred to a follow-up rather than folded into this hoist (review: PRRT_kwDOErm0O86VkCcB). --- .../legacy/shared/db-bootstrap/db-setup.ts | 90 ++++++++++++++----- .../shared/db-bootstrap/db-setup.unit.test.ts | 38 +++++++- 2 files changed, 104 insertions(+), 24 deletions(-) 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 27fe52c5b2..1471dc396f 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -58,9 +58,16 @@ * which only runs on a fresh volume. `start.handler.ts` calls it directly, outside * the `isFreshVolume` gate that wraps {@link legacyStartSetupLocalDatabase}. * - * 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). + * Go's best-effort `pgcache.TryCacheMigrationsCatalog` (`start.go:371-379`) is NOT called + * here. This IS a real gap, not a no-op divergence: it skips warming the + * `catalog-local-migrations-*` snapshot subsequent pg-delta workflows (`db diff`/`db push`) + * consume, and suppresses Go's own warning-on-failure text. The already-ported + * `legacyTryCacheMigrationsCatalog` (used by `db push`) would close this gap, but it needs + * `LegacyEdgeRuntimeScript`/`LegacyPgDeltaSslProbe` in its effect environment — adding it + * here would widen `legacyStartDatabase`'s (and both `db start`'s and `supabase start`'s + * own) environment requirements across their entire call graph and every test that + * exercises the fresh-volume setup path. Deliberately deferred to a dedicated follow-up + * rather than folded into this hoist — see CLI-1954's PR review thread. * * This module also duplicates ONE config-load pass: `legacyCheckDbToml` is called * internally (not threaded in from the caller) to resolve `[db.vault]`, `[db.seed]`, @@ -72,6 +79,7 @@ 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 { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; @@ -80,6 +88,7 @@ import { LegacyDbConfigLoadError } from "../legacy-db-config.errors.ts"; import { legacyCheckDbToml } 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 { legacyEnsureImagesCached, type LegacyImagePrepullError } from "./image-prepull.ts"; import { legacyMigrateAndSeed } from "../legacy-migrate-and-seed.ts"; import { LegacyMigrationApplyError, legacyExecSqlFile } from "../legacy-migration-apply.ts"; import type { LegacyMigrationSeedError } from "../legacy-seed.ts"; @@ -94,6 +103,8 @@ import { legacyStartInternalDbUrl, } from "./internal-db-connection.ts"; +type Spawner = ChildProcessSpawner["Service"]; + /** * Go's inline `RevokeDefaultDataApiPrivilegesSql` constant (`start.go:405-412`) — * NOT a `//go:embed` file (unlike the three large SQL templates), so transcribed @@ -126,7 +137,8 @@ export type LegacyStartSetupLocalDatabaseError = | LegacyStartDbSetupError | LegacyMigrationVaultError | LegacyMigrationApplyError - | LegacyMigrationSeedError; + | LegacyMigrationSeedError + | LegacyImagePrepullError; /** Already-resolved Docker images for the three PG15+ one-shot migrate jobs (`initSchema15`'s `initJobs`). */ export interface LegacyStartDbSetupImages { @@ -208,6 +220,16 @@ 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; + /** + * Project-`.env`-scoped `SUPABASE_INTERNAL_IMAGE_REGISTRY`/mirror overrides — threaded + * through to each one-shot migrate job's OWN per-image `legacyEnsureImagesCached` resolve + * (see {@link legacyRunStartMigrateJob}), matching Go's real process-env registry override, + * which applies uniformly to every `DockerResolveImageIfNotCached` call regardless of which + * code path triggers it. `LegacyDockerRun.runCapture`'s own ambient-only ChildProcessSpawner- + * scoped ancestor resolver (used for the long-running containers) does NOT see this — it only + * reads bare `process.env`. + */ + readonly projectEnvValues: Readonly> | undefined; } const errMessage = (e: unknown): string => @@ -283,21 +305,41 @@ const legacyStartInitSchemaPre15 = Effect.fnUntraced(function* ( * `Cmd` field), stdout discarded and stderr not teed (Go discards both outside * `--debug` — `utils.GetDebugLogger()`, `logger.go:10-15`). A non-zero exit fails * with the same shape as Go's `error running container: `. + * + * Resolves `opts.image` itself, individually, right here — via `legacyEnsureImagesCached` + * (NOT `LegacyDockerRun.runCapture`'s own ambient-only resolver, which never sees + * `opts.projectEnvValues`) — immediately before running THIS job, matching Go's + * `DockerRunJob` -> `DockerStart` -> `DockerResolveImageIfNotCached` (`docker.go:363-365`) + * resolving each one-shot job's own image individually, sequentially, exactly where it's + * used: neither caller pre-pulls these three images as a batch ahead of time (see + * `start-database.ts`'s own doc comment for why), and Go's registry-override env var applies + * uniformly to every `DockerResolveImageIfNotCached` call, including project-`.env`-scoped + * values — this call must see the same override the long-running containers' own resolve does. */ -const legacyRunStartMigrateJob = Effect.fnUntraced(function* (opts: { - readonly image: string; - readonly env: Readonly>; - readonly cmd: ReadonlyArray; - readonly networkId: string; -}) { +const legacyRunStartMigrateJob = Effect.fnUntraced(function* ( + spawner: Spawner, + opts: { + readonly image: string; + readonly env: Readonly>; + readonly cmd: ReadonlyArray; + readonly networkId: string; + readonly projectEnvValues: Readonly> | undefined; + }, +) { const docker = yield* LegacyDockerRun; const runtimeInfo = yield* RuntimeInfo; + const resolvedImages = yield* legacyEnsureImagesCached( + spawner, + [opts.image], + opts.projectEnvValues, + ); + const resolvedImage = resolvedImages.get(opts.image) ?? opts.image; // Go's `DockerStart` unconditionally appends the Linux-only `host.docker.internal: // host-gateway` extra host for every container it starts (`docker_linux.go`), // including one-shot jobs routed through the same `DockerStart` path. const extraHosts = runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; const runOpts: LegacyDockerRunOpts = { - image: opts.image, + image: resolvedImage, cmd: opts.cmd, env: opts.env, binds: [], @@ -305,10 +347,8 @@ const legacyRunStartMigrateJob = Effect.fnUntraced(function* (opts: { securityOpt: [], extraHosts, network: { _tag: "named", name: opts.networkId }, - // `opts.image` is already fully resolved (`start.handler.ts`'s `resolveImage`, which - // threads `projectEnvValues` through `legacyEnsureImagesCached`) — this layer's own - // ambient-only resolver must not re-resolve it. See `LegacyDockerRunOpts. - // skipImageResolve`'s doc comment. + // Already resolved, immediately above — `LegacyDockerRun.runCapture`'s own ambient-only + // resolver must not re-resolve it (it doesn't see `opts.projectEnvValues` at all). skipImageResolve: true, }; const result = yield* docker @@ -389,15 +429,17 @@ function legacyStartAuthMigrateEnv(input: { * fixed order (realtime, storage, auth). */ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( + spawner: Spawner, input: LegacyStartSetupLocalDatabaseInput, ) { const dbHost = legacyServiceContainerName("db", input.projectId); const dbPassword = legacyStartInternalDbPassword(input.dbUrl); if (input.config.realtime.enabled) { - yield* legacyRunStartMigrateJob({ + yield* legacyRunStartMigrateJob(spawner, { image: input.images.realtime, networkId: input.networkId, + projectEnvValues: input.projectEnvValues, env: legacyBuildRealtimeEnv({ ipVersion: input.config.realtime.ip_version, maxHeaderLength: input.config.realtime.max_header_length, @@ -443,17 +485,19 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( message: `invalid config for storage: ${errMessage(cause)}`, }), }); - yield* legacyRunStartMigrateJob({ + yield* legacyRunStartMigrateJob(spawner, { image: input.images.storage, networkId: input.networkId, + projectEnvValues: input.projectEnvValues, env: storageEnv, cmd: ["node", "dist/scripts/migrate-call.js"], }); } if (input.config.auth.enabled) { - yield* legacyRunStartMigrateJob({ + yield* legacyRunStartMigrateJob(spawner, { image: input.images.auth, networkId: input.networkId, + projectEnvValues: input.projectEnvValues, env: legacyStartAuthMigrateEnv({ apiUrl: input.apiUrl, authExternalUrl: input.authExternalUrl, @@ -474,6 +518,7 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( * `if utils.Config.Db.MajorVersion <= 14` check. */ const legacyStartInitSchema = Effect.fnUntraced(function* ( + spawner: Spawner, input: LegacyStartSetupLocalDatabaseInput, tmpDir: string, ) { @@ -489,7 +534,7 @@ const legacyStartInitSchema = Effect.fnUntraced(function* ( ); return; } - yield* legacyStartInitSchema15(input); + yield* legacyStartInitSchema15(spawner, input); }); /** @@ -567,6 +612,7 @@ export const legacyStartInitCurrentBranch = Effect.fnUntraced(function* ( * no health/readiness checks of its own. */ export const legacyStartSetupLocalDatabase = ( + spawner: Spawner, input: LegacyStartSetupLocalDatabaseInput, ): Effect.Effect< void, @@ -591,7 +637,7 @@ export const legacyStartSetupLocalDatabase = ( }), ), ); - yield* legacyStartInitSchema(input, tmpDir); + yield* legacyStartInitSchema(spawner, input, tmpDir); yield* legacyStartApplyApiPrivileges(input, tmpDir, toml.baseline.apiAutoExposeNewTables); }), ); @@ -648,8 +694,8 @@ export const legacyStartSetupLocalDatabase = ( }); // 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 — see this module's header for why (a real, + // tracked gap, not a no-op divergence). // // `initCurrentBranch` (start.go:233-241) is NOT called here — see this // module's header for why it moved to the caller instead. 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..501b5aada6 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 @@ -5,7 +5,8 @@ import type { ProjectConfig } from "@supabase/config"; import { ProjectConfigSchema } from "@supabase/config"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path, Schema } from "effect"; +import { Deferred, Effect, FileSystem, Layer, Path, Schema, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { mockOutput, mockRuntimeInfo } from "../../../../tests/helpers/mocks.ts"; import type { LegacyDbSession } from "../legacy-db-connection.service.ts"; @@ -77,6 +78,34 @@ function mockDockerRun(opts: { exitCode?: number } = {}) { return { layer, runs }; } +/** + * A `ChildProcessSpawner` where `docker image inspect ` always exits 0 (image + * already cached) — feeds `legacyRunStartMigrateJob`'s own per-image `legacyEnsureImagesCached` + * resolve (see `db-setup.ts`), so every job's `image` resolves to the SAME raw string this + * suite's `baseInput` already asserts on, without needing a real Docker daemon. + */ +function mockAlwaysCachedSpawner(): ChildProcessSpawner.ChildProcessSpawner["Service"] { + return ChildProcessSpawner.make((_command) => + Effect.gen(function* () { + 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, + }); + }), + ); +} + function mockDockerRunFails() { const layer = Layer.succeed(LegacyDockerRun, { run: () => Effect.fail(new LegacyDockerRunError({ message: "failed to run docker" })), @@ -124,6 +153,7 @@ function baseInput( storage: "public.ecr.aws/supabase/storage-api:v1.0.0", auth: "public.ecr.aws/supabase/gotrue:v2.170.0", }, + projectEnvValues: undefined, ...overrides, }; } @@ -136,7 +166,11 @@ const run = ( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - return yield* legacyStartSetupLocalDatabase({ ...input, fs, path }); + return yield* legacyStartSetupLocalDatabase(mockAlwaysCachedSpawner(), { + ...input, + fs, + path, + }); }).pipe( Effect.provide( Layer.mergeAll( From 5c07a87d602c1bcae42c2e17eb2153f5945f884f Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 02:11:21 +0100 Subject: [PATCH 16/48] 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 1e7d21d490cef5f3391068cf408c4adc3aba63d7 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 03:15:39 +0100 Subject: [PATCH 17/48] fix(cli): apply project-dotenv Docker client env before db start bootstrap (review: PRRT_kwDOErm0O86VkkNY) Go's godotenv.Load installs a project .env's DOCKER_HOST/DOCKER_CONTEXT/etc into the process environment (pkg/config/config.go:1261) before any Docker work, so a daemon target configured only in supabase/.env still governs start/stop/status/db start. legacyLoadLocalProjectContext never applied those keys to process.env, so legacyGetHostname() and every Docker subprocess this PR's native db start bootstrap spawns silently fell back to the shell's own environment instead. --- .../shared/legacy-local-project-context.ts | 21 +++++++ .../legacy-local-project-context.unit.test.ts | 63 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 apps/cli/src/legacy/shared/legacy-local-project-context.unit.test.ts diff --git a/apps/cli/src/legacy/shared/legacy-local-project-context.ts b/apps/cli/src/legacy/shared/legacy-local-project-context.ts index 62341fc56a..9958e9b6e7 100644 --- a/apps/cli/src/legacy/shared/legacy-local-project-context.ts +++ b/apps/cli/src/legacy/shared/legacy-local-project-context.ts @@ -7,6 +7,7 @@ import { } from "@supabase/config"; import { Effect, FileSystem, Path, Schema } from "effect"; +import { legacyIsDockerClientEnvKey } from "./db-bootstrap/docker-create-args.ts"; import { legacyResolveLocalProjectId, legacySanitizeProjectId } from "./legacy-docker-ids.ts"; import { legacyGetHostname } from "./legacy-hostname.ts"; import { legacyResolveProjectEnvironmentValues } from "./legacy-project-environment.ts"; @@ -92,6 +93,26 @@ export const legacyLoadLocalProjectContext = ( catch: (cause) => mapConfigLoadError(`failed to read config: ${String(cause)}`), }); + // Go's `godotenv.Load` (`loadEnvIfExists`, called by `loadNestedEnv` above this same + // `Config.Load` pass, `pkg/config/config.go:1261`) installs every parsed dotenv key into + // the process's OWN environment via `os.Setenv` — never overriding an already-set key — + // so it's visible to every subsequent Docker-client-facing call in THIS process, not just + // to `Config.Load`'s own field decoding. `legacyGetHostname()` right below, and every + // Docker subprocess this context's callers (`start`/`stop`/`status`/`db start`) spawn + // afterward, read `DOCKER_HOST`/`DOCKER_CONTEXT`/etc straight from `process.env` + // (`legacy-hostname.ts`, `extendEnv: true` at every `docker`/`podman` spawn site) — a + // daemon target configured ONLY in a project `.env` file (never exported to the shell) + // must land here, before `legacyGetHostname()` runs, or this whole context resolves + // against the WRONG daemon. Deliberately permanent (unlike `legacyApplyProjectEnv`'s own + // narrower, explicitly-scoped opt-in around a single command's container work) — matching + // Go's own non-reverting `os.Setenv`, which persists for that single-command process's + // entire lifetime. + for (const [key, value] of Object.entries(projectEnvValues)) { + if (legacyIsDockerClientEnvKey(key) && process.env[key] === undefined) { + process.env[key] = value; + } + } + // An absent config.toml is not a failure — Go's `flags.LoadConfig` still resolves a project id // via the workdir basename default. Only a malformed file (`loadProjectConfig` failing rather // than returning `null`) is a hard error. diff --git a/apps/cli/src/legacy/shared/legacy-local-project-context.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-project-context.unit.test.ts new file mode 100644 index 0000000000..d41c576422 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-local-project-context.unit.test.ts @@ -0,0 +1,63 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { useLegacyTempWorkdir } from "../../../tests/helpers/legacy-mocks.ts"; +import { legacyLoadLocalProjectContext } from "./legacy-local-project-context.ts"; + +/** + * Docker-client env keys `legacyLoadLocalProjectContext` installs from a project `.env` + * (see its own doc comment) are exactly `legacyIsDockerClientEnvKey`'s allowlist + * (`db-bootstrap/docker-create-args.ts`) — `DOCKER_HOST` stands in for the whole set here. + */ +const DOCKER_HOST_KEY = "DOCKER_HOST"; + +function writeDotEnv(workdir: string, contents: string): void { + mkdirSync(workdir, { recursive: true }); + writeFileSync(join(workdir, ".env"), contents); +} + +const tempRoot = useLegacyTempWorkdir("supabase-legacy-project-context-"); + +describe("legacyLoadLocalProjectContext", () => { + const previousDockerHost = process.env[DOCKER_HOST_KEY]; + + afterEach(() => { + if (previousDockerHost === undefined) delete process.env[DOCKER_HOST_KEY]; + else process.env[DOCKER_HOST_KEY] = previousDockerHost; + }); + + it.effect( + "installs a project .env's DOCKER_HOST into process.env before resolving hostname, matching Go's godotenv.Load", + () => { + delete process.env[DOCKER_HOST_KEY]; + const workdir = tempRoot.current; + writeDotEnv(workdir, `DOCKER_HOST=tcp://project-dotenv-host:2375\n`); + + return legacyLoadLocalProjectContext(workdir, (message) => new Error(message)).pipe( + Effect.map(() => { + expect(process.env[DOCKER_HOST_KEY]).toBe("tcp://project-dotenv-host:2375"); + }), + Effect.provide(BunServices.layer), + ); + }, + ); + + it.effect( + "never overrides an already-set DOCKER_HOST, matching godotenv.Load's shell-env-wins semantics", + () => { + process.env[DOCKER_HOST_KEY] = "tcp://real-shell-host:2375"; + const workdir = tempRoot.current; + writeDotEnv(workdir, `DOCKER_HOST=tcp://project-dotenv-host:2375\n`); + + return legacyLoadLocalProjectContext(workdir, (message) => new Error(message)).pipe( + Effect.map(() => { + expect(process.env[DOCKER_HOST_KEY]).toBe("tcp://real-shell-host:2375"); + }), + Effect.provide(BunServices.layer), + ); + }, + ); +}); From c223fdb83f998098c9660d3c7089baac5a59fbc3 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 03:15:52 +0100 Subject: [PATCH 18/48] fix(cli): tee one-shot migrate job stderr under --debug (review: PRRT_kwDOErm0O86VkkNb) Go's initSchema15 passes utils.GetDebugLogger() (os.Stderr under --debug, else io.Discard) as each PG15+ realtime/storage/auth one-shot migrate job's stderr writer (start.go:349-353), so a failed fresh-volume migration job's own diagnostics are visible under --debug, not just its exit code. legacyRunStartMigrateJob called runCapture with no teeStderr option at all, so db start/supabase start --debug surfaced only "error running container: exit N" regardless of the flag. Thread --debug through LegacyStartDatabaseSetupInput/LegacyStartSetupLocalDatabaseInput into runCapture's existing teeStderr option. --- .../legacy/commands/db/start/start.handler.ts | 3 ++ .../db/start/start.integration.test.ts | 4 ++ .../legacy/commands/start/start.handler.ts | 7 +++ .../legacy/shared/db-bootstrap/db-setup.ts | 24 +++++++-- .../shared/db-bootstrap/db-setup.unit.test.ts | 52 ++++++++++++++++++- .../shared/db-bootstrap/start-database.ts | 3 ++ 6 files changed, 87 insertions(+), 6 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 582f398915..a27418d3ae 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -4,6 +4,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 { + LegacyDebugFlag, LegacyNetworkIdFlag, legacyResolveExperimentalWithProjectEnv, } from "../../../../shared/legacy/global-flags.ts"; @@ -99,6 +100,7 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega const runtimeInfo = yield* RuntimeInfo; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const networkIdFlag = yield* LegacyNetworkIdFlag; + const debug = yield* LegacyDebugFlag; const body = Effect.gen(function* () { // Go's `flags.LoadConfig(fsys)` runs first thing in `start.Run` @@ -388,6 +390,7 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega authEnabledForSetup: bootstrapConfig.authEnabledForSetup, serviceVersionOverrides: bootstrapConfig.serviceVersionOverrides, projectEnvValues, + debug, }, 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 c9139497ff..0d66d9c1dc 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"; @@ -262,6 +263,8 @@ interface SetupOpts { readonly networkId?: string; /** `--experimental`/`SUPABASE_EXPERIMENTAL`. Defaults to `false`. */ readonly experimental?: boolean; + /** `--debug`. Defaults to `false`. */ + readonly debug?: boolean; } function setup(opts: SetupOpts = {}) { @@ -302,6 +305,7 @@ function setup(opts: SetupOpts = {}) { ), Layer.succeed(CliArgs, { args: ["db", "start"] }), Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? false), + Layer.succeed(LegacyDebugFlag, opts.debug ?? false), ); return { layer, out, telemetry, child, dbSession }; } diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index 2ef54f016e..6246cdbce9 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -1742,6 +1742,12 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // falls through to that SAME unconditional tail (`start.go:74-87`) rather // than returning early from the whole command. const bringUp = Effect.gen(function* () { + // `--debug` — threaded into `setup.debug` below so a failed fresh-volume + // Realtime/Storage/Auth migrate job (see `db-setup.ts`'s `legacyRunStartMigrateJob` + // doc comment) tees its own stderr, matching Go's `initSchema15` passing + // `utils.GetDebugLogger()` as that job's stderr writer (`start.go:349-353`). + const bringUpDebug = yield* LegacyDebugFlag; + // Runs the exact Go `StartDatabase` sequence (network -> volume probe -> container // create+start -> health wait -> fresh-volume setup -> `_current_branch`) — shared // with `db start`'s own native container bootstrap, see `legacyStartDatabase`'s own @@ -1849,6 +1855,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta authEnabledForSetup, serviceVersionOverrides, projectEnvValues, + debug: bringUpDebug, }, onFreshVolumeResolved: (resolved) => { isFreshVolume = resolved; 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 1471dc396f..ccc996bd7f 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -230,6 +230,13 @@ export interface LegacyStartSetupLocalDatabaseInput { * reads bare `process.env`. */ readonly projectEnvValues: Readonly> | undefined; + /** + * `--debug` — threaded to each PG15+ one-shot migrate job (see + * {@link legacyRunStartMigrateJob}'s own doc comment) so a failed Realtime/Storage/Auth + * migration job's own stderr is visible, matching Go's `initSchema15` passing + * `utils.GetDebugLogger()` as the job's stderr writer (`start.go:349-353`). + */ + readonly debug: boolean; } const errMessage = (e: unknown): string => @@ -302,9 +309,13 @@ const legacyStartInitSchemaPre15 = Effect.fnUntraced(function* ( * Runs one PG15+ one-shot service-migration job to completion (Go's * `utils.DockerRunJob` = `DockerRunOnceWithStream`, `docker.go:457-459,469-487`): * foreground, same Docker network as `db`, no entrypoint override (Go's plain - * `Cmd` field), stdout discarded and stderr not teed (Go discards both outside - * `--debug` — `utils.GetDebugLogger()`, `logger.go:10-15`). A non-zero exit fails - * with the same shape as Go's `error running container: `. + * `Cmd` field), stdout always discarded (Go's own `stdout` writer here is always + * `io.Discard`, `start.go:352`) and stderr teed to the parent process's own stderr ONLY + * under `--debug` — Go passes `logger := utils.GetDebugLogger()` as the job's stderr + * writer (`os.Stderr` under `--debug`, else `io.Discard`, `logger.go:10-15`) — so a + * fresh-volume Realtime/Storage/Auth migration job's own diagnostics are visible when + * `db start --debug`/`supabase start --debug` is used, not just its exit code. A + * non-zero exit fails with the same shape as Go's `error running container: `. * * Resolves `opts.image` itself, individually, right here — via `legacyEnsureImagesCached` * (NOT `LegacyDockerRun.runCapture`'s own ambient-only resolver, which never sees @@ -324,6 +335,8 @@ const legacyRunStartMigrateJob = Effect.fnUntraced(function* ( readonly cmd: ReadonlyArray; readonly networkId: string; readonly projectEnvValues: Readonly> | undefined; + /** `--debug` — Go's `utils.GetDebugLogger()`, see this function's own doc comment. */ + readonly debug: boolean; }, ) { const docker = yield* LegacyDockerRun; @@ -352,7 +365,7 @@ const legacyRunStartMigrateJob = Effect.fnUntraced(function* ( skipImageResolve: true, }; const result = yield* docker - .runCapture(runOpts) + .runCapture(runOpts, { teeStderr: opts.debug }) .pipe(Effect.mapError((cause) => new LegacyStartDbSetupError({ message: cause.message }))); if (result.exitCode !== 0) { return yield* Effect.fail( @@ -440,6 +453,7 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( image: input.images.realtime, networkId: input.networkId, projectEnvValues: input.projectEnvValues, + debug: input.debug, env: legacyBuildRealtimeEnv({ ipVersion: input.config.realtime.ip_version, maxHeaderLength: input.config.realtime.max_header_length, @@ -489,6 +503,7 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( image: input.images.storage, networkId: input.networkId, projectEnvValues: input.projectEnvValues, + debug: input.debug, env: storageEnv, cmd: ["node", "dist/scripts/migrate-call.js"], }); @@ -498,6 +513,7 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( image: input.images.auth, networkId: input.networkId, projectEnvValues: input.projectEnvValues, + debug: input.debug, env: legacyStartAuthMigrateEnv({ apiUrl: input.apiUrl, authExternalUrl: input.authExternalUrl, 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 501b5aada6..09c65b8cbf 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 @@ -63,10 +63,12 @@ function fakeSession() { function mockDockerRun(opts: { exitCode?: number } = {}) { const runs: Array = []; + const captureOptsCalls: Array<{ readonly teeStderr?: boolean } | undefined> = []; const layer = Layer.succeed(LegacyDockerRun, { run: () => Effect.succeed(opts.exitCode ?? 0), - runCapture: (runOpts) => { + runCapture: (runOpts, captureOpts) => { runs.push(runOpts); + captureOptsCalls.push(captureOpts); return Effect.succeed({ exitCode: opts.exitCode ?? 0, stdout: new Uint8Array(), @@ -75,7 +77,7 @@ function mockDockerRun(opts: { exitCode?: number } = {}) { }, runStream: () => Effect.succeed({ exitCode: opts.exitCode ?? 0, stderr: "" }), }); - return { layer, runs }; + return { layer, runs, captureOptsCalls }; } /** @@ -154,6 +156,7 @@ function baseInput( auth: "public.ecr.aws/supabase/gotrue:v2.170.0", }, projectEnvValues: undefined, + debug: false, ...overrides, }; } @@ -393,6 +396,51 @@ describe("legacyStartSetupLocalDatabase", () => { }, ); + it.effect( + "--debug tees every one-shot job's stderr, matching Go's utils.GetDebugLogger()", + () => { + const workdir = makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + const config = decodeConfig({ + realtime: { enabled: true }, + storage: { enabled: false }, + auth: { enabled: true }, + }); + return run( + baseInput(workdir, session, { majorVersion: 15, config, debug: true }), + out, + docker, + ).pipe( + Effect.map(() => { + expect(docker.runs.length).toBe(2); + expect(docker.captureOptsCalls).toEqual([{ teeStderr: true }, { teeStderr: true }]); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }, + ); + + it.effect("without --debug, one-shot jobs run with teeStderr off", () => { + const workdir = makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + const config = decodeConfig({ storage: { enabled: false }, auth: { enabled: false } }); + return run( + baseInput(workdir, session, { majorVersion: 15, config, debug: false }), + out, + docker, + ).pipe( + Effect.map(() => { + expect(docker.runs.length).toBe(1); + expect(docker.captureOptsCalls).toEqual([{ teeStderr: false }]); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + it.effect("a non-zero exit from a one-shot job fails the whole pipeline", () => { const workdir = makeWorkdir(); const { session } = fakeSession(); 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 546c630c1d..39b5e73627 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts @@ -153,6 +153,8 @@ interface LegacyStartDatabaseSetupInput { readonly authEnabledForSetup: boolean; readonly serviceVersionOverrides: LocalServiceVersionOverrides; readonly projectEnvValues: Readonly> | undefined; + /** `--debug` — threaded straight through to {@link LegacyStartSetupLocalDatabaseInput.debug}; see its own doc comment. */ + readonly debug: boolean; } export interface LegacyStartDatabaseInput { @@ -350,6 +352,7 @@ export const legacyStartDatabase = ( storageTargetMigration: setup.storageTargetMigration, images: dbSetupImages, projectEnvValues: setup.projectEnvValues, + debug: setup.debug, }); }), ); From 9e14165dd76e6fc71a005d08f06986901680d850 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 03:16:02 +0100 Subject: [PATCH 19/48] docs(cli): document project-dotenv Docker env + --debug migrate-job teeing (review: PRRT_kwDOErm0O86VkkNY, PRRT_kwDOErm0O86VkkNb) Record both start/SIDE_EFFECTS.md fixes: DOCKER_HOST/DOCKER_CONTEXT/etc are now also read from a project .env, and --debug tees the fresh-volume one-shot migrate jobs' stderr. --- .../legacy/commands/db/start/SIDE_EFFECTS.md | 40 +++++++++++-------- .../src/legacy/commands/start/SIDE_EFFECTS.md | 25 +++++++----- 2 files changed, 38 insertions(+), 27 deletions(-) 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 75e780f87e..055f158f28 100644 --- a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md @@ -110,28 +110,34 @@ 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 | -| `SUPABASE_EXPERIMENTAL` (or `--experimental`) | fresh volume + no pg-delta: applies `db.migrations.schema_paths` files instead of `migrations/*.sql` | 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 | +| `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` | Read (ambient shell OR a project `.env`/`.env.`/`.env.local` file — matching Go's `godotenv.Load`, which installs these into the process environment before any Docker work) to pick the Docker daemon this whole command talks to | no | `--network-id` (a global CLI flag, not an environment variable — `shared/legacy/global-flags.ts`) forces every created container/network onto that Docker network instead of the generated `supabase_network_`. +`--debug` tees each fresh-volume PG15+ one-shot migrate job's (realtime/storage/auth) own +stderr to the parent process's stderr in real time, matching Go's `utils.GetDebugLogger()` +(`os.Stderr` under `--debug`, else discarded) — outside `--debug` only the job's exit code is +surfaced on failure. + ## Exit Codes | Code | Condition | diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md index 429c91a18d..a7ad34d142 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -161,19 +161,24 @@ 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` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` | Read (ambient shell OR a project `.env`/`.env.`/`.env.local` file — matching Go's `godotenv.Load`) to discover the Docker daemon this whole command talks to; `DOCKER_HOST` is also 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`. +`--debug` tees the fresh-volume PG15+ one-shot migrate jobs' (realtime/storage/auth) own +stderr to the parent process's stderr in real time, matching Go's `utils.GetDebugLogger()` +(`os.Stderr` under `--debug`, else discarded) — outside `--debug` only each job's exit code is +surfaced on failure. + ## Exit Codes | Code | Condition | From 7cd904d2cd623e90f7572c543a51379657d0a038 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 03:26:23 +0100 Subject: [PATCH 20/48] 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 3bb14c19286aae50a1f731ff2035c381a5c666e3 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 04:11:02 +0100 Subject: [PATCH 21/48] fix(cli): recognize DOCKER_CONFIG as a Docker-client env var (review: PRRT_kwDOErm0O86Vk-ex) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's docker/cli reads DOCKER_CONFIG (`cli/config/config.go`'s EnvOverrideConfigDir) to locate config.json/the context store, and legacyGetHostname()'s dockerConfigDir() reads the same env var — but legacyIsDockerClientEnvKey's whitelist omitted it, so a project dotenv that set only DOCKER_CONFIG never reached process.env, silently falling back to the ambient ~/.docker config for both hostname resolution and every docker/podman subprocess. --- .../src/legacy/shared/db-bootstrap/docker-create-args.ts | 8 ++++++++ .../shared/db-bootstrap/docker-create-args.unit.test.ts | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts b/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts index 5e3d18fd53..d9edd10620 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts @@ -396,6 +396,14 @@ const DOCKER_CLIENT_ENV_KEYS: ReadonlySet = new Set([ "DOCKER_CERT_PATH", "DOCKER_CONTEXT", "DOCKER_API_VERSION", + // `docker/cli`'s own `EnvOverrideConfigDir` (`cli/config/config.go:25`) — the same env var + // `legacyGetHostname`'s `dockerConfigDir()` reads to locate `config.json`/the context store. + // Without this, a project dotenv that sets ONLY `DOCKER_CONFIG` (no `DOCKER_HOST`/ + // `DOCKER_CONTEXT`) would never reach `process.env` via `legacy-local-project-context.ts`'s + // Docker-client-env loop, so both hostname resolution and every `docker`/`podman` subprocess + // this process spawns would silently fall back to the ambient `~/.docker` config instead of the + // project-selected one — the same class of bug already fixed for `DOCKER_HOST`/`DOCKER_CONTEXT`. + "DOCKER_CONFIG", ]); /** Whether `key` configures the Docker/Podman CLI client itself — see {@link DOCKER_CLIENT_ENV_KEYS}. */ diff --git a/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.unit.test.ts index d4b2e86802..e55386f87a 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.unit.test.ts @@ -149,6 +149,10 @@ describe("legacyBuildStartContainerCreateArgs", () => { expect(legacyIsDockerClientEnvKey("DOCKER_CERT_PATH")).toBe(true); expect(legacyIsDockerClientEnvKey("DOCKER_CONTEXT")).toBe(true); expect(legacyIsDockerClientEnvKey("DOCKER_API_VERSION")).toBe(true); + // `docker/cli`'s `EnvOverrideConfigDir` (`cli/config/config.go:25`) — also read by + // `legacyGetHostname`'s `dockerConfigDir()`, so a project-dotenv-only override must reach + // `process.env` the same way `DOCKER_HOST`/`DOCKER_CONTEXT` already do (review: PRRT_kwDOErm0O86Vk-ex). + expect(legacyIsDockerClientEnvKey("DOCKER_CONFIG")).toBe(true); expect(legacyIsDockerClientEnvKey("DB_PASSWORD")).toBe(false); }); From 9f7b87edd3949ffc3bb171205c4d3cc02fd246dc Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 04:11:21 +0100 Subject: [PATCH 22/48] fix(cli): validate auth.rate_limit and gate SMS warning on auth.enabled in db start (review: PRRT_kwDOErm0O86Vk-e0, PRRT_kwDOErm0O86Vk-e2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's Config.Load decodes auth.rate_limit.* (plain uints) unconditionally in the same UnmarshalExact pass as the duration fields db start already eagerly re-validates, regardless of auth.enabled or whether db start ever reads the field — a malformed SUPABASE_AUTH_RATE_LIMIT_* override must fail the command the same way. Hoisted resolveGotrueRateLimit out of commands/start/start.handler.ts into legacy-local-config-values.ts (now a second caller, per apps/cli/CLAUDE.md's "Hoist Before You Duplicate") and call it from db start's own eager-validation block. Separately, Go's (s *sms) validate() — the source of the "no SMS provider is enabled" warning — only runs inside `if c.Auth.Enabled` (config.go:1087,1145). db start's port printed it unconditionally; gate it on the same SUPABASE_AUTH_ENABLED-overridden value Go's Validate reads, so a disabled-auth project with sms.enable_signup=true and no provider no longer prints a warning Go never emits. --- .../legacy/commands/db/start/start.handler.ts | 28 ++++++-- .../db/start/start.integration.test.ts | 43 ++++++++++++ .../legacy/commands/start/start.handler.ts | 58 +-------------- .../shared/legacy-local-config-values.ts | 70 ++++++++++++++++++- 4 files changed, 135 insertions(+), 64 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 a27418d3ae..d080766b9d 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -25,6 +25,7 @@ import { legacyResolveAuthMfa, legacyResolveAuthSms, legacyResolveDbSettingsEnvOverrides, + legacyResolveGotrueRateLimit, legacyResolveGotrueSessions, legacyResolveLocalConfigValues, legacyResolveLocalJwks, @@ -150,12 +151,20 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega yield* wrapDbConfigOverride("auth.sms.max_frequency", () => legacyParseGoDuration(smsForValidation.max_frequency), ); - // Go's `(s *sms) validate()` (`config.go:1412-1415`) prints this and downgrades - // `EnableSignup` to `false` when no provider is enabled — `legacyResolveAuthSms` already - // applies the downgrade itself, so this only needs to detect whether that branch fired (the - // user configured `enable_signup = true` with every provider disabled) to reproduce the - // matching warning, same as `commands/start/start.handler.ts`'s identical check. + // Go's `(s *sms) validate()` — including this print and the `EnableSignup` downgrade — only + // runs `if c.Auth.Enabled` (`config.go:1087,1145`); `legacyResolveAuthSms` already applies the + // downgrade unconditionally (needed for the duration check above, which Go decodes regardless + // of `auth.enabled`), so the warning itself must be re-gated here on the SAME + // `SUPABASE_AUTH_ENABLED`-overridden value `Validate` reads, or a disabled-auth project with + // `sms.enable_signup = true` and no provider would wrongly print a warning Go never emits. + const authEnabledForValidation = legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLED", + config.auth.enabled, + "auth.enabled", + projectEnvValues, + ); if ( + authEnabledForValidation && !smsForValidation.twilio.enabled && !smsForValidation.twilio_verify.enabled && !smsForValidation.messagebird.enabled && @@ -189,6 +198,15 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega legacyResolveAuthMfa(config.auth.mfa, projectEnvValues).phone.max_frequency, ), ); + // Go's `Auth.RateLimit` (plain `uint`s, `pkg/config/auth.go:200-208`) is decoded by the SAME + // unconditional `Config.Load` pass as the duration fields above — unlike `auth.sms`/`auth.mfa`, + // it has no `Enabled`-gated `validate()` method at all (`config.go:1087-1153` never mentions + // it), so a malformed override (e.g. `SUPABASE_AUTH_RATE_LIMIT_EMAIL_SENT=bogus`) must fail + // `db start` regardless of `auth.enabled`, matching `commands/start/start.handler.ts`'s + // identical eager call. + yield* wrapDbConfigOverride("auth.rate_limit", () => + legacyResolveGotrueRateLimit(config.auth.rate_limit, projectEnvValues), + ); // Go's AssertSupabaseDbIsRunning: if the db container is already up, print to // stderr and return nil (exit 0). Already native — see this module's header. Runs AFTER 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 0d66d9c1dc..da355bbcd2 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 @@ -676,6 +676,31 @@ describe("legacy db start", () => { }, ); + it.live( + "fails with a typed config error on a malformed SUPABASE_AUTH_RATE_LIMIT_EMAIL_SENT override, before any container is created", + () => { + // Go's `Auth.RateLimit` (plain `uint`s, `pkg/config/auth.go:200-208`) has no `Enabled`-gated + // `validate()` method — its only Go-side check is the unconditional `uint` type-decode inside + // `Config.Load`'s single pass, which fails a non-numeric override regardless of `auth.enabled` + // or whether `db start` itself ever reads the field (review: PRRT_kwDOErm0O86Vk-e0). + const { layer, child } = setup({}); + writeFileSync( + join(tempRoot.current, "supabase", ".env"), + "SUPABASE_AUTH_RATE_LIMIT_EMAIL_SENT=bogus\n", + ); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const message = JSON.stringify(exit.cause); + expect(message).toContain("LegacyDbConfigLoadError"); + expect(message).toContain("auth.rate_limit"); + } + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + }); + }, + ); + it.live("fails on a malformed auth duration field even when the db is already running", () => { // Go's `flags.LoadConfig` (and therefore this eager duration validation) runs before // `AssertSupabaseDbIsRunning` in `start.Run` (`internal/db/start/start.go:45-47`) — a @@ -712,6 +737,24 @@ describe("legacy db start", () => { }, ); + it.live( + "does not warn about SMS when auth is disabled, matching Go's Enabled-gated (s *sms) validate()", + () => { + // Go only calls `Sms.validate()` — the source of this warning — `if c.Auth.Enabled` + // (`config.go:1087,1145`). A disabled-auth project with `enable_signup = true` and no + // provider configured must NOT print the warning (review: PRRT_kwDOErm0O86Vk-e2). + const { layer, out } = setup({ + configContents: + 'project_id = "test"\n[auth]\nenabled = false\n[auth.sms]\nenable_signup = true\n', + route: freshVolumeRoute(defaultRoute()), + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).not.toContain("no SMS provider is enabled"); + }); + }, + ); + it.live( "does not add the Linux-only host.docker.internal extra host on a non-Linux platform", () => { diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index 6246cdbce9..3f58a99c8c 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -83,6 +83,7 @@ import { legacyResolveConfiguredSigningKeys, legacyResolveAuthExternalUrl, legacyResolveDbSettingsEnvOverrides, + legacyResolveGotrueRateLimit as resolveGotrueRateLimit, legacyResolveGotrueSessions as resolveGotrueSessions, legacyResolveLocalConfigValues, legacyResolveLocalJwks, @@ -316,63 +317,6 @@ function resolveGotruePasskeyWebauthn( return { passkeyEnabled, webauthn }; } -/** - * Go's `Auth.RateLimit` (`pkg/config/auth.go:200-208`) is a value-typed - * struct of plain `uint`s, always Viper-bound regardless of `[auth.rate_ - * limit]` presence, so every `SUPABASE_AUTH_RATE_LIMIT_*` override applies - * before `start.go` builds `GOTRUE_RATE_LIMIT_*` — no raw-document presence - * gate needed, matching the existing `db.pooler`/SMS numeric-field precedent. - */ -function resolveGotrueRateLimit( - rateLimit: ProjectConfig["auth"]["rate_limit"], - projectEnvValues: Readonly> | undefined, -): ProjectConfig["auth"]["rate_limit"] { - return { - anonymous_users: legacyEnvOverrideUint( - "SUPABASE_AUTH_RATE_LIMIT_ANONYMOUS_USERS", - "auth.rate_limit.anonymous_users", - rateLimit.anonymous_users, - projectEnvValues, - ), - token_refresh: legacyEnvOverrideUint( - "SUPABASE_AUTH_RATE_LIMIT_TOKEN_REFRESH", - "auth.rate_limit.token_refresh", - rateLimit.token_refresh, - projectEnvValues, - ), - sign_in_sign_ups: legacyEnvOverrideUint( - "SUPABASE_AUTH_RATE_LIMIT_SIGN_IN_SIGN_UPS", - "auth.rate_limit.sign_in_sign_ups", - rateLimit.sign_in_sign_ups, - projectEnvValues, - ), - token_verifications: legacyEnvOverrideUint( - "SUPABASE_AUTH_RATE_LIMIT_TOKEN_VERIFICATIONS", - "auth.rate_limit.token_verifications", - rateLimit.token_verifications, - projectEnvValues, - ), - email_sent: legacyEnvOverrideUint( - "SUPABASE_AUTH_RATE_LIMIT_EMAIL_SENT", - "auth.rate_limit.email_sent", - rateLimit.email_sent, - projectEnvValues, - ), - sms_sent: legacyEnvOverrideUint( - "SUPABASE_AUTH_RATE_LIMIT_SMS_SENT", - "auth.rate_limit.sms_sent", - rateLimit.sms_sent, - projectEnvValues, - ), - web3: legacyEnvOverrideUint( - "SUPABASE_AUTH_RATE_LIMIT_WEB3", - "auth.rate_limit.web3", - rateLimit.web3, - projectEnvValues, - ), - }; -} - /** * Go's `Auth.Web3` (`pkg/config/auth.go:379-382`) is a value-typed struct — * same no-presence-gate reasoning as {@link resolveGotrueRateLimit}. diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index c6950ed9df..2cd8f640a8 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -917,8 +917,9 @@ export type LegacyResolvedAuthEmail = Omit< /** * Go's `Auth.Email` is a value-typed (non-pointer) struct (`pkg/config/auth.go:174,242-253`), * always Viper/`AutomaticEnv`-bound regardless of `[auth.email]` presence in config.toml - * (`config.go:580-586`) — same reasoning as {@link resolveGotrueRateLimit}/`resolveGotrueSessions` - * in `start.handler.ts`, just hoisted here since `readAuthEmailTemplateContent`'s validation-only + * (`config.go:580-586`) — same reasoning as {@link legacyResolveGotrueRateLimit}/ + * {@link legacyResolveGotrueSessions} elsewhere in this module, just hoisted here since + * `readAuthEmailTemplateContent`'s validation-only * file-read ALSO needs the override-aware `template`/`notification` maps, not just * `start.handler.ts`'s GoTrue env builder — same single-source/two-consumer shape as * {@link legacyResolveAuthExternalProviders}. @@ -1656,6 +1657,71 @@ export function legacyResolveAuthMfa( }; } +/** + * Go's `Auth.RateLimit` (`pkg/config/auth.go:200-208`) is a value-typed struct of plain `uint`s, + * always Viper-bound regardless of `[auth.rate_limit]` presence, so every + * `SUPABASE_AUTH_RATE_LIMIT_*` override applies before `start.go` builds `GOTRUE_RATE_LIMIT_*` — + * no raw-document presence gate needed, matching the existing `db.pooler`/SMS numeric-field + * precedent. Unlike `auth.sms`/`auth.mfa`, `rateLimit` has no `Enabled`-gated Go `validate()` + * method at all (`config.go:1087-1153` never mentions `RateLimit`) — its only Go-side check is + * the unconditional `uint` type-decode inside `Config.Load`'s single `UnmarshalExact` pass, so + * callers resolve it eagerly and unconditionally, with no `authEnabled` gate. + * + * Hoisted here (originally private to `commands/start/start.handler.ts`) once + * `commands/db/start/start.handler.ts` became a second caller — both need the same eager, + * unconditional `auth.rate_limit.*` resolution to reproduce Go's `Config.Load` decode, per + * `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate". + */ +export function legacyResolveGotrueRateLimit( + rateLimit: ProjectConfig["auth"]["rate_limit"], + projectEnvValues: Readonly> | undefined, +): ProjectConfig["auth"]["rate_limit"] { + return { + anonymous_users: legacyEnvOverrideUint( + "SUPABASE_AUTH_RATE_LIMIT_ANONYMOUS_USERS", + "auth.rate_limit.anonymous_users", + rateLimit.anonymous_users, + projectEnvValues, + ), + token_refresh: legacyEnvOverrideUint( + "SUPABASE_AUTH_RATE_LIMIT_TOKEN_REFRESH", + "auth.rate_limit.token_refresh", + rateLimit.token_refresh, + projectEnvValues, + ), + sign_in_sign_ups: legacyEnvOverrideUint( + "SUPABASE_AUTH_RATE_LIMIT_SIGN_IN_SIGN_UPS", + "auth.rate_limit.sign_in_sign_ups", + rateLimit.sign_in_sign_ups, + projectEnvValues, + ), + token_verifications: legacyEnvOverrideUint( + "SUPABASE_AUTH_RATE_LIMIT_TOKEN_VERIFICATIONS", + "auth.rate_limit.token_verifications", + rateLimit.token_verifications, + projectEnvValues, + ), + email_sent: legacyEnvOverrideUint( + "SUPABASE_AUTH_RATE_LIMIT_EMAIL_SENT", + "auth.rate_limit.email_sent", + rateLimit.email_sent, + projectEnvValues, + ), + sms_sent: legacyEnvOverrideUint( + "SUPABASE_AUTH_RATE_LIMIT_SMS_SENT", + "auth.rate_limit.sms_sent", + rateLimit.sms_sent, + projectEnvValues, + ), + web3: legacyEnvOverrideUint( + "SUPABASE_AUTH_RATE_LIMIT_WEB3", + "auth.rate_limit.web3", + rateLimit.web3, + projectEnvValues, + ), + }; +} + /** * Go's `Auth.Sessions` (`pkg/config/auth.go:330-333`) is a value-typed struct, * always merged with a Viper default (empty durations) regardless of From bae95bf5896ebee03c61f88709646e3417d85d5a Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 05:26:24 +0100 Subject: [PATCH 23/48] fix(cli): validate remaining typed config overrides in db start before Docker (review: PRRT_kwDOErm0O86VlOHQ) Go's Config.Load decodes the ENTIRE config struct in one unconditional v.UnmarshalExact pass, for every command that loads config (including db start), regardless of whether that command's own downstream logic ever reads the field. db start's eager-validation battery previously stopped after auth.rate_limit; it now also validates auth.web3, auth.oauth_server, auth.passkey, auth.external, api.enabled, api.tls.enabled, api.max_rows, storage.vector/s3_protocol/analytics fields, local_smtp ports, analytics ports, db.pooler fields, and edge_runtime.policy/inspector_port (the field Codex's review flagged), mirroring commands/start/start.handler.ts's own identical battery. Hoisted the three GoTrue resolvers (legacyResolveGotrueWeb3, legacyResolveGotrueOAuthServer, legacyResolveGotruePasskeyWebauthn) out of start.handler.ts (where they were module-private) into the shared legacy-local-config-values.ts, since db start is now a second caller. --- .../legacy/commands/db/start/start.handler.ts | 232 ++++++++++++++++++ .../db/start/start.integration.test.ts | 85 +++++++ .../src/legacy/commands/start/start.gates.ts | 2 +- .../legacy/commands/start/start.handler.ts | 171 ++----------- .../commands/start/start.integration.test.ts | 6 +- .../shared/legacy-db-config.toml-read.ts | 4 +- .../legacy-db-config.toml-read.unit.test.ts | 4 +- .../shared/legacy-local-config-values.ts | 167 ++++++++++++- 8 files changed, 503 insertions(+), 168 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 d080766b9d..74bc48a9a0 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -19,14 +19,25 @@ import { localNetworkId, } from "../../../shared/legacy-docker-ids.ts"; import { + legacyEnvOverrideApiMaxRows, legacyEnvOverrideBool, + legacyEnvOverrideDefaultPoolSize, + legacyEnvOverrideEdgeRuntimePolicy, + legacyEnvOverrideMaxClientConn, + legacyEnvOverridePoolMode, + legacyEnvOverridePort, + legacyEnvOverrideUint, legacyResolveAuthEmail, + legacyResolveAuthExternalProviders, legacyResolveAuthExternalUrl, legacyResolveAuthMfa, legacyResolveAuthSms, legacyResolveDbSettingsEnvOverrides, + legacyResolveGotrueOAuthServer, + legacyResolveGotruePasskeyWebauthn, legacyResolveGotrueRateLimit, legacyResolveGotrueSessions, + legacyResolveGotrueWeb3, legacyResolveLocalConfigValues, legacyResolveLocalJwks, } from "../../../shared/legacy-local-config-values.ts"; @@ -208,6 +219,227 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega legacyResolveGotrueRateLimit(config.auth.rate_limit, projectEnvValues), ); + // The rest of the eager-validation battery: Go's `Config.Load` decodes the ENTIRE config + // struct in one `v.UnmarshalExact` pass (`pkg/config/config.go`'s `(c *config) load`), + // regardless of which command invoked it or whether that command's own downstream logic ever + // reads the field — `db start` and `supabase start` both funnel through this same + // `flags.LoadConfig` entrypoint. `commands/start/start.handler.ts` already validates every + // field below (its own `wrapConfigOverride` battery); this mirrors those exact calls here so + // `db start` fails just as fast on a malformed non-auth field, regardless of whether `db + // start` itself ever reads it (review: PRRT_kwDOErm0O86VlOHQ). + + // Same gap for the remaining GoTrue overrides: `auth.web3.*.enabled`/`auth.oauth_server. + // {enabled,allow_dynamic_registration}` (plain `bool`s, `pkg/config/auth.go:371-382,394-398`) + // and `auth.passkey.enabled`/`auth.webauthn.*`/per-provider `auth.external.. + // {enabled,skip_nonce_check,email_optional}` (unmodeled raw booleans, `auth.go:166-176, + // 190,361-391`) are all decoded in the same unconditional `Config.Load` pass as `auth. + // rate_limit` above, regardless of whether `db start` itself ever reads them — `db start` + // never builds a GoTrue container at all (this module's own header), so nothing else in this + // handler ever calls any of these four resolvers. Each already throws internally on a bad + // override, so calling each here, once, eagerly, and discarding the result closes the gap. + // `auth.oauth_server.authorization_url_path` is a plain string and can't throw, so it needs no + // eager check. + yield* wrapDbConfigOverride("auth.web3", () => + legacyResolveGotrueWeb3(config.auth.web3, projectEnvValues), + ); + yield* wrapDbConfigOverride("auth.oauth_server", () => + legacyResolveGotrueOAuthServer(config.auth.oauth_server, projectEnvValues), + ); + yield* wrapDbConfigOverride("auth.passkey", () => + legacyResolveGotruePasskeyWebauthn(loaded?.document, projectEnvValues), + ); + yield* wrapDbConfigOverride("auth.external", () => + legacyResolveAuthExternalProviders( + authDocForValidation, + config.auth.external, + projectEnvValues, + ), + ); + + // Same gap for `api.enabled`/`api.tls.enabled` — plain bools decoded in the same + // unconditional `Config.Load` pass (`pkg/config/config.go:1006-1027`), regardless of whether + // `db start` itself ever reads them: it never builds Kong or any other HTTP-facing container + // (this module's own header), so neither value is consumed here. Discarded. + yield* wrapDbConfigOverride("api.enabled", () => + legacyEnvOverrideBool( + "SUPABASE_API_ENABLED", + config.api.enabled, + "api.enabled", + projectEnvValues, + ), + ); + yield* wrapDbConfigOverride("api.tls.enabled", () => + legacyEnvOverrideBool( + "SUPABASE_API_TLS_ENABLED", + config.api.tls.enabled, + "api.tls.enabled", + projectEnvValues, + ), + ); + // Same gap for `api.max_rows` — a plain uint only PostgREST/Studio ever read; `db start` + // builds neither container, so the resolved value is discarded here too. + yield* wrapDbConfigOverride("api.max_rows", () => + legacyEnvOverrideApiMaxRows(config.api.max_rows, projectEnvValues), + ); + + // Same gap for `storage.vector.enabled`/`storage.s3_protocol.enabled`/`storage.analytics. + // enabled` and their five plain-uint siblings (`storage.analytics.{max_namespaces,max_tables, + // max_catalogs}`/`storage.vector.{max_buckets,max_indexes}`) — all decoded unconditionally in + // the same `Config.Load` pass (`pkg/config/storage.go:16-45`), regardless of whether `db + // start` itself ever reads them: it never builds the Storage container. Discarded. + yield* wrapDbConfigOverride("storage.vector.enabled", () => + legacyEnvOverrideBool( + "SUPABASE_STORAGE_VECTOR_ENABLED", + config.storage.vector.enabled, + "storage.vector.enabled", + projectEnvValues, + ), + ); + yield* wrapDbConfigOverride("storage.s3_protocol.enabled", () => + legacyEnvOverrideBool( + "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", + config.storage.s3_protocol.enabled, + "storage.s3_protocol.enabled", + projectEnvValues, + ), + ); + yield* wrapDbConfigOverride("storage.analytics.enabled", () => + legacyEnvOverrideBool( + "SUPABASE_STORAGE_ANALYTICS_ENABLED", + config.storage.analytics.enabled, + "storage.analytics.enabled", + projectEnvValues, + ), + ); + yield* wrapDbConfigOverride("storage.analytics.max_namespaces", () => + legacyEnvOverrideUint( + "SUPABASE_STORAGE_ANALYTICS_MAX_NAMESPACES", + "storage.analytics.max_namespaces", + config.storage.analytics.max_namespaces, + projectEnvValues, + ), + ); + yield* wrapDbConfigOverride("storage.analytics.max_tables", () => + legacyEnvOverrideUint( + "SUPABASE_STORAGE_ANALYTICS_MAX_TABLES", + "storage.analytics.max_tables", + config.storage.analytics.max_tables, + projectEnvValues, + ), + ); + yield* wrapDbConfigOverride("storage.analytics.max_catalogs", () => + legacyEnvOverrideUint( + "SUPABASE_STORAGE_ANALYTICS_MAX_CATALOGS", + "storage.analytics.max_catalogs", + config.storage.analytics.max_catalogs, + projectEnvValues, + ), + ); + yield* wrapDbConfigOverride("storage.vector.max_buckets", () => + legacyEnvOverrideUint( + "SUPABASE_STORAGE_VECTOR_MAX_BUCKETS", + "storage.vector.max_buckets", + config.storage.vector.max_buckets, + projectEnvValues, + ), + ); + yield* wrapDbConfigOverride("storage.vector.max_indexes", () => + legacyEnvOverrideUint( + "SUPABASE_STORAGE_VECTOR_MAX_INDEXES", + "storage.vector.max_indexes", + config.storage.vector.max_indexes, + projectEnvValues, + ), + ); + + // Same gap for Mailpit's three ports and Logflare's two — Go's `Config.Load` applies + // `SUPABASE_LOCAL_SMTP_{PORT,SMTP_PORT,POP3_PORT}`/`SUPABASE_ANALYTICS_{PORT,VECTOR_PORT}` + // generically (`pkg/config/config.go:580-586`), regardless of whether `db start` itself ever + // reads them: it never builds Mailpit or Logflare. `smtp_port`/`pop3_port`/`vector_port` have + // no TOML default (Go's zero-value `uint16`), matching `commands/start/start.handler.ts`'s own + // `?? 0` fallback. Discarded. + yield* wrapDbConfigOverride("local_smtp.port", () => + legacyEnvOverridePort( + "SUPABASE_LOCAL_SMTP_PORT", + config.local_smtp.port, + "local_smtp.port", + projectEnvValues, + ), + ); + yield* wrapDbConfigOverride("local_smtp.smtp_port", () => + legacyEnvOverridePort( + "SUPABASE_LOCAL_SMTP_SMTP_PORT", + config.local_smtp.smtp_port ?? 0, + "local_smtp.smtp_port", + projectEnvValues, + ), + ); + yield* wrapDbConfigOverride("local_smtp.pop3_port", () => + legacyEnvOverridePort( + "SUPABASE_LOCAL_SMTP_POP3_PORT", + config.local_smtp.pop3_port ?? 0, + "local_smtp.pop3_port", + projectEnvValues, + ), + ); + yield* wrapDbConfigOverride("analytics.port", () => + legacyEnvOverridePort( + "SUPABASE_ANALYTICS_PORT", + config.analytics.port, + "analytics.port", + projectEnvValues, + ), + ); + yield* wrapDbConfigOverride("analytics.vector_port", () => + legacyEnvOverridePort( + "SUPABASE_ANALYTICS_VECTOR_PORT", + config.analytics.vector_port ?? 0, + "analytics.vector_port", + projectEnvValues, + ), + ); + + // Same gap for Supavisor's pooler fields — Go's `Config.Load` applies + // `SUPABASE_DB_POOLER_*` generically (`pkg/config/config.go:580-586`), regardless of whether + // `db start` itself ever reads them: it never builds the pooler container. All four throw + // synchronously on a malformed override — wrapped so a bad value fails as a typed + // `LegacyDbConfigLoadError` instead of an untyped Effect defect. Discarded. + yield* wrapDbConfigOverride("db.pooler.port", () => + legacyEnvOverridePort( + "SUPABASE_DB_POOLER_PORT", + config.db.pooler.port, + "db.pooler.port", + projectEnvValues, + ), + ); + yield* wrapDbConfigOverride("db.pooler.pool_mode", () => + legacyEnvOverridePoolMode(config.db.pooler.pool_mode, projectEnvValues), + ); + yield* wrapDbConfigOverride("db.pooler.default_pool_size", () => + legacyEnvOverrideDefaultPoolSize(config.db.pooler.default_pool_size, projectEnvValues), + ); + yield* wrapDbConfigOverride("db.pooler.max_client_conn", () => + legacyEnvOverrideMaxClientConn(config.db.pooler.max_client_conn, projectEnvValues), + ); + + // Same gap for `edge_runtime.policy` (an enum via `UnmarshalText`) and + // `edge_runtime.inspector_port` (a plain `uint`) — decoded in the same unconditional + // `Config.Load` pass (`pkg/config/config.go:749-756,777`), regardless of whether `db start` + // itself ever reads them: it never builds the Edge Runtime container. Discarded. + // `edge_runtime.inspector_port` is the exact field flagged by the review thread this battery + // closes (review: PRRT_kwDOErm0O86VlOHQ). + yield* wrapDbConfigOverride("edge_runtime.policy", () => + legacyEnvOverrideEdgeRuntimePolicy(config.edge_runtime.policy, projectEnvValues), + ); + yield* wrapDbConfigOverride("edge_runtime.inspector_port", () => + legacyEnvOverridePort( + "SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", + config.edge_runtime.inspector_port, + "edge_runtime.inspector_port", + projectEnvValues, + ), + ); + // Go's AssertSupabaseDbIsRunning: if the db container is already up, print to // stderr and return nil (exit 0). Already native — see this module's header. Runs AFTER // the config load/validation above, matching Go's `start.Run` (`flags.LoadConfig` before 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 da355bbcd2..941ceb2756 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 @@ -701,6 +701,91 @@ describe("legacy db start", () => { }, ); + // Closes the review-thread gap: Go's `Config.Load` decodes the ENTIRE config struct + // unconditionally in a single `v.UnmarshalExact` pass, including every field below, regardless + // of whether `db start` itself ever reads it — mirrors `commands/start/start.handler.ts`'s own + // identical eager-validation tests for these same fields (review: PRRT_kwDOErm0O86VlOHQ). + it.live.each([ + ["edge_runtime.inspector_port", "SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", "not-a-port"], + ["edge_runtime.policy", "SUPABASE_EDGE_RUNTIME_POLICY", "not-a-policy"], + ["api.max_rows", "SUPABASE_API_MAX_ROWS", "not-a-uint"], + ["storage.analytics.max_namespaces", "SUPABASE_STORAGE_ANALYTICS_MAX_NAMESPACES", "not-a-uint"], + ["local_smtp.port", "SUPABASE_LOCAL_SMTP_PORT", "not-a-port"], + ["analytics.port", "SUPABASE_ANALYTICS_PORT", "not-a-port"], + ["db.pooler.pool_mode", "SUPABASE_DB_POOLER_POOL_MODE", "not-a-mode"], + ["auth.web3", "SUPABASE_AUTH_WEB3_SOLANA_ENABLED", "not-a-bool"], + ["auth.oauth_server", "SUPABASE_AUTH_OAUTH_SERVER_ENABLED", "not-a-bool"], + ["api.enabled", "SUPABASE_API_ENABLED", "not-a-bool"], + ["storage.vector.enabled", "SUPABASE_STORAGE_VECTOR_ENABLED", "not-a-bool"], + ] as const)( + "fails with a typed config error on a malformed %s override, before any container is created", + ([dottedFieldPath, envVar, envValue]) => { + const { layer, child } = setup({}); + writeFileSync(join(tempRoot.current, "supabase", ".env"), `${envVar}=${envValue}\n`); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const message = JSON.stringify(exit.cause); + expect(message).toContain("LegacyDbConfigLoadError"); + expect(message).toContain(dottedFieldPath); + } + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + }); + }, + ); + + it.live( + "fails on an invalid auth.passkey.enabled even when auth is disabled, matching Go's Config.Load", + () => { + // `auth.passkey`/`auth.webauthn` have no `@supabase/config` schema at all — Go decodes + // `auth.passkey.enabled` unconditionally in Config.Load (pkg/config/auth.go:384-386) via + // `legacyResolveGotruePasskeyWebauthn`'s raw-document read, so the malformed value must live + // directly in config.toml here since `@supabase/config` never sees (or rejects) this + // unmodeled field — there's no schema-level bool coercion to catch it first + // (review: PRRT_kwDOErm0O86VlOHQ). + const { layer, child } = setup({ + configContents: + 'project_id = "test"\n[auth]\nenabled = false\n[auth.passkey]\nenabled = "bad"\n', + }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const message = JSON.stringify(exit.cause); + expect(message).toContain("LegacyDbConfigLoadError"); + expect(message).toContain("auth.passkey"); + } + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + }); + }, + ); + + it.live( + "fails on an invalid auth.external..enabled even when auth is disabled, matching Go's Config.Load", + () => { + // `auth.external` is a genuine Go `map[string]provider` (auth.go:190) — an unmodeled/ + // custom provider name like `custom` is a legitimate config shape `@supabase/config`'s + // schema silently drops at decode time, so `legacyResolveAuthExternalProviders`'s + // raw-document read is the only place this malformed value is ever seen — same + // override-only-throw reasoning as the passkey test above (review: PRRT_kwDOErm0O86VlOHQ). + const { layer, child } = setup({ + configContents: + 'project_id = "test"\n[auth]\nenabled = false\n[auth.external.custom]\nenabled = "bad"\n', + }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const message = JSON.stringify(exit.cause); + expect(message).toContain("LegacyDbConfigLoadError"); + expect(message).toContain("auth.external"); + } + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + }); + }, + ); + it.live("fails on a malformed auth duration field even when the db is already running", () => { // Go's `flags.LoadConfig` (and therefore this eager duration validation) runs before // `AssertSupabaseDbIsRunning` in `start.Run` (`internal/db/start/start.go:45-47`) — a diff --git a/apps/cli/src/legacy/commands/start/start.gates.ts b/apps/cli/src/legacy/commands/start/start.gates.ts index c12af51a9b..faf4600232 100644 --- a/apps/cli/src/legacy/commands/start/start.gates.ts +++ b/apps/cli/src/legacy/commands/start/start.gates.ts @@ -123,7 +123,7 @@ export function legacyResolveStartGates(inputs: LegacyStartGateInputs): LegacySt // either — it always decodes to a defaulted `{enabled: false}`, never // `undefined` — so presence must come from the raw document, same // `asRecord(document?.[...])` gate `legacyResolveAuthEmailSmtp`/ - // `resolveGotruePasskeyWebauthn`/`legacyResolveAuthSms` already use for the + // `legacyResolveGotruePasskeyWebauthn`/`legacyResolveAuthSms` already use for the // identical Go-pointer-section shape. const imageTransformationSectionPresent = asRecord(asRecord(document?.["storage"])?.["image_transformation"]) !== undefined; diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index 3f58a99c8c..0ea03f19cf 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -1,8 +1,4 @@ -import { - inferFunctionsManifest, - resolveProjectSubtree, - type ProjectConfig, -} from "@supabase/config"; +import { inferFunctionsManifest, resolveProjectSubtree } from "@supabase/config"; import { join } from "node:path"; import { Effect, FileSystem, Option, Path, Result } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; @@ -72,7 +68,6 @@ import { legacyEnvOverridePoolMode, legacyEnvOverridePort, legacyEnvOverrideUint, - legacyRawUnmodeledBool, legacyResolveAuthCaptcha, legacyResolveAuthEmail, legacyResolveAuthEmailSmtp, @@ -83,11 +78,13 @@ import { legacyResolveConfiguredSigningKeys, legacyResolveAuthExternalUrl, legacyResolveDbSettingsEnvOverrides, + legacyResolveGotrueOAuthServer, + legacyResolveGotruePasskeyWebauthn, legacyResolveGotrueRateLimit as resolveGotrueRateLimit, legacyResolveGotrueSessions as resolveGotrueSessions, + legacyResolveGotrueWeb3, legacyResolveLocalConfigValues, legacyResolveLocalJwks, - legacyStrToArr, type LegacyLocalConfigValues, type LegacyResolvedAuthEmail, } from "../../shared/legacy-local-config-values.ts"; @@ -232,149 +229,6 @@ function wrapConfigOverride( }); } -/** - * Go's `appendGotruePasskeyEnv`/`Auth.Passkey`/`Auth.Webauthn` presence gate - * (`start.go:1427-1440`, `pkg/config/config.go:1117-1134`): `@supabase/config` - * has no `auth.passkey`/`auth.webauthn` schema fields at all, so presence and - * every field must come from the raw, pre-schema TOML document instead — same - * document-based approach `legacy-local-config-values.ts` already uses for - * these two sections. - */ -/** - * `auth.passkey.enabled`/`auth.webauthn.*` are Viper-bound like every other - * nested field once `[auth.passkey]`/`[auth.webauthn]` are present in - * config.toml (`ExperimentalBindStruct`/`AutomaticEnv`, `config.go:581-586`), - * so `SUPABASE_AUTH_PASSKEY_ENABLED`/`SUPABASE_AUTH_WEBAUTHN_{RP_ID, - * RP_DISPLAY_NAME,RP_ORIGINS}` overrides apply before `appendGotruePasskeyEnv` - * (`start.go:1427-1436`) builds GoTrue's env — same reasoning, and same - * presence-gating (an absent section is never synthesized from an env - * override alone), as `legacy-local-config-values.ts`'s identical - * `Config.Validate`-parity resolution for this raw-document pair. - * `rp_display_name` has no validation-path precedent (Go's `Config.Validate` - * never checks it), but GoTrue's env does consume it, so it gets the same - * treatment here. - */ -function resolveGotruePasskeyWebauthn( - document: Readonly> | undefined, - projectEnvValues: Readonly> | undefined, -): { - readonly passkeyEnabled: boolean | undefined; - readonly webauthn: - | { - readonly rpId: string; - readonly rpDisplayName: string; - readonly rpOrigins: ReadonlyArray; - } - | undefined; -} { - const authDoc = asRecord(document?.["auth"]); - const passkeyDoc = asRecord(authDoc?.["passkey"]); - const webauthnDoc = asRecord(authDoc?.["webauthn"]); - const passkeyEnabled = - passkeyDoc !== undefined - ? legacyEnvOverrideBool( - "SUPABASE_AUTH_PASSKEY_ENABLED", - legacyRawUnmodeledBool(passkeyDoc["enabled"], "auth.passkey.enabled"), - "auth.passkey.enabled", - projectEnvValues, - ) - : undefined; - const rpOriginsOverride = - webauthnDoc !== undefined - ? legacyEnvOverride("SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS", undefined, projectEnvValues) - : undefined; - const webauthn = - webauthnDoc !== undefined - ? { - rpId: - legacyEnvOverride( - "SUPABASE_AUTH_WEBAUTHN_RP_ID", - typeof webauthnDoc["rp_id"] === "string" ? webauthnDoc["rp_id"] : "", - projectEnvValues, - ) ?? "", - rpDisplayName: - legacyEnvOverride( - "SUPABASE_AUTH_WEBAUTHN_RP_DISPLAY_NAME", - typeof webauthnDoc["rp_display_name"] === "string" - ? webauthnDoc["rp_display_name"] - : "", - projectEnvValues, - ) ?? "", - // Go's mapstructure decode chain applies `StringToSliceHookFunc(",")` - // unconditionally to every `[]string`-typed field (`config.go:775-784`) — a raw or - // `env(...)`-resolved `rp_origins` string (this section has no `@supabase/config` - // schema at all) is comma-split, not silently dropped to `[]`. - rpOrigins: (() => { - if (rpOriginsOverride !== undefined) return legacyStrToArr(rpOriginsOverride); - const raw = webauthnDoc["rp_origins"]; - if (Array.isArray(raw)) { - return raw.filter((item): item is string => typeof item === "string"); - } - return typeof raw === "string" ? legacyStrToArr(raw) : []; - })(), - } - : undefined; - return { passkeyEnabled, webauthn }; -} - -/** - * Go's `Auth.Web3` (`pkg/config/auth.go:379-382`) is a value-typed struct — - * same no-presence-gate reasoning as {@link resolveGotrueRateLimit}. - */ -function resolveGotrueWeb3( - web3: ProjectConfig["auth"]["web3"], - projectEnvValues: Readonly> | undefined, -): ProjectConfig["auth"]["web3"] { - return { - solana: { - enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_WEB3_SOLANA_ENABLED", - web3.solana.enabled, - "auth.web3.solana.enabled", - projectEnvValues, - ), - }, - ethereum: { - enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_WEB3_ETHEREUM_ENABLED", - web3.ethereum.enabled, - "auth.web3.ethereum.enabled", - projectEnvValues, - ), - }, - }; -} - -/** - * Go's `Auth.OAuthServer` (`pkg/config/auth.go:394-398`) is a value-typed - * struct — same no-presence-gate reasoning as {@link resolveGotrueRateLimit}. - */ -function resolveGotrueOAuthServer( - oauthServer: ProjectConfig["auth"]["oauth_server"], - projectEnvValues: Readonly> | undefined, -): ProjectConfig["auth"]["oauth_server"] { - return { - enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_OAUTH_SERVER_ENABLED", - oauthServer.enabled, - "auth.oauth_server.enabled", - projectEnvValues, - ), - authorization_url_path: - legacyEnvOverride( - "SUPABASE_AUTH_OAUTH_SERVER_AUTHORIZATION_URL_PATH", - oauthServer.authorization_url_path, - projectEnvValues, - ) ?? oauthServer.authorization_url_path, - allow_dynamic_registration: legacyEnvOverrideBool( - "SUPABASE_AUTH_OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION", - oauthServer.allow_dynamic_registration, - "auth.oauth_server.allow_dynamic_registration", - projectEnvValues, - ), - }; -} - /** * Every value {@link legacyBuildGotrueContainerSpec} needs from `config`/ * `values`, minus `dbHost`/`dbPassword` (which that builder derives itself @@ -456,7 +310,10 @@ function resolveGotrueEnvInput(params: { } : undefined; - const { passkeyEnabled, webauthn } = resolveGotruePasskeyWebauthn(document, projectEnvValues); + const { passkeyEnabled, webauthn } = legacyResolveGotruePasskeyWebauthn( + document, + projectEnvValues, + ); const externalProviders = legacyResolveAuthExternalProviders( asRecord(document?.["auth"]), config.auth.external, @@ -487,8 +344,8 @@ function resolveGotrueEnvInput(params: { sessions: resolveGotrueSessions(config.auth.sessions, projectEnvValues), mfa: legacyResolveAuthMfa(config.auth.mfa, projectEnvValues), rateLimit: resolveGotrueRateLimit(config.auth.rate_limit, projectEnvValues), - web3: resolveGotrueWeb3(config.auth.web3, projectEnvValues), - oauthServer: resolveGotrueOAuthServer(config.auth.oauth_server, projectEnvValues), + web3: legacyResolveGotrueWeb3(config.auth.web3, projectEnvValues), + oauthServer: legacyResolveGotrueOAuthServer(config.auth.oauth_server, projectEnvValues), hooks: legacyResolveAuthHooks(asRecord(document?.["auth"]), config.auth.hook, projectEnvValues), captcha: legacyResolveAuthCaptcha( asRecord(document?.["auth"]), @@ -695,7 +552,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // `auth.web3.*.enabled`/`auth.oauth_server.{enabled,allow_dynamic_registration}` (plain // `bool`s) are all decoded unconditionally in Go's single `Config.Load` pass // (`pkg/config/auth.go:200-208,371-382,394-398`), regardless of `auth.enabled`/`--exclude - // gotrue`. `resolveGotrueRateLimit`/`resolveGotrueWeb3`/`resolveGotrueOAuthServer` already + // gotrue`. `resolveGotrueRateLimit`/`legacyResolveGotrueWeb3`/`legacyResolveGotrueOAuthServer` already // throw internally on a bad override, so — unlike the duration fields above — calling each // whole (pure) function once here is simpler than re-deriving every field individually; // `resolveGotrueEnvInput` below re-resolves them a second time for the real container build, @@ -705,10 +562,10 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta resolveGotrueRateLimit(config.auth.rate_limit, projectEnvValues), ); yield* wrapConfigOverride("auth.web3", () => - resolveGotrueWeb3(config.auth.web3, projectEnvValues), + legacyResolveGotrueWeb3(config.auth.web3, projectEnvValues), ); yield* wrapConfigOverride("auth.oauth_server", () => - resolveGotrueOAuthServer(config.auth.oauth_server, projectEnvValues), + legacyResolveGotrueOAuthServer(config.auth.oauth_server, projectEnvValues), ); // Same gap for `auth.passkey.enabled`/`auth.webauthn.*` and per-provider `auth.external. // .{enabled,skip_nonce_check,email_optional}` — Go decodes these raw (unmodeled by @@ -719,7 +576,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // itself gated on auth being enabled and gotrue not excluded — so calling each here, once, // eagerly and discarding the result, closes the same "validates but doesn't reach it" gap. yield* wrapConfigOverride("auth.passkey", () => - resolveGotruePasskeyWebauthn(context.loaded?.document, projectEnvValues), + legacyResolveGotruePasskeyWebauthn(context.loaded?.document, projectEnvValues), ); yield* wrapConfigOverride("auth.external", () => legacyResolveAuthExternalProviders( 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..641bc7d7b7 100644 --- a/apps/cli/src/legacy/commands/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts @@ -1486,7 +1486,7 @@ describe("legacy start integration", () => { "brings up the stack with every optional config.toml section populated (bigquery analytics, session pool mode, passkey/webauthn, external provider, SMTP, email templates)", () => { // Exercises the config-document-shape branches `start.handler.ts` itself owns - // (`resolveGotruePasskeyWebauthn`, `resolveGotrueExternalProviders`, + // (`legacyResolveGotruePasskeyWebauthn`, `resolveGotrueExternalProviders`, // `buildKongEmailTemplateMounts`, `values.analyticsBackend`) in one pass, none of // which interact with each other. A malformed `db.health_timeout` is exercised // separately below (it now hard-fails the whole command, matching Go, so it can't @@ -1969,8 +1969,8 @@ content_path = "./templates/custom_notice.html" () => { // `auth.passkey`/`auth.webauthn` have no `@supabase/config` schema at all — Go decodes // `auth.passkey.enabled` unconditionally in Config.Load (pkg/config/auth.go:384-386) via - // `resolveGotruePasskeyWebauthn`'s raw-document read, same override-only-throw reasoning as - // the web3/oauth_server tests above, except the malformed value lives directly in + // `legacyResolveGotruePasskeyWebauthn`'s raw-document read, same override-only-throw + // reasoning as the web3/oauth_server tests above, except the malformed value lives directly in // config.toml here since `@supabase/config` never sees (or rejects) this unmodeled field — // there's no schema-level bool coercion to catch it first. const { layer, child } = setup({ diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index 2ee909ae3f..919e8b005c 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -1450,8 +1450,8 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // `StringToSliceHookFunc(",")` mapstructure hook as every other `[]string` field // (`config.go:775-784`) — a raw or `env(...)`-resolved comma-separated string must be // split, not treated as "missing" just because it isn't already a literal TOML array. - // Matches `start.handler.ts`'s own `resolveGotruePasskeyWebauthn`/`legacyStrToArr` handling - // of this identical field. + // Matches `legacy-local-config-values.ts`'s own `legacyResolveGotruePasskeyWebauthn`/ + // `legacyStrToArr` handling of this identical field. const rpOrigins = Array.isArray(rpOriginsRaw) ? rpOriginsRaw : legacyStrToArr(str(webauthnRaw, "rp_origins")); diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts index ca170d27d8..a8694a324a 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts @@ -2247,8 +2247,8 @@ describe("legacyReadDbToml auth.Enabled validation (Go config.Validate parity)", it.effect("accepts a comma-separated rp_origins string instead of rejecting it as missing", () => // Go decodes `rp_origins` (a `[]string`) through the same `StringToSliceHookFunc(",")` // mapstructure hook as every other `[]string` field, so a raw string (not just a literal - // TOML array) must split, not read as absent — matches start.handler.ts's own - // resolveGotruePasskeyWebauthn/legacyStrToArr handling of this identical field. + // TOML array) must split, not read as absent — matches legacy-local-config-values.ts's own + // legacyResolveGotruePasskeyWebauthn/legacyStrToArr handling of this identical field. succeeds([ "[auth.passkey]", "enabled = true", diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 2cd8f640a8..42b9b17bd5 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -1759,6 +1759,167 @@ export function legacyResolveGotrueSessions( return { timebox, inactivity_timeout: inactivityTimeout }; } +/** + * Go's `appendGotruePasskeyEnv`/`Auth.Passkey`/`Auth.Webauthn` presence gate + * (`start.go:1427-1440`, `pkg/config/config.go:1117-1134`): `@supabase/config` + * has no `auth.passkey`/`auth.webauthn` schema fields at all, so presence and + * every field must come from the raw, pre-schema TOML document instead — same + * document-based approach this file's own `Config.Validate`-parity resolution + * (inside {@link legacyResolveLocalConfigValues}) already uses for these two + * sections. + */ +/** + * `auth.passkey.enabled`/`auth.webauthn.*` are Viper-bound like every other + * nested field once `[auth.passkey]`/`[auth.webauthn]` are present in + * config.toml (`ExperimentalBindStruct`/`AutomaticEnv`, `config.go:581-586`), + * so `SUPABASE_AUTH_PASSKEY_ENABLED`/`SUPABASE_AUTH_WEBAUTHN_{RP_ID, + * RP_DISPLAY_NAME,RP_ORIGINS}` overrides apply before `appendGotruePasskeyEnv` + * (`start.go:1427-1436`) builds GoTrue's env — same reasoning, and same + * presence-gating (an absent section is never synthesized from an env + * override alone), as this file's identical `Config.Validate`-parity + * resolution for this raw-document pair. `rp_display_name` has no + * validation-path precedent (Go's `Config.Validate` never checks it), but + * GoTrue's env does consume it, so it gets the same treatment here. + * + * Hoisted here (originally private to `commands/start/start.handler.ts`) once + * `commands/db/start/start.handler.ts` became a second caller — both need the + * same eager `auth.passkey`/`auth.webauthn` resolution to reproduce Go's + * unconditional `Config.Load` decode, per `apps/cli/CLAUDE.md`'s "Hoist Before + * You Duplicate". + */ +export function legacyResolveGotruePasskeyWebauthn( + document: Readonly> | undefined, + projectEnvValues: Readonly> | undefined, +): { + readonly passkeyEnabled: boolean | undefined; + readonly webauthn: + | { + readonly rpId: string; + readonly rpDisplayName: string; + readonly rpOrigins: ReadonlyArray; + } + | undefined; +} { + const authDoc = asRecord(document?.["auth"]); + const passkeyDoc = asRecord(authDoc?.["passkey"]); + const webauthnDoc = asRecord(authDoc?.["webauthn"]); + const passkeyEnabled = + passkeyDoc !== undefined + ? legacyEnvOverrideBool( + "SUPABASE_AUTH_PASSKEY_ENABLED", + legacyRawUnmodeledBool(passkeyDoc["enabled"], "auth.passkey.enabled"), + "auth.passkey.enabled", + projectEnvValues, + ) + : undefined; + const rpOriginsOverride = + webauthnDoc !== undefined + ? legacyEnvOverride("SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS", undefined, projectEnvValues) + : undefined; + const webauthn = + webauthnDoc !== undefined + ? { + rpId: + legacyEnvOverride( + "SUPABASE_AUTH_WEBAUTHN_RP_ID", + typeof webauthnDoc["rp_id"] === "string" ? webauthnDoc["rp_id"] : "", + projectEnvValues, + ) ?? "", + rpDisplayName: + legacyEnvOverride( + "SUPABASE_AUTH_WEBAUTHN_RP_DISPLAY_NAME", + typeof webauthnDoc["rp_display_name"] === "string" + ? webauthnDoc["rp_display_name"] + : "", + projectEnvValues, + ) ?? "", + // Go's mapstructure decode chain applies `StringToSliceHookFunc(",")` + // unconditionally to every `[]string`-typed field (`config.go:775-784`) — a raw or + // `env(...)`-resolved `rp_origins` string (this section has no `@supabase/config` + // schema at all) is comma-split, not silently dropped to `[]`. + rpOrigins: (() => { + if (rpOriginsOverride !== undefined) return legacyStrToArr(rpOriginsOverride); + const raw = webauthnDoc["rp_origins"]; + if (Array.isArray(raw)) { + return raw.filter((item): item is string => typeof item === "string"); + } + return typeof raw === "string" ? legacyStrToArr(raw) : []; + })(), + } + : undefined; + return { passkeyEnabled, webauthn }; +} + +/** + * Go's `Auth.Web3` (`pkg/config/auth.go:379-382`) is a value-typed struct — + * same no-presence-gate reasoning as {@link legacyResolveGotrueRateLimit}. + * + * Hoisted here (originally private to `commands/start/start.handler.ts`) once + * `commands/db/start/start.handler.ts` became a second caller — both need the + * same eager `auth.web3.*.enabled` resolution to reproduce Go's unconditional + * `Config.Load` decode, per `apps/cli/CLAUDE.md`'s "Hoist Before You + * Duplicate". + */ +export function legacyResolveGotrueWeb3( + web3: ProjectConfig["auth"]["web3"], + projectEnvValues: Readonly> | undefined, +): ProjectConfig["auth"]["web3"] { + return { + solana: { + enabled: legacyEnvOverrideBool( + "SUPABASE_AUTH_WEB3_SOLANA_ENABLED", + web3.solana.enabled, + "auth.web3.solana.enabled", + projectEnvValues, + ), + }, + ethereum: { + enabled: legacyEnvOverrideBool( + "SUPABASE_AUTH_WEB3_ETHEREUM_ENABLED", + web3.ethereum.enabled, + "auth.web3.ethereum.enabled", + projectEnvValues, + ), + }, + }; +} + +/** + * Go's `Auth.OAuthServer` (`pkg/config/auth.go:394-398`) is a value-typed + * struct — same no-presence-gate reasoning as {@link legacyResolveGotrueRateLimit}. + * + * Hoisted here (originally private to `commands/start/start.handler.ts`) once + * `commands/db/start/start.handler.ts` became a second caller — both need the + * same eager `auth.oauth_server.*` resolution to reproduce Go's unconditional + * `Config.Load` decode, per `apps/cli/CLAUDE.md`'s "Hoist Before You + * Duplicate". + */ +export function legacyResolveGotrueOAuthServer( + oauthServer: ProjectConfig["auth"]["oauth_server"], + projectEnvValues: Readonly> | undefined, +): ProjectConfig["auth"]["oauth_server"] { + return { + enabled: legacyEnvOverrideBool( + "SUPABASE_AUTH_OAUTH_SERVER_ENABLED", + oauthServer.enabled, + "auth.oauth_server.enabled", + projectEnvValues, + ), + authorization_url_path: + legacyEnvOverride( + "SUPABASE_AUTH_OAUTH_SERVER_AUTHORIZATION_URL_PATH", + oauthServer.authorization_url_path, + projectEnvValues, + ) ?? oauthServer.authorization_url_path, + allow_dynamic_registration: legacyEnvOverrideBool( + "SUPABASE_AUTH_OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION", + oauthServer.allow_dynamic_registration, + "auth.oauth_server.allow_dynamic_registration", + projectEnvValues, + ), + }; +} + /** Go's `(s *sms) validate()` fixed provider priority (`pkg/config/config.go:1348-1410`) — a * `switch` that validates ONLY the first enabled provider in this order. */ const LEGACY_SMS_PROVIDER_ORDER = [ @@ -2038,9 +2199,9 @@ export interface LegacyResolvedAuthExternalProvider { * as the literal string `"true"`/`"false"` instead of a real boolean. A native TOML `true`/`false` * literal still decodes to an actual `boolean` even for an unmodeled key (only `env(...)` * substitution is schema-blind), so this must accept both. Used by - * {@link legacyResolveAuthExternalProviders} below AND by `start.handler.ts`'s - * `resolveGotruePasskeyWebauthn`/this file's own passkey-validation read, since both are - * unmodeled-document reads of the identical shape. + * {@link legacyResolveAuthExternalProviders} below AND by + * {@link legacyResolveGotruePasskeyWebauthn}/this file's own passkey-validation read, since both + * are unmodeled-document reads of the identical shape. * * An unparsable STRING (e.g. a typo, or a still-literal `"env(VAR)"` when the referenced var was * never set) is a hard `Config.Load` failure in Go, not a silent `false` — `v.UnmarshalExact`'s From cc93e737b44b9198e2f2ec215e2cef9012500a6e Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 06:49:43 +0100 Subject: [PATCH 24/48] docs(cli): document why db start's hostname/passkey checks already match Go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new Codex threads on this PR both argued db start diverges from Go, but neither survives a close read against apps/cli-go/: - PRRT_kwDOErm0O86VlqIJ: claimed excluding SUPABASE_SERVICES_HOSTNAME from the project-dotenv-to-process.env install loop drops a dotenv-only override. Go's GetHostname() has exactly one call site — the utils.Config package-level var initializer — which runs before main(), before cobra parses argv, before any command's Config.Load (and its dotenv pass) ever executes. A project-dotenv-only value can never reach it; only a shell-exported one can. Verified with a scratch Go probe reproducing the exact ordering. Extending the loop to this key would be a new divergence, not a fix. - PRRT_kwDOErm0O86VlqIK: claimed the eager legacyResolveGotruePasskeyWebauthn call reimplements Config.Validate's passkey/webauthn rule. The actual "Missing required config section" rule already lives exclusively in legacyValidateResolvedConfig, invoked via legacyCheckDbToml as the very first line of this handler — before the eager-decode battery runs. The later call reuses the same shared resolver purely to surface its internal decode-hook errors eagerly (Go's unconditional Config.Load field decode), discarding the result, identical to the auth.web3/auth.oauth_server calls beside it. Both threads get a reply on the PR with this reasoning; these are doc-only comments recording it at the flagged call sites so a future reviewer doesn't re-raise the same false positive. --- .../legacy/commands/db/start/start.handler.ts | 15 +++++++++++++++ .../shared/legacy-local-project-context.ts | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+) 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 74bc48a9a0..d7580943c3 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -239,6 +239,21 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega // override, so calling each here, once, eagerly, and discarding the result closes the gap. // `auth.oauth_server.authorization_url_path` is a plain string and can't throw, so it needs no // eager check. + // + // NOT a reimplementation of `Config.Validate`'s passkey/webauthn RULE (review: + // PRRT_kwDOErm0O86VlqIK): the "Missing required config section: auth.webauthn.../rp_id/ + // rp_origins" checks (`config.go:1117-1134`) are decode-INDEPENDENT semantic validation that + // already, exclusively lives in `legacyValidateResolvedConfig` — this handler's very first + // line runs `legacyCheckDbToml`, which builds `LegacyPasskeyInput` and calls that single + // shared validator before ANY of this eager-decode battery executes, so a malformed/ + // incomplete `[auth.passkey]`/`[auth.webauthn]` section already fails fast there. The call + // below is the SAME `legacy-local-config-values.ts` resolver `start`'s own identical battery + // (and `db start`'s later GoTrue-container-building path, were it to build one) already call — + // invoked here only to force its internal `legacyEnvOverrideBool`/`legacyRawUnmodeledBool` + // decode-hook errors (a malformed `SUPABASE_AUTH_PASSKEY_ENABLED`, etc.) to surface eagerly, + // matching Go's unconditional `Config.Load` field decode. No validation logic is duplicated + // here — only the pre-existing, already-shared resolver is called again, and its result is + // discarded. yield* wrapDbConfigOverride("auth.web3", () => legacyResolveGotrueWeb3(config.auth.web3, projectEnvValues), ); diff --git a/apps/cli/src/legacy/shared/legacy-local-project-context.ts b/apps/cli/src/legacy/shared/legacy-local-project-context.ts index 9958e9b6e7..7487077d4b 100644 --- a/apps/cli/src/legacy/shared/legacy-local-project-context.ts +++ b/apps/cli/src/legacy/shared/legacy-local-project-context.ts @@ -113,6 +113,24 @@ export const legacyLoadLocalProjectContext = ( } } + // Deliberately NOT extended to `SUPABASE_SERVICES_HOSTNAME` (review: PRRT_kwDOErm0O86VlqIJ): + // Go's `GetHostname()` (`apps/cli-go/internal/utils/misc.go:305-311`) has exactly one call + // site — `var Config = config.NewConfig(config.WithHostname(GetHostname()))` + // (`internal/utils/config.go:100`), a package-level `var` initializer. Go's runtime evaluates + // every package-level `var` before `main()` runs, which is before cobra parses argv, which is + // before ANY command's `RunE`/`PersistentPreRunE` calls `flags.LoadConfig` -> `Config.Load` -> + // `loadNestedEnv` -> `godotenv.Load`. So `utils.Config.Hostname` is permanently fixed to + // whatever `os.Getenv("SUPABASE_SERVICES_HOSTNAME")` returns at Go BINARY STARTUP — before a + // project dotenv file is ever parsed by that process — and nothing re-reads `GetHostname()` + // afterward to pick up a dotenv-installed value. Verified empirically (scratch probe + // reproducing the exact package-var-init-before-dotenv-load ordering): a project-dotenv-only + // `SUPABASE_SERVICES_HOSTNAME` never reaches Go's hostname resolution; only a value already + // present in the shell env before the binary starts does. `legacyGetHostname()` right below + // must therefore NOT see a project-dotenv-only override either — installing it into + // `process.env` here would make native `db start`/`start`/`stop`/`status` honor a case Go's + // own `utils.Config.Hostname` can never observe, which is a NEW divergence from Go, not a fix + // for one. + // An absent config.toml is not a failure — Go's `flags.LoadConfig` still resolves a project id // via the workdir basename default. Only a malformed file (`loadProjectConfig` failing rather // than returning `null`) is a hard error. From 26a602ff8a15808742cce0cc5343bbf30100f7fc Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 06:50:15 +0100 Subject: [PATCH 25/48] fix(cli): honor SUPABASE_NETWORK_ID env fallback in db start and start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go registers `network-id` as a persistent flag bound to viper under SetEnvPrefix("SUPABASE") + AutomaticEnv() (cmd/root.go:318-334) — the same mechanism already ported for SUPABASE_YES/SUPABASE_EXPERIMENTAL — and DockerStart reads viper.GetString("network-id") fresh at its own call site, well after Config.Load's dotenv pass (docker.go:379-383). Both db start and start computed only `--network-id` flag -> generated network name, silently dropping the shell/project-dotenv env fallback and attaching containers to the wrong network when only the env var was set. Adds legacyViperEnvStringWithProjectFallback (legacy-viper-env.ts) alongside the existing bool helper, and legacyResolveNetworkId (legacy-docker-ids.ts) composing flag -> env -> generated-name, matching Go's precedence. The exact same duplicated snippet existed in both db/start/start.handler.ts and start/start.handler.ts (both touched by this PR) — fixed via the one shared helper per the "hoist before you duplicate" rule instead of patching db start alone and leaving start with the same gap. review: PRRT_kwDOErm0O86VlqIL --- .../legacy/commands/db/start/start.handler.ts | 16 ++++-- .../db/start/start.integration.test.ts | 25 ++++++++- .../legacy/commands/start/start.handler.ts | 17 +++++-- .../commands/start/start.integration.test.ts | 26 ++++++++++ .../src/legacy/shared/legacy-docker-ids.ts | 32 ++++++++++++ .../shared/legacy-docker-ids.unit.test.ts | 51 ++++++++++++++++++- .../cli/src/shared/legacy/legacy-viper-env.ts | 16 ++++++ .../legacy/legacy-viper-env.unit.test.ts | 42 ++++++++++++++- 8 files changed, 212 insertions(+), 13 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 d7580943c3..691b86fd8d 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -15,8 +15,8 @@ import { legacyCheckDbToml } from "../../../shared/legacy-db-config.toml-read.ts import { LegacyDbConfigLoadError } from "../../../shared/legacy-db-config.errors.ts"; import { legacyCliProjectFilterValue, + legacyResolveNetworkId, localDbContainerId, - localNetworkId, } from "../../../shared/legacy-docker-ids.ts"; import { legacyEnvOverrideApiMaxRows, @@ -525,10 +525,16 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega // 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); + // (`docker.go:379-383`) — and `--network-id` falls back to the `SUPABASE_NETWORK_ID` + // shell/project-dotenv env var when the flag itself is omitted, via the same + // `viper`/`AutomaticEnv` mechanism as `SUPABASE_YES`/`SUPABASE_EXPERIMENTAL` (review: + // PRRT_kwDOErm0O86VlqIL; see {@link legacyResolveNetworkId}'s doc comment for why this is NOT + // the same freeze-at-package-init shape as `utils.Config.Hostname`). + const networkId = legacyResolveNetworkId( + Option.getOrUndefined(networkIdFlag), + projectId, + projectEnvValues, + ); // 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 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 941ceb2756..4e0f3a63c7 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 @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; +import { afterEach, describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option, PlatformError, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClient from "effect/unstable/http/HttpClient"; @@ -314,6 +314,10 @@ const currentBranchPath = (workdir: string) => join(workdir, "supabase", ".branches", "_current_branch"); describe("legacy db start", () => { + afterEach(() => { + delete process.env["SUPABASE_NETWORK_ID"]; + }); + it.live("reports an already-running database without starting a container", () => { const { layer, out, telemetry, child } = setup({ running: true }); return Effect.gen(function* () { @@ -628,6 +632,25 @@ describe("legacy db start", () => { }, ); + it.live("falls back to SUPABASE_NETWORK_ID when --network-id is omitted", () => { + // Go's `network-id` is a persistent flag bound to viper under `SetEnvPrefix("SUPABASE")` + + // `AutomaticEnv()` (`apps/cli-go/cmd/root.go:318-334`), and `DockerStart` reads + // `viper.GetString("network-id")` fresh at its own call site — well after `Config.Load`'s + // dotenv pass — so a shell/project-dotenv `SUPABASE_NETWORK_ID` is effective when the flag + // itself is omitted (review: PRRT_kwDOErm0O86VlqIL). + process.env["SUPABASE_NETWORK_ID"] = "env-network"; + const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect( + child.spawned.some((s) => s.args[0] === "network" && s.args.at(-1) === "env-network"), + ).toBe(true); + const args = createArgs(child.spawned); + const networkIndex = args?.indexOf("--network") ?? -1; + expect(args?.[networkIndex + 1]).toBe("env-network"); + }); + }); + it.live( "fails with a typed config error on a malformed SUPABASE_DB_HEALTH_TIMEOUT, before any container is created", () => { diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index 0ea03f19cf..8351d78ee6 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -46,10 +46,10 @@ import { import { legacyParseGoDuration } from "../../shared/legacy-go-duration.ts"; import { legacyCliProjectFilterValue, + legacyResolveNetworkId, legacyServiceContainerIds, legacyServiceContainerName, localDbContainerId, - localNetworkId, } from "../../shared/legacy-docker-ids.ts"; import { legacyInspectContainerState, @@ -904,11 +904,18 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // 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`). + // `supabase_network_` fallback (`docker.go:379-383`) — and `--network-id` falls + // back to the `SUPABASE_NETWORK_ID` shell/project-dotenv env var when the flag itself is + // omitted, via the same `viper`/`AutomaticEnv` mechanism as `SUPABASE_YES`/ + // `SUPABASE_EXPERIMENTAL` (review: PRRT_kwDOErm0O86VlqIL). See + // {@link legacyResolveNetworkId}'s doc comment (shared with `db start`, which computes this + // identically). const networkIdFlag = yield* LegacyNetworkIdFlag; - const networkId = Option.isSome(networkIdFlag) - ? networkIdFlag.value - : localNetworkId(projectId); + const networkId = legacyResolveNetworkId( + Option.getOrUndefined(networkIdFlag), + projectId, + projectEnvValues, + ); // 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 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 641bc7d7b7..a6240138e6 100644 --- a/apps/cli/src/legacy/commands/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts @@ -3407,6 +3407,32 @@ content_path = "./templates/custom_notice.html" expect(kongCreate?.args[networkFlagIndex + 1]).toBe("custom-net"); }).pipe(Effect.provide(layer)); }); + + it.live("falls back to SUPABASE_NETWORK_ID when the flag itself is omitted", () => { + // Go's `network-id` is a persistent flag bound to viper under `SetEnvPrefix("SUPABASE")` + // + `AutomaticEnv()` (`apps/cli-go/cmd/root.go:318-334`), and `DockerStart` reads + // `viper.GetString("network-id")` fresh at its own call site — well after `Config.Load`'s + // dotenv pass — so a shell/project-dotenv `SUPABASE_NETWORK_ID` is effective when the flag + // itself is omitted (review: PRRT_kwDOErm0O86VlqIL). + const previous = process.env["SUPABASE_NETWORK_ID"]; + process.env["SUPABASE_NETWORK_ID"] = "env-net"; + const { layer, child } = setup(); + return Effect.gen(function* () { + yield* legacyStart(flags()); + const networkCreate = child.spawned.find( + (s) => s.args[0] === "network" && s.args[1] === "create", + ); + expect(networkCreate?.args.at(-1)).toBe("env-net"); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_NETWORK_ID"]; + else process.env["SUPABASE_NETWORK_ID"] = previous; + }), + ), + ); + }); }); describe("SUPABASE_API_PORT override", () => { diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.ts index 5334a5ee30..83ddbf72fc 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.ts @@ -8,6 +8,8 @@ import { basename } from "node:path"; +import { legacyViperEnvStringWithProjectFallback } from "../../shared/legacy/legacy-viper-env.ts"; + /** * Resolve the project id Go feeds into `utils.DbId`/`utils.NetId`. viper sets * `Config.ProjectId` from config.toml's `project_id`, then `AutomaticEnv` overrides it @@ -75,6 +77,36 @@ export function localNetworkId(projectId: string) { return legacyServiceContainerName("network", projectId); } +/** + * `utils.NetId`/`DockerStart`'s network-mode resolution (`apps/cli-go/internal/utils/docker.go: + * 379-383`, `internal/utils/config.go:62`): an explicit `--network-id` flag wins, then + * `SUPABASE_NETWORK_ID` — `network-id` is one of the persistent flags Go binds to viper under + * `SetEnvPrefix("SUPABASE")` + `AutomaticEnv()` (`cmd/root.go:318-334`, same mechanism as + * `SUPABASE_YES`/`SUPABASE_EXPERIMENTAL`), and `viper.GetString("network-id")` reads the + * (dotenv-merged) process env fresh at `DockerStart`'s own call site — deep inside container + * bring-up, well after `Config.Load`'s dotenv pass already ran — unlike `utils.Config.Hostname`, + * which is fixed once via `GetHostname()` at the `utils` package's `var` init, before `main()` + * ever runs a command's `Config.Load` (see {@link legacyGetHostname}'s own doc comment for why a + * project-dotenv-only override does NOT reach that field). Only when both the flag and the env + * are absent does Go fall back to the generated `supabase_network_` name. + * + * `db start` and `start` both compute this identically — hoisted here (rather than duplicated in + * each handler) per the "hoist before you duplicate" rule (`apps/cli/CLAUDE.md`). + */ +export function legacyResolveNetworkId( + flagValue: string | undefined, + projectId: string, + projectEnvValues: Readonly>, +): string { + if (flagValue !== undefined && flagValue.length > 0) return flagValue; + const envNetworkId = legacyViperEnvStringWithProjectFallback( + "SUPABASE_NETWORK_ID", + projectEnvValues, + ); + if (envNetworkId.length > 0) return envNetworkId; + return localNetworkId(projectId); +} + /** Go's `utils.CliProjectLabel` (`apps/cli-go/internal/utils/docker.go:59`) — the * Docker label every container/volume/network created by `supabase start` carries. */ export const LEGACY_CLI_PROJECT_LABEL = "com.supabase.cli.project"; diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts index 9c07805652..adc825453f 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts @@ -1,12 +1,14 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { LEGACY_CLI_PROJECT_LABEL, legacyCliProjectFilterValue, legacyResolveLocalProjectId, + legacyResolveNetworkId, legacySanitizeProjectId, legacyServiceContainerIds, localDbContainerId, + localNetworkId, } from "./legacy-docker-ids.ts"; describe("legacyResolveLocalProjectId", () => { @@ -81,6 +83,53 @@ describe("legacyCliProjectFilterValue", () => { }); }); +describe("legacyResolveNetworkId", () => { + const KEY = "SUPABASE_NETWORK_ID"; + + afterEach(() => { + delete process.env[KEY]; + }); + + it("prefers an explicit --network-id flag over everything else", () => { + process.env[KEY] = "env-network"; + expect(legacyResolveNetworkId("flag-network", "my-app", { [KEY]: "toml-network" })).toBe( + "flag-network", + ); + }); + + it("falls back to SUPABASE_NETWORK_ID (shell) when the flag is absent", () => { + process.env[KEY] = "shell-network"; + expect(legacyResolveNetworkId(undefined, "my-app", {})).toBe("shell-network"); + }); + + it("falls back to SUPABASE_NETWORK_ID (project .env) when both the flag and shell are absent", () => { + delete process.env[KEY]; + expect(legacyResolveNetworkId(undefined, "my-app", { [KEY]: "project-network" })).toBe( + "project-network", + ); + }); + + it("prefers the shell value over the project .env value (presence wins, matching godotenv.Load)", () => { + process.env[KEY] = "shell-network"; + expect(legacyResolveNetworkId(undefined, "my-app", { [KEY]: "project-network" })).toBe( + "shell-network", + ); + }); + + it("falls back to the generated network name when the flag and env are all absent/empty", () => { + delete process.env[KEY]; + expect(legacyResolveNetworkId(undefined, "my-app", {})).toBe(localNetworkId("my-app")); + expect(legacyResolveNetworkId("", "my-app", {})).toBe(localNetworkId("my-app")); + }); + + it("treats an empty shell value as present (blocks the project value) and falls to generated", () => { + process.env[KEY] = ""; + expect(legacyResolveNetworkId(undefined, "my-app", { [KEY]: "project-network" })).toBe( + localNetworkId("my-app"), + ); + }); +}); + describe("legacySanitizeProjectId", () => { it("replaces invalid character runs with a single underscore", () => { expect(legacySanitizeProjectId("My App!!")).toBe("My_App_"); diff --git a/apps/cli/src/shared/legacy/legacy-viper-env.ts b/apps/cli/src/shared/legacy/legacy-viper-env.ts index f787ab97d8..03fa22be15 100644 --- a/apps/cli/src/shared/legacy/legacy-viper-env.ts +++ b/apps/cli/src/shared/legacy/legacy-viper-env.ts @@ -55,3 +55,19 @@ export function legacyViperEnvBoolWithProjectFallback( ): boolean { return legacyViperBool(process.env[name] ?? projectEnv[name]); } + +/** + * `viper.GetString` for a `SUPABASE_*` key where a project `supabase/.env` value may also + * apply — same shell-*presence*-wins semantics as {@link legacyViperEnvBoolWithProjectFallback} + * (godotenv.Load's "don't override a key that already exists in `os.Environ()`" check is + * presence-based, not value-based, so an empty shell value still blocks the project file's + * value), but for a plain string-typed viper-bound flag — no `ParseBool`/`cast.ToBool` coercion, + * just the raw merged string (or `""` when the key is absent from both, matching `viper.GetString` + * always returning a string rather than `undefined`). `??` (not `||`) encodes the presence check. + */ +export function legacyViperEnvStringWithProjectFallback( + name: string, + projectEnv: Record, +): string { + return process.env[name] ?? projectEnv[name] ?? ""; +} diff --git a/apps/cli/src/shared/legacy/legacy-viper-env.unit.test.ts b/apps/cli/src/shared/legacy/legacy-viper-env.unit.test.ts index 7dc63d33d9..c67befd1d8 100644 --- a/apps/cli/src/shared/legacy/legacy-viper-env.unit.test.ts +++ b/apps/cli/src/shared/legacy/legacy-viper-env.unit.test.ts @@ -1,8 +1,13 @@ import { afterEach, describe, expect, it } from "vitest"; -import { legacyViperEnvBool, legacyViperEnvBoolWithProjectFallback } from "./legacy-viper-env.ts"; +import { + legacyViperEnvBool, + legacyViperEnvBoolWithProjectFallback, + legacyViperEnvStringWithProjectFallback, +} from "./legacy-viper-env.ts"; const KEY = "SUPABASE_TEST_VIPER_BOOL"; +const STRING_KEY = "SUPABASE_TEST_VIPER_STRING"; describe("legacyViperEnvBool", () => { afterEach(() => { @@ -70,3 +75,38 @@ describe("legacyViperEnvBoolWithProjectFallback", () => { expect(legacyViperEnvBoolWithProjectFallback(KEY, { [KEY]: "false" })).toBe(true); }); }); + +describe("legacyViperEnvStringWithProjectFallback", () => { + afterEach(() => { + delete process.env[STRING_KEY]; + }); + + it("falls back to the project value only when the shell var is absent", () => { + delete process.env[STRING_KEY]; + expect( + legacyViperEnvStringWithProjectFallback(STRING_KEY, { [STRING_KEY]: "project-value" }), + ).toBe("project-value"); + expect(legacyViperEnvStringWithProjectFallback(STRING_KEY, {})).toBe(""); + }); + + it("keeps the shell value over a project value", () => { + process.env[STRING_KEY] = "shell-value"; + expect( + legacyViperEnvStringWithProjectFallback(STRING_KEY, { [STRING_KEY]: "project-value" }), + ).toBe("shell-value"); + }); + + it("treats an empty shell value as present (blocks the project value)", () => { + // Same presence-based semantics as legacyViperEnvBoolWithProjectFallback: godotenv.Load's + // "don't override a key already in os.Environ()" check is key-existence, not value-truthiness. + process.env[STRING_KEY] = ""; + expect( + legacyViperEnvStringWithProjectFallback(STRING_KEY, { [STRING_KEY]: "project-value" }), + ).toBe(""); + }); + + it("returns an empty string (not undefined) when absent from both, matching viper.GetString", () => { + delete process.env[STRING_KEY]; + expect(legacyViperEnvStringWithProjectFallback(STRING_KEY, {})).toBe(""); + }); +}); From 7ebbc0c2e6aba517d846a43ce3ef275994d2ed70 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 07:12:19 +0100 Subject: [PATCH 26/48] fix(cli): label PG15+ one-shot setup jobs for rollback discovery (review: Codex, PR #6022) Go's DockerStart unconditionally sets com.supabase.cli.project and com.docker.compose.project on every container it starts, one-shot jobs included (apps/cli-go/internal/utils/docker.go:371-376). The generic LegacyDockerRun.runCapture path this shell's PG15+ realtime/storage/auth migrate jobs run through never emitted --label at all, so a job left running after a client interruption or daemon disconnect was invisible to supabase stop/rollback's project-label filter (legacy-docker-remove-all.ts). LegacyDockerRunOpts now carries an optional labels map, threaded through from the setup pipeline's own projectId. --- .../legacy/shared/db-bootstrap/db-setup.ts | 19 ++++++++++++- .../shared/db-bootstrap/db-setup.unit.test.ts | 27 +++++++++++++++++++ .../legacy/shared/legacy-docker-run.args.ts | 10 +++++++ .../legacy-docker-run.args.unit.test.ts | 21 +++++++++++++++ .../shared/legacy-docker-run.service.ts | 13 +++++++++ 5 files changed, 89 insertions(+), 1 deletion(-) 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 ccc996bd7f..f713e9cefe 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -86,7 +86,7 @@ import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import type { LegacyDbSession } from "../legacy-db-connection.service.ts"; import { LegacyDbConfigLoadError } from "../legacy-db-config.errors.ts"; import { legacyCheckDbToml } from "../legacy-db-config.toml-read.ts"; -import { legacyServiceContainerName } from "../legacy-docker-ids.ts"; +import { LEGACY_CLI_PROJECT_LABEL, legacyServiceContainerName } from "../legacy-docker-ids.ts"; import { LegacyDockerRun, type LegacyDockerRunOpts } from "../legacy-docker-run.service.ts"; import { legacyEnsureImagesCached, type LegacyImagePrepullError } from "./image-prepull.ts"; import { legacyMigrateAndSeed } from "../legacy-migrate-and-seed.ts"; @@ -94,6 +94,7 @@ import { LegacyMigrationApplyError, legacyExecSqlFile } from "../legacy-migratio import type { LegacyMigrationSeedError } from "../legacy-seed.ts"; import { ramInBytes } from "../legacy-size-units.ts"; import { LegacyMigrationVaultError, legacyUpsertVaultSecrets } from "../legacy-vault.ts"; +import { LEGACY_COMPOSE_PROJECT_LABEL } from "./container-lifecycle.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"; @@ -326,6 +327,14 @@ const legacyStartInitSchemaPre15 = Effect.fnUntraced(function* ( * `start-database.ts`'s own doc comment for why), and Go's registry-override env var applies * uniformly to every `DockerResolveImageIfNotCached` call, including project-`.env`-scoped * values — this call must see the same override the long-running containers' own resolve does. + * + * Labels the container with `com.supabase.cli.project`/`com.docker.compose.project` + * (`opts.projectId`), matching Go's `DockerStart`, which sets both unconditionally for + * every container it starts, one-shot jobs included (`docker.go:371-376`) — so if the + * client is interrupted or the daemon disconnects while this job is still running, the + * orphaned container is still discoverable (and removable) by `supabase stop`/rollback's + * project-label filter (`legacy-docker-remove-all.ts`), not left invisible to both + * (review: Codex, PR #6022). */ const legacyRunStartMigrateJob = Effect.fnUntraced(function* ( spawner: Spawner, @@ -334,6 +343,7 @@ const legacyRunStartMigrateJob = Effect.fnUntraced(function* ( readonly env: Readonly>; readonly cmd: ReadonlyArray; readonly networkId: string; + readonly projectId: string; readonly projectEnvValues: Readonly> | undefined; /** `--debug` — Go's `utils.GetDebugLogger()`, see this function's own doc comment. */ readonly debug: boolean; @@ -360,6 +370,10 @@ const legacyRunStartMigrateJob = Effect.fnUntraced(function* ( securityOpt: [], extraHosts, network: { _tag: "named", name: opts.networkId }, + labels: { + [LEGACY_CLI_PROJECT_LABEL]: opts.projectId, + [LEGACY_COMPOSE_PROJECT_LABEL]: opts.projectId, + }, // Already resolved, immediately above — `LegacyDockerRun.runCapture`'s own ambient-only // resolver must not re-resolve it (it doesn't see `opts.projectEnvValues` at all). skipImageResolve: true, @@ -452,6 +466,7 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( yield* legacyRunStartMigrateJob(spawner, { image: input.images.realtime, networkId: input.networkId, + projectId: input.projectId, projectEnvValues: input.projectEnvValues, debug: input.debug, env: legacyBuildRealtimeEnv({ @@ -502,6 +517,7 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( yield* legacyRunStartMigrateJob(spawner, { image: input.images.storage, networkId: input.networkId, + projectId: input.projectId, projectEnvValues: input.projectEnvValues, debug: input.debug, env: storageEnv, @@ -512,6 +528,7 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( yield* legacyRunStartMigrateJob(spawner, { image: input.images.auth, networkId: input.networkId, + projectId: input.projectId, projectEnvValues: input.projectEnvValues, debug: input.debug, env: legacyStartAuthMigrateEnv({ 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 09c65b8cbf..762a8c6207 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 @@ -296,6 +296,33 @@ describe("legacyStartSetupLocalDatabase", () => { ); }); + it.effect( + "labels every one-shot job with the project's Docker labels, matching Go's DockerStart (review: Codex, PR #6022)", + () => { + const workdir = makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + // Default config: realtime, storage, and auth are all enabled — 3 jobs. + return run( + baseInput(workdir, session, { majorVersion: 15, projectId: "labeled-proj" }), + out, + docker, + ).pipe( + Effect.map(() => { + expect(docker.runs.length).toBe(3); + for (const job of docker.runs) { + expect(job.labels).toEqual({ + "com.supabase.cli.project": "labeled-proj", + "com.docker.compose.project": "labeled-proj", + }); + } + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }, + ); + it.effect( "the realtime job's env matches `legacyBuildRealtimeEnv` on the internal db address + jwks", () => { diff --git a/apps/cli/src/legacy/shared/legacy-docker-run.args.ts b/apps/cli/src/legacy/shared/legacy-docker-run.args.ts index 6f81fcab3e..a7fd12a446 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-run.args.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-run.args.ts @@ -13,6 +13,7 @@ import type { LegacyDockerRunOpts } from "./legacy-docker-run.service.ts"; export function buildLegacyDockerArgs(opts: LegacyDockerRunOpts): ReadonlyArray { const { network, binds, env, securityOpt, extraHosts, workingDir, image, cmd } = opts; const entrypoint = opts.entrypoint ?? Option.none(); + const labels = opts.labels ?? {}; const networkArgs: ReadonlyArray = network._tag === "host" ? ["--network", "host"] @@ -35,6 +36,15 @@ export function buildLegacyDockerArgs(opts: LegacyDockerRunOpts): ReadonlyArray< ...Object.keys(env).flatMap((k) => ["-e", k]), ...securityOpt.flatMap((s) => ["--security-opt", s]), ...(Option.isSome(workingDir) ? ["-w", workingDir.value] : []), + // Go's `DockerStart` unconditionally sets `com.supabase.cli.project` and + // `com.docker.compose.project` on `config.Labels` for EVERY container it + // starts, one-shot jobs included (`apps/cli-go/internal/utils/docker.go: + // 371-376`) — `supabase stop` and this shell's own rollback both discover + // orphaned containers by that project-label filter + // (`legacy-docker-remove-all.ts`), so a one-shot job left running after a + // client interruption/daemon disconnect must carry the same labels to be + // found. Empty unless the caller opts in (review: Codex, PR #6022). + ...Object.entries(labels).flatMap(([k, v]) => ["--label", `${k}=${v}`]), // `--entrypoint` must precede the image (it is a `docker run` flag); the // remaining `cmd` tokens become the entrypoint's args, mirroring Go's // `Entrypoint: [value, ...cmd]`. diff --git a/apps/cli/src/legacy/shared/legacy-docker-run.args.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-run.args.unit.test.ts index 909dd3a1ae..fb7552e593 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-run.args.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-run.args.unit.test.ts @@ -115,6 +115,27 @@ describe("buildLegacyDockerArgs", () => { ); }); + test("omits --label entirely when labels is absent (db dump / pg_prove / edge-runtime)", () => { + expect(buildLegacyDockerArgs(base)).not.toContain("--label"); + }); + + test("emits --label k=v for each entry, before the image (Go's DockerStart project labels)", () => { + const args = buildLegacyDockerArgs({ + ...base, + labels: { + "com.supabase.cli.project": "proj", + "com.docker.compose.project": "proj", + }, + }); + const imageIdx = args.indexOf("supabase/pg_prove:3.36"); + expect(args.slice(imageIdx - 4, imageIdx)).toEqual([ + "--label", + "com.supabase.cli.project=proj", + "--label", + "com.docker.compose.project=proj", + ]); + }); + test("never serializes env values into argv (CWE-214: PGPASSWORD must not leak to ps)", () => { const args = buildLegacyDockerArgs({ ...base, diff --git a/apps/cli/src/legacy/shared/legacy-docker-run.service.ts b/apps/cli/src/legacy/shared/legacy-docker-run.service.ts index 12a25307a0..628e8db842 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-run.service.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-run.service.ts @@ -29,6 +29,19 @@ export interface LegacyDockerRunOpts { */ readonly extraHosts: ReadonlyArray; readonly network: LegacyDockerNetwork; + /** + * Docker labels (`--label k=v`) applied to the container. Go's `DockerStart` + * unconditionally sets `com.supabase.cli.project`/`com.docker.compose.project` + * on every container it starts (`apps/cli-go/internal/utils/docker.go:371-376`), + * including one-shot jobs — omitted here (defaults to none) for callers whose + * container is always synchronously reaped by the SAME process before it could + * ever need project-label-based discovery (e.g. `db dump`/`pg_prove`/edge-runtime + * one-shot runs); set by callers whose container can outlive an interrupted + * process (e.g. `start`'s fresh-volume PG15+ realtime/storage/auth migrate jobs) + * so `supabase stop`/rollback's project-label filter + * (`legacy-docker-remove-all.ts`) can still find and remove it if orphaned. + */ + readonly labels?: Readonly>; /** * Skips this layer's own image resolution (`legacyMakeDockerImageResolver`) when the * caller already resolved `image` itself through a `projectEnvValues`-aware path (e.g. From d9e6ba855a2f04dc2e64780c09268afdc1ea03dd Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 07:12:43 +0100 Subject: [PATCH 27/48] fix(cli): print the OrioleDB S3 env-unset WARN exactly once during fresh-volume start (review: Codex, PR #6022) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's flags.LoadConfig runs exactly once per command invocation, so its assertEnvLoaded WARN ("environment variable is unset: ...") for an OrioleDB project with an unresolved S3 env(VAR) prints at most once. This shell's start/db start fresh-volume bootstrap reads config.toml more than once per invocation: an early, discarded-result legacyCheckDbToml call already runs purely for Go-parity validation (start.handler.ts, db/start/start.handler.ts), then legacyIsLocalDbRunning's best-effort projectId probe and legacyStartSetupLocalDatabase's own accepted duplicate config-load pass each re-read the same file — reprinting the same WARN a second (start) or third (db start) time. legacyCheckDbToml/legacyReadDbToml now accept warnOnUnresolvedEnv (default true); the two internal re-reads that run after an already-validated preflight pass false, so the warning fires exactly once, matching Go. --- .../legacy/shared/db-bootstrap/db-setup.ts | 13 +++++- .../shared/db-bootstrap/local-db-running.ts | 6 +++ .../shared/legacy-db-config.toml-read.ts | 36 +++++++++++---- .../legacy-db-config.toml-read.unit.test.ts | 44 +++++++++++++++++++ 4 files changed, 90 insertions(+), 9 deletions(-) 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 f713e9cefe..5e9c8554c8 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -655,7 +655,18 @@ export const legacyStartSetupLocalDatabase = ( Effect.gen(function* () { const { session, fs, path, workdir } = input; - const toml = yield* legacyCheckDbToml(fs, path, workdir); + // `warnOnUnresolvedEnv: false` — both `start.handler.ts` and `db/start/ + // start.handler.ts` already ran an earlier, same-invocation `legacyCheckDbToml` + // purely for its Go-parity validation side effect (their own callers discard the + // result) before ever reaching this fresh-volume setup, so that earlier call + // already printed Go's single `assertEnvLoaded` OrioleDB S3 WARN, if any. Without + // this, this module's own accepted duplicate config-load pass (see this module's + // header) would print the SAME warning a second time — a real, observable stderr + // divergence from Go's exactly-once `flags.LoadConfig`, unlike the harmless + // resolved-value duplication the header describes. + const toml = yield* legacyCheckDbToml(fs, path, workdir, undefined, { + warnOnUnresolvedEnv: false, + }); // SetupDatabase: initSchema -> ApplyApiPrivileges (start.go:383-389). yield* Effect.scoped( 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..60f3f029d7 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 @@ -60,8 +60,14 @@ export function legacyIsLocalDbRunning( ): Effect.Effect { return Effect.scoped( Effect.gen(function* () { + // `warnOnUnresolvedEnv: false` — this doc comment's own `resolveDbToml` note: + // the caller has already run Go's `LoadConfig` validation (and, if the config + // has an OrioleDB project with an unresolved S3 `env(VAR)`, already printed + // Go's single `assertEnvLoaded` WARN) before reaching this probe. Re-printing + // it here would diverge from Go's exactly-once `flags.LoadConfig` call. const tomlProjectId = yield* legacyReadDbToml(fs, path, workdir, undefined, { validate: false, + warnOnUnresolvedEnv: false, }).pipe( Effect.map((toml) => toml.projectId), Effect.orElseSucceed(() => Option.none()), diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index 919e8b005c..291363f071 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -922,6 +922,19 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // wrapper uses this as its fallback after a config-load failure, mirroring the // best-effort behavior the container-id seam relied on before. ignoreConfigFile = false, + // Internal: gates the `assertEnvLoaded` OrioleDB S3 stderr WARN below (review: + // Codex, PR #6022). Go's `flags.LoadConfig` runs exactly once per command + // invocation, so the warning prints at most once. `start`/`db start`'s + // fresh-volume bootstrap calls this reader more than once in a single + // invocation — once purely for its Go-parity validation side effect + // (`start.handler.ts:614`, `db/start/start.handler.ts:125`, both discard the + // result), then again internally wherever a resolved value is actually needed + // (`legacyIsLocalDbRunning`'s best-effort `projectId` probe, + // `legacyStartSetupLocalDatabase`'s own accepted duplicate config-load pass — + // see `db-bootstrap/db-setup.ts`'s header). Those internal re-reads pass + // `false` so the warning still fires exactly once per invocation, matching + // Go, instead of two or three times. + warnOnUnresolvedEnv = true, ) { const supabaseDir = path.join(workdir, "supabase"); const configPath = path.join(supabaseDir, "config.toml"); @@ -1194,7 +1207,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( if (typeof raw !== "string") continue; const expanded = legacyExpandEnv(raw, lookup); const unset = ENV_PATTERN.exec(expanded); - if (unset !== null) { + if (unset !== null && warnOnUnresolvedEnv) { process.stderr.write(`WARN: environment variable is unset: ${unset[1] ?? ""}\n`); } } @@ -2002,7 +2015,12 @@ export const legacyCheckDbToml = ( path: Path.Path, workdir: string, ref?: string, -) => readDbTomlCore(fs, path, workdir, ref, false); + // `warnOnUnresolvedEnv: false` — see `readDbTomlCore`'s own doc comment — for a + // caller known to run AFTER an earlier, same-invocation `legacyCheckDbToml`/ + // `legacyReadDbToml` call already printed the OrioleDB S3 `assertEnvLoaded` WARN + // once. Omit (default `true`) for every standalone command entry point. + opts?: { readonly warnOnUnresolvedEnv?: boolean }, +) => readDbTomlCore(fs, path, workdir, ref, false, opts?.warnOnUnresolvedEnv ?? true); /** * Read `config.toml`. Defaults to Go's validating behavior (identical to @@ -2017,17 +2035,19 @@ export const legacyReadDbToml = ( path: Path.Path, workdir: string, ref?: string, - opts?: { readonly validate?: boolean }, -) => - opts?.validate === false - ? readDbTomlCore(fs, path, workdir, ref, false).pipe( + opts?: { readonly validate?: boolean; readonly warnOnUnresolvedEnv?: boolean }, +) => { + const warnOnUnresolvedEnv = opts?.warnOnUnresolvedEnv ?? true; + return opts?.validate === false + ? readDbTomlCore(fs, path, workdir, ref, false, warnOnUnresolvedEnv).pipe( // Fall back to the ignore-file defaults path (never re-reads the broken config) // so a best-effort caller gets a well-formed defaults result instead of a throw. Effect.catchTag("LegacyDbConfigLoadError", () => - readDbTomlCore(fs, path, workdir, ref, true), + readDbTomlCore(fs, path, workdir, ref, true, warnOnUnresolvedEnv), ), ) - : readDbTomlCore(fs, path, workdir, ref, false); + : readDbTomlCore(fs, path, workdir, ref, false, warnOnUnresolvedEnv); +}; /** * The effective declarative schema directory: the configured diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts index a8694a324a..5257f7fd89 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts @@ -1518,6 +1518,50 @@ describe("legacyReadDbToml", () => { ); }); + it.effect( + "warnOnUnresolvedEnv: false suppresses the S3 env WARN (review: Codex, PR #6022)", + () => { + // `start`/`db start`'s fresh-volume bootstrap reads this same config.toml more + // than once per invocation (an earlier, authoritative preflight call already + // warned) — internal re-reads pass `warnOnUnresolvedEnv: false` so Go's + // exactly-once `flags.LoadConfig` WARN isn't printed a second/third time. + delete process.env["LEGACY_S3_KEY_QUIET"]; + const writes: Array = []; + const original = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: string | Uint8Array): boolean => { + writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString()); + return true; + }) as typeof process.stderr.write; + const dir = withConfig( + [ + "[db]", + "major_version = 15", + "[experimental]", + 'orioledb_version = "15.1.0.55"', + 's3_access_key = "env(LEGACY_S3_KEY_QUIET)"', + "", + ].join("\n"), + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacyReadDbToml(fs, path, dir, undefined, { warnOnUnresolvedEnv: false }); + }).pipe( + Effect.provide(BunServices.layer), + Effect.tap((v) => + Effect.sync(() => { + // Config load still succeeds and still resolves the value; only the + // stderr WARN side effect is suppressed. + expect(Option.getOrNull(v.orioledbVersion)).toBe("15.1.0.55"); + expect(writes.join("")).not.toContain("WARN: environment variable is unset"); + process.stderr.write = original; + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + it.effect("keeps the literal password when its env var is unset/empty", () => { // Go's LoadEnvHook only substitutes when len(os.Getenv(name)) > 0; otherwise it // preserves the literal string. Password is a plain string field, so an From 18ae9fecd3b7a9c20f8890139b752917a0ab3d4b Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 08:18:27 +0100 Subject: [PATCH 28/48] fix(cli): validate Realtime env overrides in db start's eager battery before the already-running short-circuit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's flags.LoadConfig decodes Realtime.IpVersion/MaxHeaderLength unconditionally in the same Config.Load pass as edge_runtime.inspector_port, before AssertSupabaseDbIsRunning ever runs. The ported eager-validation battery in db start's handler already closes this gap for every sibling field, but stopped short of these two Realtime fields, which were previously only validated inside legacyResolveDbBootstrapConfig — unreachable once Postgres is already running. Fixes the gap flagged in PR #6022 review thread PRRT_kwDOErm0O86VmHkl. --- .../legacy/commands/db/start/start.handler.ts | 15 +++++++++++ .../db/start/start.integration.test.ts | 27 +++++++++++++++++++ 2 files changed, 42 insertions(+) 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 691b86fd8d..abe8aa2b99 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -26,6 +26,8 @@ import { legacyEnvOverrideMaxClientConn, legacyEnvOverridePoolMode, legacyEnvOverridePort, + legacyEnvOverrideRealtimeIpVersion, + legacyEnvOverrideRealtimeMaxHeaderLength, legacyEnvOverrideUint, legacyResolveAuthEmail, legacyResolveAuthExternalProviders, @@ -455,6 +457,19 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega ), ); + // Same gap for Realtime's `ip_version` (an enum via `UnmarshalText`) and + // `max_header_length` (a plain `uint`) — decoded in the same unconditional `Config.Load` + // pass as `edge_runtime.inspector_port` above (`pkg/config/config.go:252-253`), regardless + // of whether this eager battery itself ever reads them: they're only otherwise consumed by + // `legacyResolveDbBootstrapConfig` below, which never runs on the already-running + // short-circuit right after this block (review: PRRT_kwDOErm0O86VmHkl). + yield* wrapDbConfigOverride("realtime.ip_version", () => + legacyEnvOverrideRealtimeIpVersion(config.realtime.ip_version, projectEnvValues), + ); + yield* wrapDbConfigOverride("realtime.max_header_length", () => + legacyEnvOverrideRealtimeMaxHeaderLength(config.realtime.max_header_length, projectEnvValues), + ); + // Go's AssertSupabaseDbIsRunning: if the db container is already up, print to // stderr and return nil (exit 0). Already native — see this module's header. Runs AFTER // the config load/validation above, matching Go's `start.Run` (`flags.LoadConfig` before 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 4e0f3a63c7..38691ca0cc 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 @@ -758,6 +758,33 @@ describe("legacy db start", () => { }, ); + // Regression test for the exact gap the review thread found: `legacyEnvOverrideRealtimeIpVersion`/ + // `legacyEnvOverrideRealtimeMaxHeaderLength` used to be invoked ONLY inside + // `legacyResolveDbBootstrapConfig`, which never runs once `legacyIsLocalDbRunning` short-circuits + // — so a malformed override was silently ignored whenever Postgres was already running, unlike + // Go's `flags.LoadConfig`, which decodes both fields unconditionally before + // `AssertSupabaseDbIsRunning` (review: PRRT_kwDOErm0O86VmHkl). + it.live.each([ + ["realtime.ip_version", "SUPABASE_REALTIME_IP_VERSION", "IPv5"], + ["realtime.max_header_length", "SUPABASE_REALTIME_MAX_HEADER_LENGTH", "not-a-uint"], + ] as const)( + "fails with a typed config error on a malformed %s override even when Postgres is already running", + ([dottedFieldPath, envVar, envValue]) => { + const { layer, child } = setup({ running: true }); + writeFileSync(join(tempRoot.current, "supabase", ".env"), `${envVar}=${envValue}\n`); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const message = JSON.stringify(exit.cause); + expect(message).toContain("LegacyDbConfigLoadError"); + expect(message).toContain(dottedFieldPath); + } + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + }); + }, + ); + it.live( "fails on an invalid auth.passkey.enabled even when auth is disabled, matching Go's Config.Load", () => { From dd76ae96c2cc70983d9acef223475a761be1bf01 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 08:19:02 +0100 Subject: [PATCH 29/48] fix(cli): emit db start's "Starting database..." progress line unconditionally, matching Go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start-database.ts gated this stderr line on output.format === "text", silently dropping it under --output-format json/stream-json. Go's StartDatabase writes it unconditionally to stderr — it has no output-format concept at all — and every sibling progress line in this same shared bootstrap pipeline (db-setup.ts's "Initialising schema..."/"Seeding globals...", legacy-migrate-and-seed.ts's "Applying migration ...") is already unguarded. Fixes the gap flagged in PR #6022 review thread PRRT_kwDOErm0O86VmHkn. --- .../db/start/start.integration.test.ts | 5 +++++ .../shared/db-bootstrap/start-database.ts | 19 +++++++++++-------- 2 files changed, 16 insertions(+), 8 deletions(-) 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 38691ca0cc..465d565c6b 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 @@ -949,6 +949,11 @@ describe("legacy db start", () => { expect(createArgs(child.spawned)).not.toBeUndefined(); const success = out.messages.find((m) => m.type === "success"); expect(success?.data?.["status"]).toBe("started"); + // Go's `StartDatabase` (`start.go:168-175`) writes "Starting database..." (or "...from + // backup..." on a pre-existing volume, as here — see `defaultRoute`) to stderr + // unconditionally — no output-format concept gates it in Go, so the `--output-format json` + // run must still see it on stderr (review: PRRT_kwDOErm0O86VmHkn). + expect(out.stderrText).toContain("Starting database from backup...\n"); }); }); }); 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 39b5e73627..23e4ce2a79 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts @@ -229,14 +229,17 @@ export const legacyStartDatabase = ( ); } - if (output.format === "text") { - yield* output.raw( - isFreshVolume - ? LEGACY_START_STARTING_DATABASE_MESSAGE - : LEGACY_START_STARTING_DATABASE_FROM_BACKUP_MESSAGE, - "stderr", - ); - } + // Go's `StartDatabase` (`start.go:168-175`) prints this unconditionally to stderr — Go has + // no output-format concept for this seam at all. Matches every other progress line in this + // same pipeline (`db-setup.ts`'s "Initialising schema..."/"Seeding globals...", + // `legacy-migrate-and-seed.ts`'s "Applying migration ..."), which are also unguarded + // (review: PRRT_kwDOErm0O86VmHkn). + yield* output.raw( + isFreshVolume + ? LEGACY_START_STARTING_DATABASE_MESSAGE + : LEGACY_START_STARTING_DATABASE_FROM_BACKUP_MESSAGE, + "stderr", + ); const resolvedPostgresImage = yield* input.resolvePostgresImage; From 42cafbf8ac79d5bb53cfb2f0f09cd0e5ab32c935 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 08:19:17 +0100 Subject: [PATCH 30/48] fix(cli): install project-dotenv BITBUCKET_CLONE_DIR before Docker bring-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit legacyLoadLocalProjectContext only installed Docker-client env keys (DOCKER_HOST etc.) from a project dotenv file into process.env, so a BITBUCKET_CLONE_DIR set only in supabase/.env never reached the later, process-only legacyIsBitbucketPipeline() check. Go's DockerStart reads os.Getenv("BITBUCKET_CLONE_DIR") inside its own function body, well after Config.Load's godotenv.Load has already installed dotenv keys into the process env — unlike the already-rejected SUPABASE_SERVICES_HOSTNAME case, where GetHostname() is wired into a package-level var evaluated before godotenv.Load ever runs. Installs BITBUCKET_CLONE_DIR alongside the Docker-client keys (not into DOCKER_CLIENT_ENV_KEYS itself, which is scoped to Docker/Podman client-targeting vars and also governs container env emission), with the same shell-env-wins semantics. Fixes the gap flagged in PR #6022 review thread PRRT_kwDOErm0O86VmHkm. --- .../shared/legacy-bitbucket-pipeline.ts | 14 +++++- .../shared/legacy-local-project-context.ts | 11 ++++- .../legacy-local-project-context.unit.test.ts | 43 +++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-bitbucket-pipeline.ts b/apps/cli/src/legacy/shared/legacy-bitbucket-pipeline.ts index 9cee8f5dad..adf44c844f 100644 --- a/apps/cli/src/legacy/shared/legacy-bitbucket-pipeline.ts +++ b/apps/cli/src/legacy/shared/legacy-bitbucket-pipeline.ts @@ -1,3 +1,15 @@ +/** + * `BITBUCKET_CLONE_DIR` — exported so `legacy-local-project-context.ts` can install it from a + * project dotenv file into `process.env` before this check ever runs. Unlike + * `SUPABASE_SERVICES_HOSTNAME` (deliberately NOT installed, see that file's own doc comment), + * Go's `os.Getenv("BITBUCKET_CLONE_DIR")` read (`apps/cli-go/internal/utils/docker.go:401`) lives + * inside `DockerStart`, a regular function invoked during the command's own `Run()`, well after + * `flags.LoadConfig` -> `godotenv.Load` has already installed dotenv keys into the process env + * (`pkg/config/config.go:786-791,1261`) — not in a package-level `var` initializer evaluated + * before `godotenv.Load` ever runs (review: PRRT_kwDOErm0O86VmHkm). + */ +export const LEGACY_BITBUCKET_CLONE_DIR_ENV_KEY = "BITBUCKET_CLONE_DIR"; + /** * Whether the current process is running inside a Bitbucket Pipelines runner, * mirroring Go's `os.Getenv("BITBUCKET_CLONE_DIR") != ""` check @@ -12,6 +24,6 @@ * creation (`legacy/shared/db-bootstrap/container-lifecycle.ts`). */ export function legacyIsBitbucketPipeline(): boolean { - const value = globalThis.process.env["BITBUCKET_CLONE_DIR"]; + const value = globalThis.process.env[LEGACY_BITBUCKET_CLONE_DIR_ENV_KEY]; return value !== undefined && value.length > 0; } diff --git a/apps/cli/src/legacy/shared/legacy-local-project-context.ts b/apps/cli/src/legacy/shared/legacy-local-project-context.ts index 7487077d4b..fb758ee87b 100644 --- a/apps/cli/src/legacy/shared/legacy-local-project-context.ts +++ b/apps/cli/src/legacy/shared/legacy-local-project-context.ts @@ -8,6 +8,7 @@ import { import { Effect, FileSystem, Path, Schema } from "effect"; import { legacyIsDockerClientEnvKey } from "./db-bootstrap/docker-create-args.ts"; +import { LEGACY_BITBUCKET_CLONE_DIR_ENV_KEY } from "./legacy-bitbucket-pipeline.ts"; import { legacyResolveLocalProjectId, legacySanitizeProjectId } from "./legacy-docker-ids.ts"; import { legacyGetHostname } from "./legacy-hostname.ts"; import { legacyResolveProjectEnvironmentValues } from "./legacy-project-environment.ts"; @@ -107,8 +108,16 @@ export const legacyLoadLocalProjectContext = ( // narrower, explicitly-scoped opt-in around a single command's container work) — matching // Go's own non-reverting `os.Setenv`, which persists for that single-command process's // entire lifetime. + // + // `BITBUCKET_CLONE_DIR` is included alongside the Docker-client keys for the same + // subsequent-process-env-read reason, even though it isn't itself a Docker-client-targeting + // var (see {@link LEGACY_BITBUCKET_CLONE_DIR_ENV_KEY}'s own doc comment for why this one, and + // not `SUPABASE_SERVICES_HOSTNAME` right below, must be installed here: review: + // PRRT_kwDOErm0O86VmHkm). for (const [key, value] of Object.entries(projectEnvValues)) { - if (legacyIsDockerClientEnvKey(key) && process.env[key] === undefined) { + const installsFromProjectDotenv = + legacyIsDockerClientEnvKey(key) || key === LEGACY_BITBUCKET_CLONE_DIR_ENV_KEY; + if (installsFromProjectDotenv && process.env[key] === undefined) { process.env[key] = value; } } diff --git a/apps/cli/src/legacy/shared/legacy-local-project-context.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-project-context.unit.test.ts index d41c576422..4368f194aa 100644 --- a/apps/cli/src/legacy/shared/legacy-local-project-context.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-project-context.unit.test.ts @@ -14,6 +14,14 @@ import { legacyLoadLocalProjectContext } from "./legacy-local-project-context.ts */ const DOCKER_HOST_KEY = "DOCKER_HOST"; +/** + * `BITBUCKET_CLONE_DIR` is installed alongside the Docker-client keys even though it isn't one + * itself — see `LEGACY_BITBUCKET_CLONE_DIR_ENV_KEY`'s doc comment (review: + * PRRT_kwDOErm0O86VmHkm) for why this key, unlike `SUPABASE_SERVICES_HOSTNAME`, must reach + * `process.env` from a project-only dotenv file. + */ +const BITBUCKET_CLONE_DIR_KEY = "BITBUCKET_CLONE_DIR"; + function writeDotEnv(workdir: string, contents: string): void { mkdirSync(workdir, { recursive: true }); writeFileSync(join(workdir, ".env"), contents); @@ -23,10 +31,13 @@ const tempRoot = useLegacyTempWorkdir("supabase-legacy-project-context-"); describe("legacyLoadLocalProjectContext", () => { const previousDockerHost = process.env[DOCKER_HOST_KEY]; + const previousBitbucketCloneDir = process.env[BITBUCKET_CLONE_DIR_KEY]; afterEach(() => { if (previousDockerHost === undefined) delete process.env[DOCKER_HOST_KEY]; else process.env[DOCKER_HOST_KEY] = previousDockerHost; + if (previousBitbucketCloneDir === undefined) delete process.env[BITBUCKET_CLONE_DIR_KEY]; + else process.env[BITBUCKET_CLONE_DIR_KEY] = previousBitbucketCloneDir; }); it.effect( @@ -60,4 +71,36 @@ describe("legacyLoadLocalProjectContext", () => { ); }, ); + + it.effect( + "installs a project .env's BITBUCKET_CLONE_DIR into process.env, matching Go's godotenv.Load preceding DockerStart's os.Getenv read", + () => { + delete process.env[BITBUCKET_CLONE_DIR_KEY]; + const workdir = tempRoot.current; + writeDotEnv(workdir, `BITBUCKET_CLONE_DIR=/opt/atlassian/pipelines/agent/build\n`); + + return legacyLoadLocalProjectContext(workdir, (message) => new Error(message)).pipe( + Effect.map(() => { + expect(process.env[BITBUCKET_CLONE_DIR_KEY]).toBe("/opt/atlassian/pipelines/agent/build"); + }), + Effect.provide(BunServices.layer), + ); + }, + ); + + it.effect( + "never overrides an already-set BITBUCKET_CLONE_DIR, matching godotenv.Load's shell-env-wins semantics", + () => { + process.env[BITBUCKET_CLONE_DIR_KEY] = "/real-shell-clone-dir"; + const workdir = tempRoot.current; + writeDotEnv(workdir, `BITBUCKET_CLONE_DIR=/opt/atlassian/pipelines/agent/build\n`); + + return legacyLoadLocalProjectContext(workdir, (message) => new Error(message)).pipe( + Effect.map(() => { + expect(process.env[BITBUCKET_CLONE_DIR_KEY]).toBe("/real-shell-clone-dir"); + }), + Effect.provide(BunServices.layer), + ); + }, + ); }); From 46b2a3c67948cdc34c715cf7f3a49748e96ef17e Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 08:49:56 +0100 Subject: [PATCH 31/48] fix(cli): validate auth.jwt_expiry before db start's already-running shortcut Go's Config.Load decodes auth.jwt_expiry (a plain uint) unconditionally before AssertSupabaseDbIsRunning. The native db start port only resolved it as part of legacyResolveLocalConfigValues, which runs solely in the not-running branch, so a malformed SUPABASE_AUTH_JWT_EXPIRY was silently accepted whenever Postgres was already running (review: Codex, PR #6022). --- .../legacy/commands/db/start/start.handler.ts | 15 +++++++++++ .../db/start/start.integration.test.ts | 27 +++++++++++++++++++ 2 files changed, 42 insertions(+) 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 abe8aa2b99..9113b8741f 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -220,6 +220,21 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega yield* wrapDbConfigOverride("auth.rate_limit", () => legacyResolveGotrueRateLimit(config.auth.rate_limit, projectEnvValues), ); + // Same gap for `auth.jwt_expiry` — a plain `uint` (`pkg/config/auth.go:155`) decoded by the + // SAME unconditional `Config.Load` pass as `auth.rate_limit` above, with no `Enabled`-gated + // `validate()` method of its own. Below, it's only ever resolved as part of + // `values.authJwtExpiry` (`legacyResolveLocalConfigValues`), which this handler calls ONLY in + // the not-running branch — so a malformed `SUPABASE_AUTH_JWT_EXPIRY` would otherwise be + // silently accepted whenever Postgres is already running, unlike Go, which decodes it before + // `AssertSupabaseDbIsRunning` regardless (review: PRRT_kwDOErm0O86VmpeG). + yield* wrapDbConfigOverride("auth.jwt_expiry", () => + legacyEnvOverrideUint( + "SUPABASE_AUTH_JWT_EXPIRY", + "auth.jwt_expiry", + config.auth.jwt_expiry, + projectEnvValues, + ), + ); // The rest of the eager-validation battery: Go's `Config.Load` decodes the ENTIRE config // struct in one `v.UnmarshalExact` pass (`pkg/config/config.go`'s `(c *config) load`), 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 465d565c6b..6b92591fca 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 @@ -785,6 +785,33 @@ describe("legacy db start", () => { }, ); + // Same gap, same shape, different field: `auth.jwt_expiry` (a plain `uint`, + // `pkg/config/auth.go:155`) was only ever resolved as part of `values.authJwtExpiry` + // (`legacyResolveLocalConfigValues`), which this handler calls ONLY in the not-running branch — + // so a malformed override was silently ignored whenever Postgres was already running, unlike + // Go's `flags.LoadConfig`, which decodes it unconditionally before `AssertSupabaseDbIsRunning` + // (review: PRRT_kwDOErm0O86VmpeG). + it.live( + "fails with a typed config error on a malformed SUPABASE_AUTH_JWT_EXPIRY override even when Postgres is already running", + () => { + const { layer, child } = setup({ running: true }); + writeFileSync( + join(tempRoot.current, "supabase", ".env"), + "SUPABASE_AUTH_JWT_EXPIRY=not-a-uint\n", + ); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const message = JSON.stringify(exit.cause); + expect(message).toContain("LegacyDbConfigLoadError"); + expect(message).toContain("auth.jwt_expiry"); + } + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + }); + }, + ); + it.live( "fails on an invalid auth.passkey.enabled even when auth is disabled, matching Go's Config.Load", () => { From f8f8aaf0e972ca71440e64e3819b81761d482af5 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 10:10:15 +0100 Subject: [PATCH 32/48] fix(cli): validate the complete root-auth scalar group before db start's already-running shortcut (review: PR #6022) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's flags.LoadConfig decodes auth.enable_signup, auth.enable_anonymous_sign_ins, auth.enable_refresh_token_rotation, auth.refresh_token_reuse_interval, auth.enable_manual_linking, auth.minimum_password_length, and auth.password_requirements unconditionally before AssertSupabaseDbIsRunning (internal/db/start/start.go:45-47) — none of these root-level auth scalars is referenced in Config.Validate's Enabled-gated block, so they're pure decode-time fields like auth.jwt_expiry/auth.rate_limit already fixed in prior rounds. db start's eager battery only reached these via legacyResolveLocalConfigValues in the not-running branch, so a malformed override was silently accepted whenever Postgres was already running. Extracts legacyEnvOverrideAuthPasswordRequirements out of legacyResolveLocalConfigValues so both callers share one implementation instead of duplicating the enum check. --- .../legacy/commands/db/start/start.handler.ts | 72 +++++++++++++++++++ .../db/start/start.integration.test.ts | 33 +++++++++ .../shared/legacy-local-config-values.ts | 44 +++++++----- 3 files changed, 133 insertions(+), 16 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 9113b8741f..6343da0748 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -20,6 +20,7 @@ import { } from "../../../shared/legacy-docker-ids.ts"; import { legacyEnvOverrideApiMaxRows, + legacyEnvOverrideAuthPasswordRequirements, legacyEnvOverrideBool, legacyEnvOverrideDefaultPoolSize, legacyEnvOverrideEdgeRuntimePolicy, @@ -235,6 +236,77 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega projectEnvValues, ), ); + // Same gap for the remaining root-level `auth.*` scalars — Go decodes ALL of them in the SAME + // unconditional `Config.Load` pass as `auth.jwt_expiry` above (`pkg/config/auth.go:158-163`), + // and NONE of them is referenced anywhere in `Config.Validate`'s `if c.Auth.Enabled` block + // (`config.go:1086-1153`) — so, like `auth.jwt_expiry`/`auth.rate_limit`, they're pure + // decode-time fields with no `Enabled`-gated `validate()` method of their own. + // `auth.enable_refresh_token_rotation`/`auth.enable_manual_linking`/ + // `auth.enable_anonymous_sign_ins` (plain `bool`s), `auth.refresh_token_reuse_interval`/ + // `auth.minimum_password_length` (plain `uint`s), and `auth.password_requirements` (an enum + // via `UnmarshalText`) are only otherwise resolved as part of `values.authEnableRefreshTokenRotation`/ + // `values.authEnableManualLinking`/`values.authEnableAnonymousSignIns`/ + // `values.authRefreshTokenReuseInterval`/`values.authMinimumPasswordLength`/ + // `values.authPasswordRequirements` (`legacyResolveLocalConfigValues`), which — like + // `values.authJwtExpiry` above — this handler calls ONLY in the not-running branch, so a + // malformed override of any one of them would otherwise be silently accepted whenever Postgres + // is already running, unlike Go (review: PRRT_kwDOErm0O86VnEV6, which named + // `auth.refresh_token_reuse_interval`/`auth.enable_signup`/`auth.password_requirements` as + // examples of "the complete root-auth group"). + yield* wrapDbConfigOverride("auth.enable_signup", () => + legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLE_SIGNUP", + config.auth.enable_signup, + "auth.enable_signup", + projectEnvValues, + ), + ); + yield* wrapDbConfigOverride("auth.enable_anonymous_sign_ins", () => + legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", + config.auth.enable_anonymous_sign_ins, + "auth.enable_anonymous_sign_ins", + projectEnvValues, + ), + ); + yield* wrapDbConfigOverride("auth.enable_refresh_token_rotation", () => + legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", + config.auth.enable_refresh_token_rotation, + "auth.enable_refresh_token_rotation", + projectEnvValues, + ), + ); + yield* wrapDbConfigOverride("auth.refresh_token_reuse_interval", () => + legacyEnvOverrideUint( + "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", + "auth.refresh_token_reuse_interval", + config.auth.refresh_token_reuse_interval, + projectEnvValues, + ), + ); + yield* wrapDbConfigOverride("auth.enable_manual_linking", () => + legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", + config.auth.enable_manual_linking, + "auth.enable_manual_linking", + projectEnvValues, + ), + ); + yield* wrapDbConfigOverride("auth.minimum_password_length", () => + legacyEnvOverrideUint( + "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", + "auth.minimum_password_length", + config.auth.minimum_password_length, + projectEnvValues, + ), + ); + yield* wrapDbConfigOverride("auth.password_requirements", () => + legacyEnvOverrideAuthPasswordRequirements( + config.auth.password_requirements, + projectEnvValues, + ), + ); // The rest of the eager-validation battery: Go's `Config.Load` decodes the ENTIRE config // struct in one `v.UnmarshalExact` pass (`pkg/config/config.go`'s `(c *config) load`), 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 6b92591fca..796ce388af 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 @@ -812,6 +812,39 @@ describe("legacy db start", () => { }, ); + // Same gap, same shape, remaining root-level `auth.*` scalars: none of these is referenced + // anywhere in `Config.Validate`'s `if c.Auth.Enabled` block (`pkg/config/config.go:1086-1153`), + // so — like `auth.jwt_expiry` above — each was only ever resolved as part of + // `legacyResolveLocalConfigValues`, which this handler calls ONLY in the not-running branch, and + // a malformed override was silently ignored whenever Postgres was already running, unlike Go's + // `flags.LoadConfig`, which decodes all of them unconditionally before + // `AssertSupabaseDbIsRunning` (review: PRRT_kwDOErm0O86VnEV6). + it.live.each([ + ["auth.enable_signup", "SUPABASE_AUTH_ENABLE_SIGNUP", "not-a-bool"], + ["auth.enable_anonymous_sign_ins", "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", "not-a-bool"], + ["auth.enable_refresh_token_rotation", "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", "not-a-bool"], + ["auth.refresh_token_reuse_interval", "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", "not-a-uint"], + ["auth.enable_manual_linking", "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", "not-a-bool"], + ["auth.minimum_password_length", "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", "not-a-uint"], + ["auth.password_requirements", "SUPABASE_AUTH_PASSWORD_REQUIREMENTS", "not-a-requirement"], + ] as const)( + "fails with a typed config error on a malformed %s override even when Postgres is already running", + ([dottedFieldPath, envVar, envValue]) => { + const { layer, child } = setup({ running: true }); + writeFileSync(join(tempRoot.current, "supabase", ".env"), `${envVar}=${envValue}\n`); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const message = JSON.stringify(exit.cause); + expect(message).toContain("LegacyDbConfigLoadError"); + expect(message).toContain(dottedFieldPath); + } + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + }); + }, + ); + it.live( "fails on an invalid auth.passkey.enabled even when auth is disabled, matching Go's Config.Load", () => { diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 42b9b17bd5..8e59e0a861 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -1452,6 +1452,32 @@ const LEGACY_PASSWORD_REQUIREMENTS_VALUES = new Set([ "lower_upper_letters_digits_symbols", ]); +/** + * `auth.password_requirements`-flavored sibling of {@link legacyEnvOverrideEdgeRuntimePolicy} — + * Go's `PasswordRequirements.UnmarshalText` (`pkg/config/auth.go:26-31`) hard-fails config loading + * on a value outside this fixed set, same decode-time-failure semantics as the other `UnmarshalText` + * enums above. Extracted to its own exported function (rather than left inline in + * {@link legacyResolveLocalConfigValues}) so `db start`'s own eager-validation battery + * (`commands/db/start/start.handler.ts`) can call it directly instead of duplicating the check — + * mirroring how that battery already calls `legacyEnvOverrideBool`/`legacyEnvOverrideUint` directly + * for `auth.enable_signup`/`auth.refresh_token_reuse_interval` rather than going through the full + * resolver (review: PRRT_kwDOErm0O86VnEV6). + */ +export function legacyEnvOverrideAuthPasswordRequirements( + configured: string, + projectEnvValues: Readonly> | undefined, +): string { + const override = legacyEnvOverride( + "SUPABASE_AUTH_PASSWORD_REQUIREMENTS", + undefined, + projectEnvValues, + ); + if (override !== undefined && !LEGACY_PASSWORD_REQUIREMENTS_VALUES.has(override)) { + throw new Error(`Failed reading config: Invalid auth.password_requirements: ${override}.`); + } + return override ?? configured; +} + /** Narrows an unknown value to a plain object, mirroring `legacy-db-config.toml-read.ts`'s `asRecord`. */ function asRecord(value: unknown): Record | undefined { return typeof value === "object" && value !== null && !Array.isArray(value) @@ -2742,24 +2768,10 @@ export function legacyResolveLocalConfigValues( config.auth.minimum_password_length, projectEnvValues, ); - // Go's `PasswordRequirements.UnmarshalText` (`pkg/config/auth.go:26-31`) - // hard-fails config loading on a value outside this fixed set — same - // decode-time-failure semantics as the numeric overrides above, just - // string-typed. - const passwordRequirementsOverride = legacyEnvOverride( - "SUPABASE_AUTH_PASSWORD_REQUIREMENTS", - undefined, + const passwordRequirements = legacyEnvOverrideAuthPasswordRequirements( + config.auth.password_requirements, projectEnvValues, ); - if ( - passwordRequirementsOverride !== undefined && - !LEGACY_PASSWORD_REQUIREMENTS_VALUES.has(passwordRequirementsOverride) - ) { - throw new Error( - `Failed reading config: Invalid auth.password_requirements: ${passwordRequirementsOverride}.`, - ); - } - const passwordRequirements = passwordRequirementsOverride ?? config.auth.password_requirements; // `LoadedProjectConfig.document` (the raw, pre-schema-default TOML `config` was decoded from) — // hoisted here (rather than inside the `authEnabled` block below, where it used to live) because // the captcha presence check right below needs it too. `undefined` for callers that haven't From c6b7cdcd8371ff5baa7230afbd2b27b2a01e4dfc Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 11:24:00 +0100 Subject: [PATCH 33/48] fix(cli): validate api.port before db start's already-running shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's flags.LoadConfig decodes api.port (a uint16, pkg/config/api.go:29) via viper's unconditional AutomaticEnv pass before AssertSupabaseDbIsRunning (internal/db/start/start.go:45-47), regardless of whether db start ever reads it. The native port's eager-validation battery already covered api.enabled/api.tls.enabled/api.max_rows but missed api.port, whose only other resolution (values.apiPort via legacyResolveLocalConfigValues) is gated behind the not-running branch — so a malformed SUPABASE_API_PORT was silently ignored whenever Postgres was already running, unlike Go (review: PR #6022). --- .../legacy/commands/db/start/start.handler.ts | 12 +++++++ .../db/start/start.integration.test.ts | 35 +++++++++++++++++-- 2 files changed, 45 insertions(+), 2 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 6343da0748..5522ff4798 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -385,6 +385,18 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega yield* wrapDbConfigOverride("api.max_rows", () => legacyEnvOverrideApiMaxRows(config.api.max_rows, projectEnvValues), ); + // Same gap for `api.port` — a plain `uint16` decoded in the SAME unconditional `Config.Load` + // pass as `api.max_rows` above (`pkg/config/api.go:29`), regardless of whether `db start` + // itself ever reads it: it never builds Kong or any other HTTP-facing container (this + // module's own header). It's only otherwise resolved as part of `values.apiPort` + // (`legacyResolveLocalConfigValues`), which — like `values.authJwtExpiry` above — this + // handler calls ONLY in the not-running branch, so a malformed `SUPABASE_API_PORT` would + // otherwise be silently accepted whenever Postgres is already running, unlike Go, which + // decodes it before `AssertSupabaseDbIsRunning` regardless (review: PRRT_kwDOErm0O86Vnmss). + // Discarded. + yield* wrapDbConfigOverride("api.port", () => + legacyEnvOverridePort("SUPABASE_API_PORT", config.api.port, "api.port", projectEnvValues), + ); // Same gap for `storage.vector.enabled`/`storage.s3_protocol.enabled`/`storage.analytics. // enabled` and their five plain-uint siblings (`storage.analytics.{max_namespaces,max_tables, 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 796ce388af..ea78d86fa4 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 @@ -812,6 +812,29 @@ describe("legacy db start", () => { }, ); + // Same gap, same shape, different field: `api.port` (a plain `uint16`, `pkg/config/api.go:29`) + // was only ever resolved as part of `values.apiPort` (`legacyResolveLocalConfigValues`), which + // this handler calls ONLY in the not-running branch — so a malformed override was silently + // ignored whenever Postgres was already running, unlike Go's `flags.LoadConfig`, which decodes + // it unconditionally before `AssertSupabaseDbIsRunning` (review: PRRT_kwDOErm0O86Vnmss). + it.live( + "fails with a typed config error on a malformed SUPABASE_API_PORT override even when Postgres is already running", + () => { + const { layer, child } = setup({ running: true }); + writeFileSync(join(tempRoot.current, "supabase", ".env"), "SUPABASE_API_PORT=not-a-port\n"); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const message = JSON.stringify(exit.cause); + expect(message).toContain("LegacyDbConfigLoadError"); + expect(message).toContain("api.port"); + } + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + }); + }, + ); + // Same gap, same shape, remaining root-level `auth.*` scalars: none of these is referenced // anywhere in `Config.Validate`'s `if c.Auth.Enabled` block (`pkg/config/config.go:1086-1153`), // so — like `auth.jwt_expiry` above — each was only ever resolved as part of @@ -822,8 +845,16 @@ describe("legacy db start", () => { it.live.each([ ["auth.enable_signup", "SUPABASE_AUTH_ENABLE_SIGNUP", "not-a-bool"], ["auth.enable_anonymous_sign_ins", "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", "not-a-bool"], - ["auth.enable_refresh_token_rotation", "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", "not-a-bool"], - ["auth.refresh_token_reuse_interval", "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", "not-a-uint"], + [ + "auth.enable_refresh_token_rotation", + "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", + "not-a-bool", + ], + [ + "auth.refresh_token_reuse_interval", + "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", + "not-a-uint", + ], ["auth.enable_manual_linking", "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", "not-a-bool"], ["auth.minimum_password_length", "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", "not-a-uint"], ["auth.password_requirements", "SUPABASE_AUTH_PASSWORD_REQUIREMENTS", "not-a-requirement"], From 96c013b1a8207220847ec6bc87c161de1d6fb686 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 12:22:21 +0100 Subject: [PATCH 34/48] fix(cli): validate db.settings.* before db start's already-running shortcut (review: PR #6022) Go's Config.Load decodes the entire db.settings struct (max_connections, track_commit_timestamp, etc.) via viper's SetEnvPrefix("SUPABASE") + AutomaticEnv() in the same unconditional pass as every other config field, before AssertSupabaseDbIsRunning. legacyResolveDbSettingsEnvOverrides was only otherwise invoked building legacyStartDatabase's postgresSpec, which never runs once the already-running short-circuit fires, so a malformed override like SUPABASE_DB_SETTINGS_MAX_CONNECTIONS=bogus was silently accepted whenever Postgres was already running. --- .../legacy/commands/db/start/start.handler.ts | 14 ++++++++++ .../db/start/start.integration.test.ts | 27 +++++++++++++++++++ 2 files changed, 41 insertions(+) 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 5522ff4798..c09d37645c 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -569,6 +569,20 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega legacyEnvOverrideRealtimeMaxHeaderLength(config.realtime.max_header_length, projectEnvValues), ); + // Same gap for the entire `db.settings.*` group (`max_connections`, + // `track_commit_timestamp`, `session_replication_role`, etc.) — Go's `Config.Load` decodes + // ALL of them via the same unconditional viper `SetEnvPrefix("SUPABASE")` + + // `AutomaticEnv()` pass as Realtime/Edge Runtime above (`pkg/config/config.go:749-756`), + // regardless of whether this eager battery itself ever reads them. + // `legacyResolveDbSettingsEnvOverrides` is only otherwise called below, building + // `legacyStartDatabase`'s `postgresSpec.db.settings`, which never runs on the + // already-running short-circuit right after this block — so a malformed override (e.g. + // `SUPABASE_DB_SETTINGS_MAX_CONNECTIONS=bogus`) would otherwise be silently accepted + // whenever Postgres is already running, unlike Go (review: PRRT_kwDOErm0O86Vn3Hw). + yield* wrapDbConfigOverride("db.settings", () => + legacyResolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues), + ); + // Go's AssertSupabaseDbIsRunning: if the db container is already up, print to // stderr and return nil (exit 0). Already native — see this module's header. Runs AFTER // the config load/validation above, matching Go's `start.Run` (`flags.LoadConfig` before 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 ea78d86fa4..819be77c4b 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 @@ -785,6 +785,33 @@ describe("legacy db start", () => { }, ); + // Same gap, same shape, whole `db.settings.*` group: `legacyResolveDbSettingsEnvOverrides` was + // only ever invoked building `legacyStartDatabase`'s `postgresSpec.db.settings`, which never + // runs once `legacyIsLocalDbRunning` short-circuits — so a malformed override was silently + // ignored whenever Postgres was already running, unlike Go's `flags.LoadConfig`, which decodes + // the entire `db.settings` struct unconditionally before `AssertSupabaseDbIsRunning` (review: + // PRRT_kwDOErm0O86Vn3Hw). + it.live.each([ + ["db.settings.max_connections", "SUPABASE_DB_SETTINGS_MAX_CONNECTIONS", "bogus"], + ["db.settings.track_commit_timestamp", "SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP", "bogus"], + ] as const)( + "fails with a typed config error on a malformed %s override even when Postgres is already running", + ([dottedFieldPath, envVar, envValue]) => { + const { layer, child } = setup({ running: true }); + writeFileSync(join(tempRoot.current, "supabase", ".env"), `${envVar}=${envValue}\n`); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const message = JSON.stringify(exit.cause); + expect(message).toContain("LegacyDbConfigLoadError"); + expect(message).toContain(dottedFieldPath); + } + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + }); + }, + ); + // Same gap, same shape, different field: `auth.jwt_expiry` (a plain `uint`, // `pkg/config/auth.go:155`) was only ever resolved as part of `values.authJwtExpiry` // (`legacyResolveLocalConfigValues`), which this handler calls ONLY in the not-running branch — From 2795e54bc90ef313cb5e5b91af72ba4faac3eb3f Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 14:13:07 +0100 Subject: [PATCH 35/48] fix(cli): validate db.health_timeout/storage.file_size_limit/realtime.enabled before db start's already-running shortcut (review: PR #6022) Go's flags.LoadConfig decodes SUPABASE_DB_HEALTH_TIMEOUT/ SUPABASE_STORAGE_FILE_SIZE_LIMIT/SUPABASE_REALTIME_ENABLED unconditionally before AssertSupabaseDbIsRunning (internal/db/start/start.go:45-47). The TS port only parsed these three inside legacyResolveDbBootstrapConfig, which never runs on db start's already-running short-circuit, so a malformed override was silently accepted whenever Postgres was already up. Add them to the eager validation battery, matching the same treatment already applied to edge_runtime/realtime/db.settings fields in earlier rounds of this PR. --- .../legacy/commands/db/start/start.handler.ts | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) 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 c09d37645c..875ae956d6 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -19,6 +19,7 @@ import { localDbContainerId, } from "../../../shared/legacy-docker-ids.ts"; import { + legacyEnvOverride, legacyEnvOverrideApiMaxRows, legacyEnvOverrideAuthPasswordRequirements, legacyEnvOverrideBool, @@ -44,7 +45,11 @@ import { legacyResolveLocalConfigValues, legacyResolveLocalJwks, } from "../../../shared/legacy-local-config-values.ts"; -import { legacyParseGoDuration } from "../../../shared/legacy-go-duration.ts"; +import { + legacyParseGoDuration, + legacyResolveHealthTimeoutSeconds, +} from "../../../shared/legacy-go-duration.ts"; +import { ramInBytes } from "../../../shared/legacy-size-units.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"; @@ -583,6 +588,45 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega legacyResolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues), ); + // Same gap for the fresh-volume one-shot setup jobs' own `realtime.enabled`/ + // `storage.file_size_limit` overrides and `db.health_timeout` — Go's `Config.Load` + // decodes all three in the same unconditional pass as the fields above (the bool via + // `mapstructure`'s `decodeBool`, `pkg/config/config.go:749-756`; the byte-size and + // duration via their own `UnmarshalText`/`StringToTimeDurationHookFunc` hooks, + // `config.go:39-49,580-586`). `legacyResolveDbBootstrapConfig` (below) is the only + // other place that parses `SUPABASE_REALTIME_ENABLED`/ + // `SUPABASE_STORAGE_FILE_SIZE_LIMIT`/`SUPABASE_DB_HEALTH_TIMEOUT`, and it never runs + // on the already-running short-circuit right after this block — so a malformed + // override (e.g. `SUPABASE_DB_HEALTH_TIMEOUT=bogus`) would otherwise be silently + // accepted whenever Postgres is already running, unlike Go (review: + // PRRT_kwDOErm0O86VoJnt). + yield* wrapDbConfigOverride("realtime.enabled", () => + legacyEnvOverrideBool( + "SUPABASE_REALTIME_ENABLED", + config.realtime.enabled, + "realtime.enabled", + projectEnvValues, + ), + ); + yield* wrapDbConfigOverride("storage.file_size_limit", () => + ramInBytes( + legacyEnvOverride( + "SUPABASE_STORAGE_FILE_SIZE_LIMIT", + config.storage.file_size_limit, + projectEnvValues, + ) ?? config.storage.file_size_limit, + ), + ); + yield* wrapDbConfigOverride("db.health_timeout", () => + legacyResolveHealthTimeoutSeconds( + legacyEnvOverride( + "SUPABASE_DB_HEALTH_TIMEOUT", + config.db.health_timeout, + projectEnvValues, + ) ?? config.db.health_timeout, + ), + ); + // Go's AssertSupabaseDbIsRunning: if the db container is already up, print to // stderr and return nil (exit 0). Already native — see this module's header. Runs AFTER // the config load/validation above, matching Go's `start.Run` (`flags.LoadConfig` before From 1cae8b850100bff1a8cfc19ee4b0a56edd21e7c0 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 14:13:18 +0100 Subject: [PATCH 36/48] fix(cli): discard one-shot setup-job stdout instead of buffering it (review: PR #6022) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's initSchema15 passes io.Discard as the stdout writer for the fresh-volume realtime/storage/auth migrate jobs (start.go:352), so their output never accumulates. The TS port ran these jobs through LegacyDockerRun.runCapture, which buffers the entire stdout stream into memory even though the caller only ever reads the exit code — a verbose or unusually large migration job's output could grow the CLI process's memory without bound. Switch to runStream with a no-op stdout sink, which discards each chunk as it arrives and keeps memory constant, matching Go. --- apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts | 10 ++++++++-- .../legacy/shared/db-bootstrap/db-setup.unit.test.ts | 10 +++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) 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 5e9c8554c8..666457f381 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -319,7 +319,7 @@ const legacyStartInitSchemaPre15 = Effect.fnUntraced(function* ( * non-zero exit fails with the same shape as Go's `error running container: `. * * Resolves `opts.image` itself, individually, right here — via `legacyEnsureImagesCached` - * (NOT `LegacyDockerRun.runCapture`'s own ambient-only resolver, which never sees + * (NOT `LegacyDockerRun.runStream`'s own ambient-only resolver, which never sees * `opts.projectEnvValues`) — immediately before running THIS job, matching Go's * `DockerRunJob` -> `DockerStart` -> `DockerResolveImageIfNotCached` (`docker.go:363-365`) * resolving each one-shot job's own image individually, sequentially, exactly where it's @@ -378,8 +378,14 @@ const legacyRunStartMigrateJob = Effect.fnUntraced(function* ( // resolver must not re-resolve it (it doesn't see `opts.projectEnvValues` at all). skipImageResolve: true, }; + // `runStream` (not `runCapture`) so stdout is actually discarded chunk-by-chunk as it + // arrives, matching Go's `io.Discard` writer for this job (`start.go:352`, and this + // function's own doc comment above) at constant memory — `runCapture` would instead + // buffer the ENTIRE stdout stream into `stdoutChunks` even though nothing here ever + // reads it, which a large/verbose migration job's output could grow without bound + // (review: Codex, PR #6022). const result = yield* docker - .runCapture(runOpts, { teeStderr: opts.debug }) + .runStream(runOpts, { onStdout: () => Effect.void, teeStderr: opts.debug }) .pipe(Effect.mapError((cause) => new LegacyStartDbSetupError({ message: cause.message }))); if (result.exitCode !== 0) { return yield* Effect.fail( 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 762a8c6207..dd471d6a83 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 @@ -75,7 +75,15 @@ function mockDockerRun(opts: { exitCode?: number } = {}) { stderr: "", }); }, - runStream: () => Effect.succeed({ exitCode: opts.exitCode ?? 0, stderr: "" }), + // `legacyRunStartMigrateJob` (`db-setup.ts`) discards stdout via `runStream` (not + // `runCapture`), matching Go's `io.Discard` writer for these one-shot jobs — this + // suite's `docker.runs`/`captureOptsCalls` assertions track THIS method's calls, not + // `runCapture`'s (which nothing under test still calls). + runStream: (runOpts, streamOpts) => { + runs.push(runOpts); + captureOptsCalls.push({ teeStderr: streamOpts.teeStderr }); + return Effect.succeed({ exitCode: opts.exitCode ?? 0, stderr: "" }); + }, }); return { layer, runs, captureOptsCalls }; } From 78b9d96829c59b6f2775bf2e22b535463428cdc1 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 15:21:29 +0100 Subject: [PATCH 37/48] fix(cli): validate storage.enabled before db start's already-running shortcut (review: PR #6022) Go's flags.LoadConfig decodes SUPABASE_STORAGE_ENABLED unconditionally before AssertSupabaseDbIsRunning (internal/db/start/start.go:45-47). The TS port only parsed it inside legacyResolveDbBootstrapConfig, which never runs on db start's already-running short-circuit, so a malformed override was silently accepted whenever Postgres was already up. Add it to the eager validation battery, matching the same treatment already applied to realtime.enabled/ storage.file_size_limit/db.health_timeout. --- .../legacy/commands/db/start/start.handler.ts | 20 +++++++++----- .../db/start/start.integration.test.ts | 27 +++++++++++++++++++ 2 files changed, 41 insertions(+), 6 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 875ae956d6..7056c41f86 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -589,17 +589,17 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega ); // Same gap for the fresh-volume one-shot setup jobs' own `realtime.enabled`/ - // `storage.file_size_limit` overrides and `db.health_timeout` — Go's `Config.Load` - // decodes all three in the same unconditional pass as the fields above (the bool via - // `mapstructure`'s `decodeBool`, `pkg/config/config.go:749-756`; the byte-size and - // duration via their own `UnmarshalText`/`StringToTimeDurationHookFunc` hooks, + // `storage.enabled`/`storage.file_size_limit` overrides and `db.health_timeout` — Go's + // `Config.Load` decodes all four in the same unconditional pass as the fields above (the + // bools via `mapstructure`'s `decodeBool`, `pkg/config/config.go:749-756`; the byte-size + // and duration via their own `UnmarshalText`/`StringToTimeDurationHookFunc` hooks, // `config.go:39-49,580-586`). `legacyResolveDbBootstrapConfig` (below) is the only - // other place that parses `SUPABASE_REALTIME_ENABLED`/ + // other place that parses `SUPABASE_REALTIME_ENABLED`/`SUPABASE_STORAGE_ENABLED`/ // `SUPABASE_STORAGE_FILE_SIZE_LIMIT`/`SUPABASE_DB_HEALTH_TIMEOUT`, and it never runs // on the already-running short-circuit right after this block — so a malformed // override (e.g. `SUPABASE_DB_HEALTH_TIMEOUT=bogus`) would otherwise be silently // accepted whenever Postgres is already running, unlike Go (review: - // PRRT_kwDOErm0O86VoJnt). + // PRRT_kwDOErm0O86VoJnt, PRRT_kwDOErm0O86VooCL). yield* wrapDbConfigOverride("realtime.enabled", () => legacyEnvOverrideBool( "SUPABASE_REALTIME_ENABLED", @@ -608,6 +608,14 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega projectEnvValues, ), ); + yield* wrapDbConfigOverride("storage.enabled", () => + legacyEnvOverrideBool( + "SUPABASE_STORAGE_ENABLED", + config.storage.enabled, + "storage.enabled", + projectEnvValues, + ), + ); yield* wrapDbConfigOverride("storage.file_size_limit", () => ramInBytes( legacyEnvOverride( 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 819be77c4b..63ee6fa7e0 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 @@ -812,6 +812,33 @@ describe("legacy db start", () => { }, ); + // Same gap, same shape, different field: `storage.enabled` (a plain `bool`, + // `pkg/config/storage.go:11`) was only ever resolved inside + // `legacyResolveDbBootstrapConfig` (gating the fresh-volume storage migrate job), which never + // runs once `legacyIsLocalDbRunning` short-circuits — so a malformed override was silently + // ignored whenever Postgres was already running, unlike Go's `flags.LoadConfig`, which decodes + // it unconditionally before `AssertSupabaseDbIsRunning` (review: PRRT_kwDOErm0O86VooCL). + it.live( + "fails with a typed config error on a malformed SUPABASE_STORAGE_ENABLED override even when Postgres is already running", + () => { + const { layer, child } = setup({ running: true }); + writeFileSync( + join(tempRoot.current, "supabase", ".env"), + "SUPABASE_STORAGE_ENABLED=not-a-bool\n", + ); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const message = JSON.stringify(exit.cause); + expect(message).toContain("LegacyDbConfigLoadError"); + expect(message).toContain("storage.enabled"); + } + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + }); + }, + ); + // Same gap, same shape, different field: `auth.jwt_expiry` (a plain `uint`, // `pkg/config/auth.go:155`) was only ever resolved as part of `values.authJwtExpiry` // (`legacyResolveLocalConfigValues`), which this handler calls ONLY in the not-running branch — From 6bd9981b9e8bf8ce2d8ded92813cbc8dbfd214dc Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 16:23:55 +0100 Subject: [PATCH 38/48] fix(cli): validate edge_runtime/network_restrictions/studio/local_smtp enabled before db start's already-running shortcut (review: PR #6022) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's Config.Load decodes edge_runtime.enabled, db.network_restrictions.enabled, studio.enabled, and local_smtp.enabled unconditionally in the same viper AutomaticEnv() pass as every other field this eager battery already covers (pkg/config/config.go:749-756). None of their containers are ever built by db start, but a malformed SUPABASE_*_ENABLED override must still fail before AssertSupabaseDbIsRunning, matching Go — these four were previously only validated via legacyResolveLocalConfigValues in the not-running branch, so a bad override was silently accepted whenever Postgres was already running. --- .../legacy/commands/db/start/start.handler.ts | 45 +++++++++++++++++++ .../db/start/start.integration.test.ts | 30 +++++++++++++ 2 files changed, 75 insertions(+) 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 7056c41f86..5e9d9af7eb 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -635,6 +635,51 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega ), ); + // Same gap for the remaining plain `*.enabled` booleans Go's `Config.Load` still decodes + // unconditionally in the same viper `AutomaticEnv()` pass as every field above (the bools via + // `mapstructure`'s `decodeBool`, `pkg/config/config.go:749-756`) but this battery hadn't yet + // reached: `edge_runtime.enabled` (the direct sibling of `edge_runtime.policy`/ + // `edge_runtime.inspector_port` above — same `edgeRuntime` struct, `pkg/config/config.go:279`), + // `db.network_restrictions.enabled` (`db.go:73`, non-pointer like `db.settings` above, unlike + // the presence-gated `db.ssl_enforcement`), `studio.enabled` (`config.go:260`), and + // `local_smtp.enabled` (Go's `Inbucket.Enabled`, `config.go:269`). None of their containers + // are ever built by `db start`, but a malformed override (e.g. + // `SUPABASE_EDGE_RUNTIME_ENABLED=bogus`) must still fail before `AssertSupabaseDbIsRunning`, + // matching every other field in this battery — so it would otherwise be silently accepted + // whenever Postgres is already running, unlike Go (review: PRRT_kwDOErm0O86Vo7zx). + yield* wrapDbConfigOverride("edge_runtime.enabled", () => + legacyEnvOverrideBool( + "SUPABASE_EDGE_RUNTIME_ENABLED", + config.edge_runtime.enabled, + "edge_runtime.enabled", + projectEnvValues, + ), + ); + yield* wrapDbConfigOverride("db.network_restrictions.enabled", () => + legacyEnvOverrideBool( + "SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED", + config.db.network_restrictions.enabled, + "db.network_restrictions.enabled", + projectEnvValues, + ), + ); + yield* wrapDbConfigOverride("studio.enabled", () => + legacyEnvOverrideBool( + "SUPABASE_STUDIO_ENABLED", + config.studio.enabled, + "studio.enabled", + projectEnvValues, + ), + ); + yield* wrapDbConfigOverride("local_smtp.enabled", () => + legacyEnvOverrideBool( + "SUPABASE_LOCAL_SMTP_ENABLED", + config.local_smtp.enabled, + "local_smtp.enabled", + projectEnvValues, + ), + ); + // Go's AssertSupabaseDbIsRunning: if the db container is already up, print to // stderr and return nil (exit 0). Already native — see this module's header. Runs AFTER // the config load/validation above, matching Go's `start.Run` (`flags.LoadConfig` before 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 63ee6fa7e0..1d51bafd88 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 @@ -839,6 +839,36 @@ describe("legacy db start", () => { }, ); + // Same gap, same shape, different fields: `edge_runtime.enabled`, `db.network_restrictions. + // enabled`, `studio.enabled`, and `local_smtp.enabled` were only ever resolved inside + // `legacyResolveLocalConfigValues` (the not-running branch's own config-values resolver, called + // below), which never runs once `legacyIsLocalDbRunning` short-circuits — so a malformed + // override was silently ignored whenever Postgres was already running, unlike Go's + // `flags.LoadConfig`, which decodes all four unconditionally before + // `AssertSupabaseDbIsRunning` (review: PRRT_kwDOErm0O86Vo7zx). + it.live.each([ + ["edge_runtime.enabled", "SUPABASE_EDGE_RUNTIME_ENABLED", "not-a-bool"], + ["db.network_restrictions.enabled", "SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED", "not-a-bool"], + ["studio.enabled", "SUPABASE_STUDIO_ENABLED", "not-a-bool"], + ["local_smtp.enabled", "SUPABASE_LOCAL_SMTP_ENABLED", "not-a-bool"], + ] as const)( + "fails with a typed config error on a malformed %s override even when Postgres is already running", + ([dottedFieldPath, envVar, envValue]) => { + const { layer, child } = setup({ running: true }); + writeFileSync(join(tempRoot.current, "supabase", ".env"), `${envVar}=${envValue}\n`); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const message = JSON.stringify(exit.cause); + expect(message).toContain("LegacyDbConfigLoadError"); + expect(message).toContain(dottedFieldPath); + } + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); + }); + }, + ); + // Same gap, same shape, different field: `auth.jwt_expiry` (a plain `uint`, // `pkg/config/auth.go:155`) was only ever resolved as part of `values.authJwtExpiry` // (`legacyResolveLocalConfigValues`), which this handler calls ONLY in the not-running branch — From 76cdae879f5681a5720fd6207f80ba4f52e77297 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 17:19:55 +0100 Subject: [PATCH 39/48] fix(cli): validate db.pooler.enabled eagerly in db start's config battery (review: PR #6022) Go's Config.Load decodes db.pooler.enabled unconditionally, same as its port/pool_mode/default_pool_size/max_client_conn siblings, but db start's eager-validation battery only force-decoded the latter four. A malformed SUPABASE_DB_POOLER_ENABLED override was silently accepted and Postgres would still start, unlike Go. --- apps/cli/src/legacy/commands/db/start/start.handler.ts | 10 +++++++++- .../legacy/commands/db/start/start.integration.test.ts | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) 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 5e9d9af7eb..eb95bc48b8 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -522,9 +522,17 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega // Same gap for Supavisor's pooler fields — Go's `Config.Load` applies // `SUPABASE_DB_POOLER_*` generically (`pkg/config/config.go:580-586`), regardless of whether - // `db start` itself ever reads them: it never builds the pooler container. All four throw + // `db start` itself ever reads them: it never builds the pooler container. All five throw // synchronously on a malformed override — wrapped so a bad value fails as a typed // `LegacyDbConfigLoadError` instead of an untyped Effect defect. Discarded. + yield* wrapDbConfigOverride("db.pooler.enabled", () => + legacyEnvOverrideBool( + "SUPABASE_DB_POOLER_ENABLED", + config.db.pooler.enabled, + "db.pooler.enabled", + projectEnvValues, + ), + ); yield* wrapDbConfigOverride("db.pooler.port", () => legacyEnvOverridePort( "SUPABASE_DB_POOLER_PORT", 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 1d51bafd88..02417132cc 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 @@ -736,6 +736,7 @@ describe("legacy db start", () => { ["local_smtp.port", "SUPABASE_LOCAL_SMTP_PORT", "not-a-port"], ["analytics.port", "SUPABASE_ANALYTICS_PORT", "not-a-port"], ["db.pooler.pool_mode", "SUPABASE_DB_POOLER_POOL_MODE", "not-a-mode"], + ["db.pooler.enabled", "SUPABASE_DB_POOLER_ENABLED", "not-a-bool"], ["auth.web3", "SUPABASE_AUTH_WEB3_SOLANA_ENABLED", "not-a-bool"], ["auth.oauth_server", "SUPABASE_AUTH_OAUTH_SERVER_ENABLED", "not-a-bool"], ["api.enabled", "SUPABASE_API_ENABLED", "not-a-bool"], From 69b0568531de1333b2bf34782e073e7f61008b2e Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 18:42:25 +0100 Subject: [PATCH 40/48] fix(cli): validate studio.port eagerly in db start's config battery (review: PR #6022) Go's Config.Validate rejects studio.port === 0 only when studio.enabled, before AssertSupabaseDbIsRunning. legacy-config-validate.ts marks this check L-only (D's LegacyConfigValidationInput has no studio section), so legacyCheckDbToml never catches it for db start. A malformed or zero SUPABASE_STUDIO_PORT was silently accepted whenever Postgres was already running. --- .../legacy/commands/db/start/start.handler.ts | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) 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 eb95bc48b8..14e5cfc297 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -671,7 +671,7 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega projectEnvValues, ), ); - yield* wrapDbConfigOverride("studio.enabled", () => + const studioEnabledForValidation = yield* wrapDbConfigOverride("studio.enabled", () => legacyEnvOverrideBool( "SUPABASE_STUDIO_ENABLED", config.studio.enabled, @@ -679,6 +679,33 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega projectEnvValues, ), ); + // Unlike every other field in this battery, `studio.port` isn't satisfied by a bare decode: + // Go's `Config.Validate` rejects `studio.port === 0`/`SUPABASE_STUDIO_PORT=0` ONLY when + // `studio.enabled` (`pkg/config/config.go:1070-1073`) — a Validate-level rule, still run + // unconditionally inside `flags.LoadConfig`, before `AssertSupabaseDbIsRunning`. This + // handler's very first line already runs `legacyCheckDbToml` (D's shared-validator caller), + // but `legacy-config-validate.ts`'s own scope table marks `studio.port`/`studio.api_url` as + // "L-only — D has no studio section", so — unlike the passkey/webauthn rule the comment above + // cites — this check does NOT already fail fast there. `values.studioPort` + // (`legacyResolveLocalConfigValues`) is the only other place this is checked, and this + // handler calls it ONLY in the not-running branch — so a malformed/zero `SUPABASE_STUDIO_PORT` + // would otherwise be silently accepted whenever Postgres is already running, unlike Go + // (review: PRRT_kwDOErm0O86VpoeR). + const studioPortForValidation = yield* wrapDbConfigOverride("studio.port", () => + legacyEnvOverridePort( + "SUPABASE_STUDIO_PORT", + config.studio.port, + "studio.port", + projectEnvValues, + ), + ); + if (studioEnabledForValidation && studioPortForValidation === 0) { + yield* Effect.fail( + new LegacyDbConfigLoadError({ + message: "Missing required field in config: studio.port", + }), + ); + } yield* wrapDbConfigOverride("local_smtp.enabled", () => legacyEnvOverrideBool( "SUPABASE_LOCAL_SMTP_ENABLED", From 656030751d5b7186ad798382706943d945b8133f Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 18:42:32 +0100 Subject: [PATCH 41/48] fix(cli): create the current-branch file with Go's 0644 mode (review: PR #6022) Go's utils.WriteFile writes supabase/.branches/_current_branch through afero.WriteFile(fsys, path, contents, 0644). Effect's writeFileString without an explicit mode falls back to Node's default (0666 before the umask), so under a permissive/group-writable umask this file could end up 0666/0664 instead of 0644, making project branch metadata writable by additional local users. --- apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 666457f381..8602713783 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -633,7 +633,13 @@ export const legacyStartInitCurrentBranch = Effect.fnUntraced(function* ( }), ), ); - yield* fs.writeFileString(currentBranchPath, "main").pipe( + // Go's `utils.WriteFile` writes through `afero.WriteFile(fsys, path, contents, 0644)` + // (`internal/utils/misc.go:280-286`) — an explicit mode, not the platform default. Effect's + // `writeFileString` falls back to Node's default file mode (`0666` before the umask) when no + // `mode` is given, so under a permissive/group-writable umask (`000`/`002`) this file could be + // created `0666`/`0664` instead of Go's `0644`, making project branch metadata writable by + // additional local users. + yield* fs.writeFileString(currentBranchPath, "main", { mode: 0o644 }).pipe( Effect.mapError( (error) => new LegacyStartDbSetupError({ From 26f71f1535eafc8cba287a15759ba7b08cd94c1e Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 18:42:46 +0100 Subject: [PATCH 42/48] fix(cli): only treat a confirmed not-found image inspect as a cache miss (review: PR #6022) Go's DockerResolveImageIfNotCached proceeds to the pull loop only when docker image inspect fails with a confirmed errdefs.IsNotFound; every other inspect error (an auth-plugin denial, an invalid reference, an API error, ...) returns immediately instead. This port's hasLocalImage had the polarity backwards: it only fast-failed on a daemon-unreachable message and treated every other inspect failure as a cache miss, sending it through the multi-registry pull loop instead - performing unauthorized network operations, delaying the failure by each retry backoff, and replacing the real inspect error with a pull aggregate. Flips the default to fail, and only treats a confirmed "no such image" (verified empirically against a real docker image inspect) as a cache miss. Updates the existing mocks that relied on the old "anything but daemon-unreachable is a cache miss" default to return a genuine not-found response, and adds a regression test for the new fail-fast branch. --- .../commands/start/start.integration.test.ts | 19 +++++++-- .../db-bootstrap/image-prepull.unit.test.ts | 21 ++++++++-- .../shared/legacy-docker-image-resolve.ts | 34 ++++++++++++---- .../legacy-docker-image-resolve.unit.test.ts | 40 +++++++++++++++++-- 4 files changed, 97 insertions(+), 17 deletions(-) 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 a6240138e6..aa6f9a9585 100644 --- a/apps/cli/src/legacy/commands/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts @@ -2706,8 +2706,16 @@ content_path = "./templates/custom_notice.html" const pullAttempts = new Map(); const base = defaultRoute(); const route = (args: ReadonlyArray): RouteResult => { - // Force every image through the pull path instead of the "already cached" shortcut. - if (args[0] === "image" && args[1] === "inspect") return { exitCode: 1 }; + // Force every image through the pull path instead of the "already cached" shortcut — + // a confirmed "no such image" (not merely a non-zero exit) is what tells + // `hasLocalImage` this is a genuine cache miss rather than some other inspect + // failure, which now fails fast instead of falling through to a pull. + if (args[0] === "image" && args[1] === "inspect") { + return { + exitCode: 1, + stderr: [`Error response from daemon: No such image: ${args[2]}`], + }; + } if (args[0] === "pull") { const image = args[1] ?? ""; if (image.includes("kong")) { @@ -2745,7 +2753,12 @@ content_path = "./templates/custom_notice.html" // unlike a failure inside `bringUp` itself (see the "rollback" describe block below). const base = defaultRoute(); const route = (args: ReadonlyArray): RouteResult => { - if (args[0] === "image" && args[1] === "inspect") return { exitCode: 1 }; + if (args[0] === "image" && args[1] === "inspect") { + return { + exitCode: 1, + stderr: [`Error response from daemon: No such image: ${args[2]}`], + }; + } if (args[0] === "pull") { const image = args[1] ?? ""; if (image.includes("kong")) { diff --git a/apps/cli/src/legacy/shared/db-bootstrap/image-prepull.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/image-prepull.unit.test.ts index 716eddc854..831f5824c6 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/image-prepull.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/image-prepull.unit.test.ts @@ -56,7 +56,12 @@ describe("legacyEnsureImagesCached", () => { const cached = image === "public.ecr.aws/supabase/postgres:15" || image === "public.ecr.aws/supabase/kong:3"; - return { exitCode: cached ? 0 : 1 }; + // A confirmed "no such image" (not merely a non-zero exit) is what tells + // `hasLocalImage` this candidate is a genuine cache miss rather than some other + // inspect failure, which now fails fast instead of falling through to a pull. + return cached + ? { exitCode: 0 } + : { exitCode: 1, stderr: `Error response from daemon: No such image: ${image}` }; } return { exitCode: 1 }; }); @@ -132,7 +137,12 @@ describe("legacyEnsureImagesCached", () => { "aggregates every failed image's message into one combined error", () => { const mock = mockSpawner((args) => { - if (args[0] === "image" && args[1] === "inspect") return { exitCode: 1 }; + if (args[0] === "image" && args[1] === "inspect") { + return { + exitCode: 1, + stderr: `Error response from daemon: No such image: ${args[2]}`, + }; + } if (args[0] === "pull") return { exitCode: 1, stderr: `no such image: ${args[1]}\n` }; return { exitCode: 1 }; }); @@ -153,7 +163,12 @@ describe("legacyEnsureImagesCached", () => { "appends the install hint once when a failure indicates the daemon is unreachable", () => { const mock = mockSpawner((args) => { - if (args[0] === "image" && args[1] === "inspect") return { exitCode: 1 }; + if (args[0] === "image" && args[1] === "inspect") { + return { + exitCode: 1, + stderr: `Error response from daemon: No such image: ${args[2]}`, + }; + } if (args[0] === "pull") { return { exitCode: 1, diff --git a/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts b/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts index d41d903c94..a66ba958e1 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts @@ -20,6 +20,14 @@ const spawnError = () => message: `failed to run docker. ${LEGACY_SUGGEST_DOCKER_INSTALL}`, }); +/** + * Docker's/Podman's "image not found" stderr shape for `image inspect` — the subprocess + * equivalent of Go's `errdefs.IsNotFound`, mirroring `db-bootstrap/container-lifecycle.ts`'s + * (private) `isVolumeNotFoundMessage` for `volume inspect`. Confirmed empirically: `docker image + * inspect ` prints `Error response from daemon: No such image: ` and exits 1. + */ +const isImageNotFoundMessage = (message: string): boolean => /no such image/iu.test(message); + const concat = (chunks: ReadonlyArray): Uint8Array => { const total = chunks.reduce((size, chunk) => size + chunk.length, 0); const bytes = new Uint8Array(total); @@ -89,14 +97,24 @@ export function legacyMakeDockerImageResolver( ); if (exitCode === 0) return true; const stderr = new TextDecoder().decode(concat(stderrChunks)).trim(); - if (legacyIsDockerDaemonUnreachable(stderr)) { - return yield* Effect.fail( - new LegacyDockerRunError({ - message: `failed to inspect docker image: ${stderr}\n\n${LEGACY_SUGGEST_DOCKER_INSTALL}`, - }), - ); - } - return false; + // Go's `DockerResolveImageIfNotCached` proceeds to the pull loop only when the inspect + // error is a confirmed `errdefs.IsNotFound` — any OTHER inspect error (daemon unreachable, + // an auth-plugin denial, an invalid reference, an API error, ...) returns immediately + // instead (`internal/utils/docker.go:326-334`). Defaulting anything-but-daemon-unreachable + // to "not cached" (as this used to) would instead send every OTHER inspect failure through + // the multi-registry pull loop too — performing unauthorized network operations, delaying + // the failure by each retry backoff, and replacing the real inspect error with a pull + // aggregate. So this must default to failing, and only treat a confirmed not-found as a + // cache miss — the inverse of the daemon-unreachable-only gate this replaced. + if (isImageNotFoundMessage(stderr)) return false; + const hint = legacyIsDockerDaemonUnreachable(stderr) + ? `\n\n${LEGACY_SUGGEST_DOCKER_INSTALL}` + : ""; + return yield* Effect.fail( + new LegacyDockerRunError({ + message: `failed to inspect docker image: ${stderr}${hint}`, + }), + ); }).pipe(Effect.scoped); const pullImage = ( diff --git a/apps/cli/src/legacy/shared/legacy-docker-image-resolve.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-image-resolve.unit.test.ts index 633dab0a9c..dbb9004b63 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-image-resolve.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-image-resolve.unit.test.ts @@ -12,12 +12,16 @@ const REGISTRY_ENV = "SUPABASE_INTERNAL_IMAGE_REGISTRY"; function mockSpawner( pullResults: ReadonlyArray<{ readonly exitCode: number; readonly stderr?: string }>, - // Defaults to a non-zero exit with empty stderr, which forces every + // Defaults to a confirmed "not found" inspect response, which forces every // candidate through the pull path instead of the already-cached shortcut — // the behavior both existing pull-retry tests below rely on. A test // covering `hasLocalImage`'s own fail-fast behavior overrides this to - // simulate a daemon-down `image inspect` response instead. - imageInspectResult: { readonly exitCode: number; readonly stderr?: string } = { exitCode: 1 }, + // simulate a daemon-down (or other non-not-found) `image inspect` response + // instead. + imageInspectResult: { readonly exitCode: number; readonly stderr?: string } = { + exitCode: 1, + stderr: "Error response from daemon: No such image: placeholder", + }, ) { const pulls: Array = []; const imageInspectOptions: Array = []; @@ -196,4 +200,34 @@ describe("legacyMakeDockerImageResolver", () => { } }), ); + + it.effect( + "fails fast on a non-not-found image inspect error (e.g. an auth-plugin denial) without ever attempting a pull", + () => + Effect.gen(function* () { + const previousRegistry = process.env[REGISTRY_ENV]; + process.env[REGISTRY_ENV] = "docker.io"; + + try { + // Go's `DockerResolveImageIfNotCached` treats ONLY a confirmed `errdefs.IsNotFound` + // as a cache miss; every other inspect error — this is neither a "no such image" nor + // a daemon-unreachable message — returns immediately instead of falling through to + // the pull loop (`internal/utils/docker.go:326-334`). + const authPluginDenialStderr = + "Error response from daemon: authorization denied by plugin AuthZPlugin: no policy matched"; + const mock = mockSpawner([], { exitCode: 1, stderr: authPluginDenialStderr }); + const resolve = legacyMakeDockerImageResolver(mock.spawner); + + const error = yield* resolve("supabase/postgres:17.6.1.138").pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyDockerRunError); + expect(error.message).toContain(authPluginDenialStderr); + expect(error.message).not.toContain(LEGACY_SUGGEST_DOCKER_INSTALL); + expect(mock.pulls).toHaveLength(0); + } finally { + if (previousRegistry === undefined) delete process.env[REGISTRY_ENV]; + else process.env[REGISTRY_ENV] = previousRegistry; + } + }), + ); }); From 0f2b6d99c9c20618d126a76c6be7d3907e53e843 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Sat, 1 Aug 2026 18:42:55 +0100 Subject: [PATCH 43/48] fix(cli): expand matched seed directories to their .sql files (review: PR #6022) Go's GetPendingSeeds resolves db.seed.sql_paths through locals.SQLFiles(fsys) - the same Glob.SQLFiles method db.migrations.schema_paths resolves through - which expands a matched directory to its sorted, regular .sql files recursively. This port's resolveSeedFiles instead used the plainer Glob.Files-equivalent (legacyGlobPattern) with no directory expansion, so a directory seed entry (e.g. sql_paths = ["./seeds"]) resolved to the directory itself and then failed being read as a seed file. Hoists the directory-walk helper (previously private to legacy-migrate-and-seed.ts) into legacy-glob.ts so both callers share one Glob.SQLFiles port, and reuses it in legacy-seed.ts's resolveSeedFiles - preserving its existing warn-only (never hard-fail) error handling, which differs from the schema-paths caller's fail-when-empty behavior. --- apps/cli/src/legacy/shared/legacy-glob.ts | 48 ++++++++++++++++ .../legacy/shared/legacy-migrate-and-seed.ts | 43 +------------- apps/cli/src/legacy/shared/legacy-seed.ts | 56 ++++++++++++++++--- .../legacy/shared/legacy-seed.unit.test.ts | 34 ++++++++++- 4 files changed, 130 insertions(+), 51 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-glob.ts b/apps/cli/src/legacy/shared/legacy-glob.ts index bd83f6e39c..256f4e4977 100644 --- a/apps/cli/src/legacy/shared/legacy-glob.ts +++ b/apps/cli/src/legacy/shared/legacy-glob.ts @@ -1,4 +1,5 @@ import { Effect, type FileSystem, type Path } from "effect"; +import type { PlatformError } from "effect/PlatformError"; import { legacyPathMatch } from "./legacy-path-match.ts"; @@ -103,3 +104,50 @@ export const legacyGlobPattern = ( } return result; }); + +/** + * Port of Go's `walkMatchedDir` (`pkg/config/config.go:194-207`, called by `Glob.SQLFiles` on + * every directory match): a manual, non-recursing-through-`{recursive: true}` walk, because + * Go's `fs.WalkDir` never follows a symlinked `DirEntry` — its `IsDir()` is false for a + * symlink regardless of target, so `WalkDir` neither descends into a symlinked subdirectory + * nor lets `entry.Type().IsRegular()` (the `.sql`-file inclusion check) pass a symlinked file. + * The `FileSystem` service exposes no non-following `lstat`; `fs.readLink` succeeding on a + * path IS Effect's only non-following "is this a symlink" primitive, so it stands in for that + * check at each level, both for recursion (a symlinked directory is skipped, not walked) and + * for file inclusion (a symlinked `.sql` file is skipped, not applied) — using `fs.stat` + * (which follows) here instead would silently include a symlink's target, unlike Go. Returns + * paths relative to `dir`; the caller does the single final sort over the whole aggregate, + * matching Go's one `sort.Strings(files)` after the complete walk rather than per-directory. + * + * Hoisted here (from `legacy-migrate-and-seed.ts`, the first caller, for `db.migrations. + * schema_paths`) once `legacy-seed.ts`'s `db.seed.sql_paths` resolution became a second + * caller — Go's `Glob.SQLFiles` (`pkg/config/config.go:122-128`) is the SAME method both + * config fields resolve through (`GetPendingSeeds` calls `locals.SQLFiles(fsys)` exactly like + * `applySchemaFiles`'s `SchemaPaths.SQLFiles(fsys)`), so a matched seed directory must expand + * to its sorted regular `.sql` files exactly like a matched schema-path directory does. + */ +export const legacyWalkSqlFiles = ( + fs: FileSystem.FileSystem, + dir: string, + relativePrefix: string, +): Effect.Effect, PlatformError> => + Effect.gen(function* () { + const names = yield* fs.readDirectory(dir); + const files: Array = []; + for (const name of names) { + const absChild = `${dir}/${name}`; + const relChild = relativePrefix.length === 0 ? name : `${relativePrefix}/${name}`; + const isSymlink = yield* fs.readLink(absChild).pipe( + Effect.map(() => true), + Effect.orElseSucceed(() => false), + ); + if (isSymlink) continue; + const info = yield* fs.stat(absChild).pipe(Effect.orElseSucceed(() => undefined)); + if (info?.type === "Directory") { + files.push(...(yield* legacyWalkSqlFiles(fs, absChild, relChild))); + } else if (info?.type === "File" && relChild.endsWith(".sql")) { + files.push(relChild); + } + } + return files; + }); diff --git a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts index ce3fad0905..5f9c3c0683 100644 --- a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts +++ b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts @@ -1,10 +1,9 @@ import { Effect, type FileSystem, type Path, Result } from "effect"; -import type { PlatformError } from "effect/PlatformError"; import { Output } from "../../shared/output/output.service.ts"; import { legacyBold } from "./legacy-colors.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; -import { legacyGlobPattern, legacyResolveUnderWorkdir } from "./legacy-glob.ts"; +import { legacyGlobPattern, legacyResolveUnderWorkdir, legacyWalkSqlFiles } from "./legacy-glob.ts"; import { LegacyMigrationApplyError, legacyApplyMigrationFile, @@ -34,46 +33,6 @@ export interface LegacyMigrateAndSeedConfig { readonly schemaPaths: ReadonlyArray; } -/** - * Port of Go's `walkMatchedDir` (`pkg/config/config.go:194-207`, called by `Glob.SQLFiles` on - * every directory match): a manual, non-recursing-through-`{recursive: true}` walk, because - * Go's `fs.WalkDir` never follows a symlinked `DirEntry` — its `IsDir()` is false for a - * symlink regardless of target, so `WalkDir` neither descends into a symlinked subdirectory - * nor lets `entry.Type().IsRegular()` (the `.sql`-file inclusion check) pass a symlinked file. - * The `FileSystem` service exposes no non-following `lstat`; `fs.readLink` succeeding on a - * path IS Effect's only non-following "is this a symlink" primitive, so it stands in for that - * check at each level, both for recursion (a symlinked directory is skipped, not walked) and - * for file inclusion (a symlinked `.sql` file is skipped, not applied) — using `fs.stat` - * (which follows) here instead would silently include a symlink's target, unlike Go. Returns - * paths relative to `dir`; the caller does the single final sort over the whole aggregate, - * matching Go's one `sort.Strings(files)` after the complete walk rather than per-directory. - */ -const legacyWalkSqlFiles = ( - fs: FileSystem.FileSystem, - dir: string, - relativePrefix: string, -): Effect.Effect, PlatformError> => - Effect.gen(function* () { - const names = yield* fs.readDirectory(dir); - const files: Array = []; - for (const name of names) { - const absChild = `${dir}/${name}`; - const relChild = relativePrefix.length === 0 ? name : `${relativePrefix}/${name}`; - const isSymlink = yield* fs.readLink(absChild).pipe( - Effect.map(() => true), - Effect.orElseSucceed(() => false), - ); - if (isSymlink) continue; - const info = yield* fs.stat(absChild).pipe(Effect.orElseSucceed(() => undefined)); - if (info?.type === "Directory") { - files.push(...(yield* legacyWalkSqlFiles(fs, absChild, relChild))); - } else if (info?.type === "File" && relChild.endsWith(".sql")) { - files.push(relChild); - } - } - return files; - }); - /** * Port of Go's `Glob.SQLFiles` (`pkg/config/config.go:122-128` → `files`/`walkMatchedDir`), * called with zero `GlobOption`s exactly like `applySchemaFiles`'s own call diff --git a/apps/cli/src/legacy/shared/legacy-seed.ts b/apps/cli/src/legacy/shared/legacy-seed.ts index 6bc7805a75..53247f0dff 100644 --- a/apps/cli/src/legacy/shared/legacy-seed.ts +++ b/apps/cli/src/legacy/shared/legacy-seed.ts @@ -1,9 +1,9 @@ import { createHash } from "node:crypto"; -import { Data, Effect, FileSystem, Path } from "effect"; +import { Data, Effect, FileSystem, Path, Result } from "effect"; import { Output } from "../../shared/output/output.service.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; -import { legacyGlobPattern, legacyResolveUnderWorkdir } from "./legacy-glob.ts"; +import { legacyGlobPattern, legacyResolveUnderWorkdir, legacyWalkSqlFiles } from "./legacy-glob.ts"; import { legacyCreateSeedTable, legacyReadSeedTable, @@ -34,7 +34,22 @@ interface LegacyPendingSeed { readonly dirty: boolean; } -/** Go's `config.Glob.Files`: glob each pattern, sort, dedup; warn on bad/no-match. */ +/** + * Port of Go's `Glob.SQLFiles` (`pkg/config/config.go:122-128` → `files`/`walkMatchedDir`) as + * called by `GetPendingSeeds` (`locals.SQLFiles(fsys)`, `pkg/migration/seed.go:35`) — the SAME + * method `db.migrations.schema_paths` resolves through (`legacyResolveSchemaPathFiles` in + * `legacy-migrate-and-seed.ts`), not the plainer `Glob.Files`: each pattern is glob-matched via + * {@link legacyGlobPattern}, and a matched DIRECTORY is expanded to its sorted, regular `.sql` + * files, recursively (via the shared {@link legacyWalkSqlFiles}), rather than kept as-is — a + * plain glob match (e.g. `[db.seed] sql_paths = ["./seeds"]` with no metacharacters) previously + * resolved a directory entry to itself, which then failed reading it as a seed file. A matched + * plain file is kept as-is, even a non-`.sql` one, matching `expandDir`'s `IsDir()`-only gate. + * + * Unlike `legacyResolveSchemaPathFiles`, a bad pattern, an empty match, or a directory-walk + * failure is NEVER a hard failure here — `GetPendingSeeds` only ever warns + * (`fmt.Fprintln(os.Stderr, "WARN:", err)`) and proceeds with whatever it already collected, + * even if that ends up empty (`len(locals) == 0` just means no pending seeds, not an error). + */ const resolveSeedFiles = ( fs: FileSystem.FileSystem, path: Path.Path, @@ -57,14 +72,39 @@ const resolveSeedFiles = ( const matches = [...(yield* legacyGlobPattern(fs, path, workdir, pattern))].sort(); if (matches.length === 0) unmatched.push(`no files matched pattern: ${pattern}`); for (const match of matches) { - if (!seen.has(match)) { - seen.add(match); - result.push(match); + const absMatch = legacyResolveUnderWorkdir(path, workdir, match); + const statResult = yield* fs.stat(absMatch).pipe(Effect.result); + if (Result.isFailure(statResult)) { + unmatched.push(`failed to stat matched file: ${match}`); + continue; + } + if (statResult.success.type !== "Directory") { + if (!seen.has(match)) { + seen.add(match); + result.push(match); + } + continue; + } + // Go's `walkMatchedDir`: recursively list the matched directory, keep only regular + // `.sql` files, sorted (a global sort over the full relative-to-fsys-root path, not + // per-directory — matches `sort.Strings(files)` running once after the whole walk). + const namesResult = yield* legacyWalkSqlFiles(fs, absMatch, "").pipe(Effect.result); + if (Result.isFailure(namesResult)) { + unmatched.push(`failed to walk matched directory: ${match}`); + continue; + } + const sqlRelative = [...namesResult.success].sort(); + for (const relative of sqlRelative) { + const relativeToWorkdir = `${match}/${relative}`; + if (!seen.has(relativeToWorkdir)) { + seen.add(relativeToWorkdir); + result.push(relativeToWorkdir); + } } } } - // Go collects all glob errors into one `errors.Join` and prints a single - // `WARN: ` line (`config.Glob.Files` → `seed.go:37`), not one per pattern. + // Go collects all glob/walk errors into one `errors.Join` and prints a single + // `WARN: ` line (`Glob.SQLFiles` → `seed.go:35-36`), not one per pattern. if (unmatched.length > 0) yield* output.raw(`WARN: ${unmatched.join("\n")}\n`, "stderr"); return result; }); diff --git a/apps/cli/src/legacy/shared/legacy-seed.unit.test.ts b/apps/cli/src/legacy/shared/legacy-seed.unit.test.ts index 671d5ea5f7..e3f672ef51 100644 --- a/apps/cli/src/legacy/shared/legacy-seed.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-seed.unit.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; @@ -78,4 +78,36 @@ describe("legacyApplySeedFiles seed glob", () => { ), ); }); + + it.effect( + "expands a matched directory to its sorted, regular .sql files (Go's Glob.SQLFiles)", + () => { + // Go's `GetPendingSeeds` calls `locals.SQLFiles(fsys)` — the SAME `Glob.SQLFiles` method + // `db.migrations.schema_paths` resolves through — which expands a directory match to its + // recursively-walked, sorted `.sql` files rather than treating the directory itself as a + // seed file. + const dir = mkdtempSync(join(tmpdir(), "legacy-seed-")); + mkdirSync(join(dir, "seeds")); + writeFileSync(join(dir, "seeds", "b.sql"), "insert into t values (2);"); + writeFileSync(join(dir, "seeds", "a.sql"), "insert into t values (1);"); + writeFileSync(join(dir, "seeds", "README.md"), "not a seed file"); + const { session, queries } = fakeSession(); + const out = mockOutput(); + return run(session, dir, ["seeds"], out).pipe( + Effect.tap(() => + Effect.sync(() => { + const upserts = queries.filter((q) => + q.sql.includes("INSERT INTO supabase_migrations.seed_files"), + ); + expect(upserts.map((q) => q.params?.[0])).toEqual(["seeds/a.sql", "seeds/b.sql"]); + expect(out.rawChunks.map((c) => c.text)).toEqual([ + "Seeding data from seeds/a.sql...\n", + "Seeding data from seeds/b.sql...\n", + ]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); }); From f9b5ddaad29c5724023390e585315d459c59a668 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 3 Aug 2026 14:15:49 +0100 Subject: [PATCH 44/48] fix(cli): pass debug through legacyRollbackStart at db start's call site Merging develop added a debug parameter to legacyRollbackStart (shared/ db-bootstrap/rollback.ts) for supabase start's own call sites; db start's call site (added on this branch) needed the same update to keep types:check green. Also re-runs oxfmt on go-cli-porting-status.md after merge conflict resolution. --- apps/cli/docs/go-cli-porting-status.md | 6 +++--- apps/cli/src/legacy/commands/db/start/start.handler.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index b6382d7b6e..b201950fb4 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -86,8 +86,8 @@ These commands exist in the TS CLI today but have no direct top-level equivalent | `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). Pipeline-incompatible statements (`CREATE INDEX CONCURRENTLY`, `VACUUM`, …) run standalone outside the batch transaction — from the closed Go PR supabase/cli#5156, also ported into `apps/cli-go` (CLI-1989 ruling). | -| `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). Pipeline-incompatible statements run standalone outside the batch transaction, same as `db push` (closed Go PR supabase/cli#5156, CLI-1989 ruling). | +| `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). Pipeline-incompatible statements (`CREATE INDEX CONCURRENTLY`, `VACUUM`, …) run standalone outside the batch transaction — from the closed Go PR supabase/cli#5156, also ported into `apps/cli-go` (CLI-1989 ruling). | +| `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). Pipeline-incompatible statements run standalone outside the batch transaction, same as `db push` (closed Go PR supabase/cli#5156, CLI-1989 ruling). | | `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. | @@ -121,7 +121,7 @@ These commands exist in the TS CLI today but have no direct top-level equivalent | `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 (pipeline-incompatible statements run standalone — closed Go PR supabase/cli#5156, ported into `apps/cli-go`, CLI-1989 ruling); `--include-all` for out-of-order; defaults to `--local`. Does not seed (matches Go). | +| `migration up` | `ported` | `legacy/commands/migration/up/` | `n/a` | `n/a` | Native TS port. Computes pending migrations, upserts `[db.vault]`, applies each transactionally (pipeline-incompatible statements run standalone — closed Go PR supabase/cli#5156, ported into `apps/cli-go`, CLI-1989 ruling); `--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). | 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 14e5cfc297..f4fddf5907 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -928,7 +928,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), ), ); From f74d5cd987b45696f0d6c8121ad824acba19f8a7 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Mon, 3 Aug 2026 15:10:05 +0100 Subject: [PATCH 45/48] 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"); }), ); From e899e290c30c0d859a30b2980017178ef6cd340c Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 6 Aug 2026 16:40:09 +0100 Subject: [PATCH 46/48] fix(cli): run db schema declarative's local reset in-process (CLI-2062) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hoists db reset's local-recreate composition into a shared legacyResetLocalDatabase (legacy/shared/db-bootstrap/reset-local-database.ts) and rewires db schema declarative's smart-target local-reset prompt and db schema sync's failed-apply recovery reset to call it in-process, instead of shelling out to a second supabase-go child via LegacyDeclarativeSeam .execInherit (now removed). Go's own db_schema_declarative.go calls reset.Run in-process too, sharing the outer command's PersistentPostRun — the removed subprocess design instead fired a second, independent telemetry/linked-project-cache cycle from the child process's own Execute(), which this closes. reset.handler.ts's own cfg.isLocal branch becomes a thin wrapper around the extracted function, keeping only version/seed-flags resolution and the JSON envelope. await-storage-ready.ts moves alongside it into db-bootstrap/ since it now has a second caller. --- apps/cli/docs/go-cli-porting-status.md | 90 +++---- .../commands/db/diff/diff.integration.test.ts | 1 - .../commands/db/pull/pull.integration.test.ts | 1 - .../legacy/commands/db/reset/SIDE_EFFECTS.md | 32 ++- .../legacy/commands/db/reset/reset.errors.ts | 10 - .../legacy/commands/db/reset/reset.handler.ts | 184 ++----------- .../db/reset/reset.integration.test.ts | 6 +- ...eclarative.orchestrate.integration.test.ts | 1 - .../declarative/declarative.smart-target.ts | 33 +-- .../declarative/generate/SIDE_EFFECTS.md | 2 +- .../generate/generate.integration.test.ts | 124 +++++++-- .../declarative/generate/generate.layers.ts | 7 +- .../schema/declarative/sync/SIDE_EFFECTS.md | 7 +- .../schema/declarative/sync/sync.handler.ts | 31 +-- .../declarative/sync/sync.integration.test.ts | 113 ++++++-- .../db/schema/declarative/sync/sync.layers.ts | 8 +- .../db/shared/legacy-pgdelta.seam.layer.ts | 25 -- .../db/shared/legacy-pgdelta.seam.service.ts | 14 - .../db-bootstrap}/await-storage-ready.ts | 15 +- .../await-storage-ready.unit.test.ts | 2 +- .../db-bootstrap/recreate-local-database.ts | 2 +- .../db-bootstrap/reset-local-database.ts | 244 ++++++++++++++++++ apps/cli/tests/helpers/legacy-local-reset.ts | 177 +++++++++++++ apps/cli/tests/helpers/legacy-mocks.ts | 20 ++ 24 files changed, 780 insertions(+), 369 deletions(-) rename apps/cli/src/legacy/{commands/db/reset => shared/db-bootstrap}/await-storage-ready.ts (81%) rename apps/cli/src/legacy/{commands/db/reset => shared/db-bootstrap}/await-storage-ready.unit.test.ts (98%) create mode 100644 apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts create mode 100644 apps/cli/tests/helpers/legacy-local-reset.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 8c15692431..83c9bf4b0f 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. `--use-pg-schema` is deprecated (CLI-1960: TS-only stderr warning + `--help` note) in favor of the pg-delta engine or the default migra engine — it wraps the in-process `stripe/pg-schema-diff` Go library, which has no TS/container equivalent, so it is a documented keep-in-Go exception, not a pending port. It will be the sole remaining Go delegation once `--use-pgadmin`, the `db __shadow`/`db __db-bootstrap` seams, and the other in-flight M9 issues are done. | -| `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, deprecated in favor of `--declarative` (CLI-1957) — it needs a TS PostgreSQL DDL parser for Go's `format.WriteStructuredSchemas` that has no equivalent in this repo, and `--declarative` already delivers the same per-object schema split via pg-delta catalog introspection. | -| `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). Pipeline-incompatible statements (`CREATE INDEX CONCURRENTLY`, `VACUUM`, …) run standalone outside the batch transaction — from the closed Go PR supabase/cli#5156, also ported into `apps/cli-go` (CLI-1989 ruling). | -| `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. The best-effort pg-delta migrations-catalog cache warmup (`pgcache.TryCacheMigrationsCatalog`, reachable via `SetupLocalDatabase` on the PG15 recreate path) IS ported too, same as `db start`. Pipeline-incompatible statements run standalone outside the batch transaction, same as `db push` (closed Go PR supabase/cli#5156, CLI-1989 ruling). | -| `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 (including its best-effort pg-delta migrations-catalog warmup), 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 (pipeline-incompatible statements run standalone — closed Go PR supabase/cli#5156, ported into `apps/cli-go`, CLI-1989 ruling); `--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/shared/legacy-test-db.*` (command definitions in `legacy/commands/test/db/` and its hidden `db test` alias, `legacy/commands/db/test/`) | `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 is honored. `[images]` config override not modeled (documented divergence). | -| `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. `--use-pg-schema` is deprecated (CLI-1960: TS-only stderr warning + `--help` note) in favor of the pg-delta engine or the default migra engine — it wraps the in-process `stripe/pg-schema-diff` Go library, which has no TS/container equivalent, so it is a documented keep-in-Go exception, not a pending port. It will be the sole remaining Go delegation once `--use-pgadmin`, the `db __shadow`/`db __db-bootstrap` seams, and the other in-flight M9 issues are done. | +| `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, deprecated in favor of `--declarative` (CLI-1957) — it needs a TS PostgreSQL DDL parser for Go's `format.WriteStructuredSchemas` that has no equivalent in this repo, and `--declarative` already delivers the same per-object schema split via pg-delta catalog introspection. | +| `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). Pipeline-incompatible statements (`CREATE INDEX CONCURRENTLY`, `VACUUM`, …) run standalone outside the batch transaction — from the closed Go PR supabase/cli#5156, also ported into `apps/cli-go` (CLI-1989 ruling). | +| `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). The local-reset composition is hoisted into `legacy/shared/db-bootstrap/reset-local-database.ts`'s `legacyResetLocalDatabase` (CLI-2062); `db schema declarative`'s smart-target and `db schema sync` now call it in-process too, instead of the removed `LegacyDeclarativeSeam.execInherit` seam that used to shell out to a second `supabase-go db reset --local` child. The best-effort pg-delta migrations-catalog cache warmup (`pgcache.TryCacheMigrationsCatalog`, reachable via `SetupLocalDatabase` on the PG15 recreate path) IS ported too, same as `db start`. Pipeline-incompatible statements run standalone outside the batch transaction, same as `db push` (closed Go PR supabase/cli#5156, CLI-1989 ruling). | +| `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 (including its best-effort pg-delta migrations-catalog warmup), 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 (pipeline-incompatible statements run standalone — closed Go PR supabase/cli#5156, ported into `apps/cli-go`, CLI-1989 ruling); `--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/shared/legacy-test-db.*` (command definitions in `legacy/commands/test/db/` and its hidden `db test` alias, `legacy/commands/db/test/`) | `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 is honored. `[images]` config override not modeled (documented divergence). | +| `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/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts index 1b784983bc..b73557e9b0 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -69,7 +69,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { exportCatalogCalls.push({ mode, projectRef }); return Effect.succeed("supabase/.temp/pgdelta/migrations.json"); }, - execInherit: () => Effect.succeed(0), ensureLocalDatabaseStarted: () => Effect.void, ensureLocalPostgresImageCurrent: () => Effect.void, provisionShadow: ({ mode, targetLocal, usePgDelta, projectRef }) => { diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index f22dd4e230..128205371d 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -117,7 +117,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { const removedContainers: string[] = []; const seam = Layer.succeed(LegacyDeclarativeSeam, { exportCatalog: () => Effect.succeed("supabase/.temp/pgdelta/x.json"), - execInherit: () => Effect.succeed(0), ensureLocalDatabaseStarted: () => Effect.void, ensureLocalPostgresImageCurrent: () => Effect.void, provisionShadow: ({ mode, usePgDelta, targetLocal, projectRef }) => { 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 18a0c5b274..8591bad33f 100644 --- a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md @@ -10,19 +10,21 @@ container-recreate composition (`legacy/shared/db-bootstrap/recreate-local-datab 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 +(`legacy/shared/db-bootstrap/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. +The whole local-reset composition is hoisted into `legacy/shared/db-bootstrap/ +reset-local-database.ts`'s `legacyResetLocalDatabase` (CLI-2062), which this +handler's own `cfg.isLocal` branch calls as a thin wrapper (keeping only version/ +seed-flags resolution and the JSON envelope, which are specific to this top-level +command). `db schema declarative`'s smart-target local-reset prompt and `db schema +sync`'s failed-apply recovery reset both call the SAME function in-process now, +instead of shelling out to a second `supabase-go` child through the previously +removed `LegacyDeclarativeSeam.execInherit` seam — see those commands' own +`SIDE_EFFECTS.md`. ## Files Read @@ -211,9 +213,11 @@ path has no confirmation prompt. for it (see `db-setup.ts`'s own header for the exact gate). The write is silent on success; a failure only warns on stderr and never fails the reset, matching Go. - `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. +- `db schema declarative`/`db schema sync`'s own local-reset paths now call + `legacyResetLocalDatabase` in-process too (CLI-2062) — the previous scope boundary + (those two commands shelling out to a second `supabase-go` child via the now-removed + `LegacyDeclarativeSeam.execInherit`) is closed. That in-process call collapses to a + single telemetry/linked-project-cache finalizer cycle (the outer `db schema +declarative`/`sync` command's own), matching Go's single-process `reset.Run` call — + the removed subprocess design used to fire a second, independent one from the child + process's own `Execute()`. diff --git a/apps/cli/src/legacy/commands/db/reset/reset.errors.ts b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts index 80d2cba332..43dba0f603 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.errors.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts @@ -56,16 +56,6 @@ export class LegacyDbResetApplyError extends Data.TaggedError("LegacyDbResetAppl readonly message: string; }> {} -/** - * The local database container is not running. Byte-matches Go's - * `utils.ErrNotRunning` (`internal/utils/misc.go:116`), `"supabase start - * is not running."`, returned by `AssertSupabaseDbIsRunning` before the local - * reset (`internal/db/reset/reset.go:57`). - */ -export class LegacyDbResetNotRunningError extends Data.TaggedError("LegacyDbResetNotRunningError")<{ - readonly message: string; -}> {} - /** * `--last` was given a negative value. Go declares `--last` as an unsigned flag * (`UintVar`, `cmd/db.go`), so cobra rejects a negative at parse time. Byte-matches 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 9b3d54d0d2..d00c8ff428 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts @@ -1,13 +1,7 @@ import { Effect, FileSystem, Option, Path } from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; -import { detectGitBranch } from "../../../../shared/git/git-branch.ts"; -import { - LegacyDebugFlag, - LegacyNetworkIdFlag, - LegacyDnsResolverFlag, -} from "../../../../shared/legacy/global-flags.ts"; +import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; import { legacyResolveExperimentalWithProjectEnv, legacyResolveYesWithProjectEnv, @@ -15,15 +9,11 @@ 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 { legacyResetLocalDatabase } from "../../../shared/db-bootstrap/reset-local-database.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import { legacyCheckDbToml, @@ -44,7 +34,6 @@ import { legacyListLocalMigrations } from "../../../shared/legacy-pgdelta.cache. import { legacyPathMatch } from "../../../shared/legacy-path-match.ts"; import { legacyGetPendingSeeds, legacySeedData } from "../../../shared/legacy-seed-ops.ts"; import { legacyUpsertVaultSecrets } from "../../../shared/legacy-vault.ts"; -import { legacySeedBucketsRun } from "../../../shared/legacy-seed-buckets.ts"; import type { LegacyDbResetFlags } from "./reset.command.ts"; import { LegacyDbResetApplyError, @@ -52,7 +41,6 @@ import { LegacyDbResetInvalidVersionError, LegacyDbResetLastFlagError, LegacyDbResetMigrationFileError, - LegacyDbResetNotRunningError, LegacyDbResetSeedFlagsError, LegacyDbResetTargetFlagsError, LegacyDbResetVersionFlagsError, @@ -105,15 +93,18 @@ const buildResetArgs = ( * * 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'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. + * primitives are ALSO native now — the hidden `db __db-bootstrap` Go seam this used to + * delegate to (CLI-1325 Stage 3's documented interim) is gone (CLI-1955), and the + * local-reset composition itself is hoisted into `legacyResetLocalDatabase` + * (`legacy/shared/db-bootstrap/reset-local-database.ts`, CLI-2062) so `db schema + * declarative`'s smart-target/sync recovery reset can call it in-process too, instead + * of shelling out to a second `supabase-go` child. 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; @@ -125,17 +116,8 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega 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; - // Threaded into `legacyBuildLocalDbContainerInputs`'s own `setup.debug`, so a failed - // fresh-volume Realtime/Storage/Auth migrate job on the PG15 recreate path tees its own - // stderr, matching Go's `initSchema15` passing `utils.GetDebugLogger()` as that job's - // stderr writer (`start.go:349-353`) — reached by BOTH real Go callers of - // `SetupLocalDatabase` (`db start` and `db reset`'s PG15 recreate). - const debug = yield* LegacyDebugFlag; const workdir = cliConfig.workdir; const migrationsDir = path.join(workdir, "supabase", "migrations"); @@ -315,140 +297,18 @@ 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 (CLI-1955: the hidden Go `db __db-bootstrap` - // seam is gone). Mirrors `internal/db/reset/reset.go:57-77`. + // Local target → native local reset. Mirrors `internal/db/reset/reset.go:57-77`; + // the actual composition (running check, container recreate, storage-health gate, + // bucket seeding, git-branch line) is hoisted into `legacyResetLocalDatabase` + // (CLI-2062) — shared with `db schema declarative`'s in-process recovery reset — + // so this call site stays a thin wrapper around it, keeping only the version/ + // seed-flags plumbing and the JSON envelope, which belong to this top-level + // command alone (see that function's own header for why). if (cfg.isLocal) { - // Go's `flags.LoadConfig` (root `PersistentPreRunE` → the local target's - // per-connType `LoadConfig`, `internal/utils/flags/db_url.go:77-80`) runs full - // config validation before `reset.Run` ever reaches `AssertSupabaseDbIsRunning` - // / the destructive `resetDatabase` (`internal/db/reset/reset.go:57-61`). The - // resolver's own local read (above, line 239) already performs the identical - // validation and would already reject a broken config before this point is - // reached — so today this re-validates for its own sake. Repeat it here anyway, - // as an explicit, independent gate (the same pattern `db start` and `db push` - // use), so the "malformed config aborts before the local database is recreated" - // guarantee is enforced by this handler directly and stays covered by a - // handler-level test even if the resolver's own internal read is ever mocked, - // relaxed, or refactored to stop validating. - yield* legacyCheckDbToml(fs, path, workdir); - - // AssertSupabaseDbIsRunning — error if the local db container is down. Native TS, - // hoisted out of the seam by CLI-1954 (see `legacyIsLocalDbRunning`'s own header). - const running = yield* legacyIsLocalDbRunning( - spawner, - fs, - path, - workdir, - Option.getOrUndefined(cliConfig.projectId), - ); - if (!running) { - return yield* Effect.fail( - new LegacyDbResetNotRunningError({ - message: `${legacyAqua("supabase start")} is not running.`, - }), - ); - } - // resetDatabase: "Resetting local database…" then recreate + migrate + seed. - yield* output.raw(`Resetting local database${toLogMessage(resolvedVersion)}\n`, "stderr"); - - // 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, - debug, - ); - 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, + yield* legacyResetLocalDatabase({ version: resolvedVersion, 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* 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. - // - // `legacyCheckDbToml` above resolves `env(VAR)` via `legacyLoadProjectEnv`, - // which mirrors Go's full nested-env walk (`.env..local`, - // `.env.local`, `.env.`, `.env`, across both `supabase/` and the - // project root — `pkg/config/config.go:1220-1257`). This reload instead goes - // through `@supabase/config`'s `loadProjectConfig` → `loadProjectEnvironment`, - // which only ever reads `supabase/.env`/`.env.local` plus ambient env - // (`packages/config/src/project.ts:209-245`) — regardless of `goViperCompat`, - // which only widens `env(VAR)` matching, not the file set consulted. So a - // config whose `env(VAR)` reference is backed by e.g. `supabase/.env.development` - // is genuinely Go-valid (Go's `godotenv.Load` calls `os.Setenv`, so the value is - // real ambient env by the time Go resolves it — `config.go:1260-1261`) and - // already passed `legacyCheckDbToml` and the real recreate above, but this - // narrower reload can still reject it. A `LegacySeedConfigLoadError` here is - // that env-file-set gap, not a genuinely invalid config — and recreate already - // dropped/rebuilt the DB, so aborting now would leave the reset half-done; warn - // and skip buckets so `db reset` finishes like Go instead. - yield* legacySeedBucketsRun({ - projectRef: "", - emitSummary: false, - interactive: false, - // Go loads nested env before `buckets.Run`, so `SUPABASE_YES` in `supabase/.env` - // auto-confirms bucket/vector/analytics prune prompts. Pass the project-env-resolved - // `yes` (the shared runner's own `legacyResolveYes` only sees the shell env). - yes, - }).pipe( - Effect.catchTag("LegacySeedConfigLoadError", (error) => - output.raw( - `${legacyYellow("WARNING:")} skipped seeding storage buckets: ${error.message}\n`, - "stderr", - ), - ), - ); - } - - // "Finished supabase db reset on branch ." (both Aqua). - const branch = Option.getOrElse(yield* detectGitBranch(workdir), () => "main"); - yield* output.raw( - `Finished ${legacyAqua("supabase db reset")} on branch ${legacyAqua(branch)}.\n`, - "stderr", - ); if (output.format !== "text") { yield* output.success("Reset local database.", { target: "local", 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 18129bea10..b5a837dac1 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 @@ -541,7 +541,7 @@ describe("legacy db reset", () => { describe("local reset — PG15+", () => { it.live("recreates the container, waits healthy, and runs the setup pipeline", () => { - const { layer, out, child } = setup(tmp.current, { + const { layer, out, child, telemetry } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "reset", "--local"], isLocal: true, @@ -568,6 +568,10 @@ describe("legacy db reset", () => { expect(kongReloadCalls(child.spawned)).toHaveLength(1); expect(out.stderrText).toContain("Finished "); expect(out.stderrText).toContain("on branch "); + // The local-reset composition now lives in the shared + // `legacyResetLocalDatabase` (CLI-2062) — confirm this handler's own + // single `Effect.ensuring` finalizer still fires exactly once through it. + expect(telemetry.flushCount).toBe(1); }); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts index 600a4f4d39..00455e786d 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts @@ -44,7 +44,6 @@ function mockSeam(paths: Record) { calls.push({ mode, noCache }); return Effect.succeed(paths[mode]); }, - execInherit: () => Effect.succeed(0), ensureLocalDatabaseStarted: () => Effect.void, ensureLocalPostgresImageCurrent: () => Effect.void, // The migrations-catalog source now resolves natively (CLI-1959) via diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts index 2a01c96912..86823af931 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts @@ -2,11 +2,11 @@ import { Effect, type FileSystem, Option, type Path } from "effect"; import { LegacyDnsResolverFlag, - LegacyNetworkIdFlag, legacyResolveYesWithProjectEnv, } from "../../../../../shared/legacy/global-flags.ts"; import { legacyPromptYesNo } from "../../../../../shared/legacy/legacy-prompt-yes-no.ts"; import { Output } from "../../../../../shared/output/output.service.ts"; +import { legacyResetLocalDatabase } from "../../../../shared/db-bootstrap/reset-local-database.ts"; import { PROJECT_REF_PATTERN } from "../../../../config/legacy-project-ref.service.ts"; import { LegacyDbConfigResolver } from "../../../../shared/legacy-db-config.service.ts"; import { legacyLoadProjectEnv } from "../../../../shared/legacy-db-config.toml-read.ts"; @@ -102,7 +102,6 @@ export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( // project `.env` — must auto-confirm too, not just the flag (CLI-1974). const projectEnv = yield* legacyLoadProjectEnv(fs, path, workdir); const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); - const networkId = yield* LegacyNetworkIdFlag; // Insert "Linked project" between local and custom (Go's choice order) when the // workdir is linked with a valid ref. Go gates this on `LoadProjectRef`, which // validates the ref (`project_ref.go:75`), so an invalid on-disk ref hides the @@ -174,25 +173,17 @@ export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( } if (shouldReset) { // Go runs reset in-process and returns the error (`cmd/db_schema_declarative.go:262-267`). - // `execInherit` (not `LegacyGoProxy.exec`) returns the child's exit code as a - // catchable value rather than exiting the host process — the same - // typed-failure design CLI-1879 gave `LegacyGoProxy.exec` itself, predating - // it here as its own seam. Propagate a failure on a non-zero reset exit. - const seam = yield* LegacyDeclarativeSeam; - // Forward --network-id: Go's in-process reset.Run honors the root viper - // network-id (`apps/cli-go/internal/utils/docker.go:267-271`), so the - // seam-spawned reset must carry it to stay on a custom Docker network. - const code = yield* seam.execInherit([ - "db", - "reset", - "--local", - ...(Option.isSome(networkId) ? ["--network-id", networkId.value] : []), - ]); - if (code !== 0) { - return yield* Effect.fail( - new LegacyDeclarativeApplyError({ message: `database reset failed (exit ${code})` }), - ); - } + // `legacyResetLocalDatabase` now runs the same way — in-process, sharing this + // command's own context — rather than shelling out to a second `supabase-go` child + // (CLI-2062): it resolves `LegacyNetworkIdFlag` itself, so no argv-forwarding is + // needed to stay on a custom Docker network, and a real failure propagates through + // the effect's own failure channel instead of a synthesized exit code. + yield* legacyResetLocalDatabase().pipe( + Effect.mapError( + (error) => + new LegacyDeclarativeApplyError({ message: `database reset failed: ${error.message}` }), + ), + ); } return legacyLocalUrl(local); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md index bd009a4af3..3e99f70643 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md @@ -28,7 +28,7 @@ pg-delta catalog (source) against the target database's catalog (target). | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | | `supabase-go db schema declarative __catalog --mode baseline --experimental` (hidden seam) — provisions a shadow Postgres + `start.SetupDatabase`, exports the baseline catalog | always | | Edge-runtime container (`supabase/edge-runtime`) running the pg-delta declarative-export Deno script (host network, deno-cache volume `supabase_edge_runtime_`) | always | -| `supabase-go db reset --local` | smart-mode Local choice when reset is confirmed (or `--reset`) | +| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `legacyResetLocalDatabase` | smart-mode Local choice when reset is confirmed (or `--reset`) | ## Environment Variables diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts index 002fc2bc49..389df21455 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -6,22 +6,41 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option } from "effect"; import { stripAnsi } from "../../../../../../../tests/helpers/ansi.ts"; -import { mockOutput, mockStdin, mockTty } from "../../../../../../../tests/helpers/mocks.ts"; +import { + alwaysReadyHttpClientLayer, + defaultLocalResetRoute, + legacyLocalResetCreateArgs, + legacyLocalResetRemovedContainers, + mockContainerCliSpawner, +} from "../../../../../../../tests/helpers/legacy-local-reset.ts"; +import { + mockOutput, + mockProcessControl, + mockRuntimeInfo, + mockStdin, + mockTty, +} from "../../../../../../../tests/helpers/mocks.ts"; import { mockLegacyCliConfig, mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApiService, mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../../../../shared/cli/cli-args.service.ts"; import { + LegacyDebugFlag, LegacyDnsResolverFlag, LegacyExperimentalFlag, LegacyNetworkIdFlag, LegacyYesFlag, } from "../../../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../../../shared/legacy/go-proxy.service.ts"; +import { LegacyPlatformApi } from "../../../../../auth/legacy-platform-api.service.ts"; +import { LegacyPlatformApiFactory } from "../../../../../auth/legacy-platform-api-factory.service.ts"; +import { legacyDockerRunLayer } from "../../../../../shared/legacy-docker-run.layer.ts"; import { LegacyDbConfigResolver } from "../../../../../shared/legacy-db-config.service.ts"; +import { LegacyDbConnection } from "../../../../../shared/legacy-db-connection.service.ts"; import { type LegacyEdgeRuntimeRunOpts, LegacyEdgeRuntimeScript, @@ -57,7 +76,12 @@ interface SetupOpts { promptSelectResponses?: ReadonlyArray; promptTextResponses?: ReadonlyArray; exportJson?: string; - resetExitCode?: number; + /** + * Makes the local-reset prompt's `legacyResetLocalDatabase` fail immediately + * with `LegacyResetLocalDbNotRunningError` (the local `db` container reports as + * not running) instead of completing a real recreate. + */ + resetShouldFail?: boolean; networkId?: Option.Option; projectId?: Option.Option; exportFailsForMode?: LegacyCatalogMode; @@ -74,9 +98,33 @@ function setup(workdir: string, opts: SetupOpts = {}) { const cache = mockLegacyLinkedProjectCacheTracked(); const seamCalls: LegacyCatalogMode[] = []; const seamExportCalls: Array<{ mode: LegacyCatalogMode; projectRef?: string }> = []; - const execInheritCalls: ReadonlyArray[] = []; const localPostgresImageChecks: Array = []; let ensureStartedCalls = 0; + const platformApi = mockLegacyPlatformApiService({}); + // Backs `legacyResetLocalDatabase`'s real, native container-recreate — reached + // when the smart-target local-reset prompt is confirmed (CLI-2062: it now runs + // in-process instead of shelling out to a second `supabase-go` child). + const child = mockContainerCliSpawner( + defaultLocalResetRoute("test", { running: opts.resetShouldFail !== true }), + ); + const dbExec: string[] = []; + const dbConn = Layer.succeed(LegacyDbConnection, { + connect: () => + Effect.succeed({ + exec: (sql: string) => + Effect.sync(() => { + dbExec.push(sql); + }), + query: (sql: string) => + Effect.sync(() => { + dbExec.push(sql); + return []; + }), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }), + }); const seam = Layer.succeed(LegacyDeclarativeSeam, { exportCatalog: ({ mode, projectRef }) => { seamCalls.push(mode); @@ -85,10 +133,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { ? Effect.fail(new LegacyDeclarativeShadowDbError({ message: `export failed for ${mode}` })) : Effect.succeed("supabase/.temp/pgdelta/base.json"); }, - execInherit: (args) => { - execInheritCalls.push(args); - return Effect.succeed(opts.resetExitCode ?? 0); - }, ensureLocalDatabaseStarted: () => Effect.sync(() => { ensureStartedCalls += 1; @@ -147,6 +191,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { edge, resolver, proxy, + dbConn, mockLegacyCliConfig({ workdir, projectId: opts.projectId ?? Option.some("test") }), mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), mockStdin(opts.stdinIsTty ?? false), @@ -155,20 +200,39 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.succeed(LegacyYesFlag, opts.yes ?? false), Layer.succeed(LegacyNetworkIdFlag, opts.networkId ?? Option.none()), Layer.succeed(LegacyDnsResolverFlag, "native"), + Layer.succeed(LegacyDebugFlag, false), // The remote ref is a non-Supabase host that refuses TLS → no SSL env. Layer.succeed(LegacyPgDeltaSslProbe, { requireSsl: () => Effect.succeed(false), requireSslForHost: () => Effect.succeed(false), }), + // The local-reset bucket-seed core statically requires the (lazy) Management-API + // factory; never invoked on the local reset (projectRef === ""). + Layer.succeed(LegacyPlatformApiFactory, { + make: LegacyPlatformApi.pipe(Effect.provide(platformApi.layer)), + }), BunServices.layer, + // `child.layer` must be listed AFTER `BunServices.layer` — `Layer.mergeAll` + // resolves a duplicate service tag to whichever layer is listed LAST, so this + // mock overrides Bun's real `ChildProcessSpawner` instead of the reverse. + child.layer, + mockRuntimeInfo({ platform: "linux" }), + mockProcessControl().layer, + alwaysReadyHttpClientLayer, + legacyDockerRunLayer.pipe( + Layer.provide(child.layer), + Layer.provide(mockProcessControl().layer), + ), ); return { layer, out, cache, + telemetry, + child, + dbExec, seamCalls, seamExportCalls, - execInheritCalls, edgeCalls, resolverCalls, proxyCalls, @@ -722,21 +786,25 @@ describe("legacy db schema declarative generate integration", () => { }); it.effect("smart mode: propagates a reset failure instead of exiting the process", () => { - // Go runs reset in-process and returns the error; using the non-exiting seam, - // a non-zero reset must fail the effect (so telemetry flush / error handling run) - // rather than process.exit via LegacyGoProxy. + // Go runs reset in-process and returns the error; `legacyResetLocalDatabase` now + // runs the same way (CLI-2062), so its real failure must fail the effect (so + // telemetry flush / error handling run) rather than process.exit via LegacyGoProxy. mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, promptSelectResponses: ["local"], - resetExitCode: 1, + resetShouldFail: true, }); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyDbSchemaDeclarativeGenerate(flags({ reset: true }))); expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)).toMatchObject({ message: "database reset failed (exit 1)" }); + expect(failError(exit)).toMatchObject({ + message: "database reset failed: supabase start is not running.", + }); + // Failed before any destructive container work. + expect(legacyLocalResetRemovedContainers(s.child.spawned)).toEqual([]); }).pipe(Effect.provide(s.layer)); }); @@ -803,6 +871,14 @@ describe("legacy db schema declarative generate integration", () => { return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags()); expect(s.cache.cached).toBe(true); + // This scenario also runs a real in-process local reset + // (`legacyResetLocalDatabase`, CLI-2062) — its own body never touches the + // linked-project cache or telemetry, so the outer command's single + // `Effect.ensuring` finalizer must still fire EXACTLY once each, not + // twice, matching Go's single-process `reset.Run` (no second + // `PersistentPostRun` from a separate child process). + expect(s.cache.cacheCount).toBe(1); + expect(s.telemetry.flushCount).toBe(1); }).pipe(Effect.provide(s.layer)); }, ); @@ -889,6 +965,11 @@ describe("legacy db schema declarative generate integration", () => { // reset must run. No promptConfirmResponses are supplied, so a prompt would throw. mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); + // `legacyResetLocalDatabase`'s container-recreate resolves its own project id from + // `@supabase/config` (config.toml / real env), independently of the mocked + // `LegacyCliConfig.projectId` — pin it to "test" so the recreated container name + // matches the spawner route's assumption. + writeFileSync(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, @@ -897,15 +978,21 @@ describe("legacy db schema declarative generate integration", () => { }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags()); - expect(s.execInheritCalls).toEqual([["db", "reset", "--local"]]); + // The reset actually ran — recreated the local `db` container in-process + // (CLI-2062: no `supabase-go` child) — proving it's a real effect. + expect(legacyLocalResetRemovedContainers(s.child.spawned)).toContain("supabase_db_test"); + expect(legacyLocalResetCreateArgs(s.child.spawned)).not.toBeUndefined(); + expect(s.out.rawChunks.some((c) => c.text.includes("Resetting local database"))).toBe(true); }).pipe(Effect.provide(s.layer)); }); it.effect("smart mode: forwards --network-id to the local reset", () => { - // Go's in-process reset.Run honors the root viper network-id, so the spawned - // reset must carry `--network-id` to stay on a custom Docker network. + // `legacyResetLocalDatabase` resolves `LegacyNetworkIdFlag` itself from the + // shared context (CLI-2062) — no argv-forwarding needed — so the recreated + // container must land on the custom network directly. mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); + writeFileSync(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, @@ -915,7 +1002,10 @@ describe("legacy db schema declarative generate integration", () => { }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags()); - expect(s.execInheritCalls).toEqual([["db", "reset", "--local", "--network-id", "my-net"]]); + const createArgs = legacyLocalResetCreateArgs(s.child.spawned); + const networkIndex = createArgs?.indexOf("--network") ?? -1; + expect(networkIndex).toBeGreaterThanOrEqual(0); + expect(createArgs?.[networkIndex + 1]).toBe("my-net"); }).pipe(Effect.provide(s.layer)); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.layers.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.layers.ts index 6f2429fe57..61eaf96544 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.layers.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.layers.ts @@ -22,7 +22,11 @@ import { legacyDeclarativeSeamLayer } from "../../../shared/legacy-pgdelta.seam. * `runCli`. This layer adds the declarative-specific services: the edge-runtime * pg-delta runner and the Go shadow-database seam, plus the db-config resolver * for `--linked` / `--db-url`. Per the "provide doesn't share to siblings" rule, - * `LegacyCliConfig` is provided to every layer that needs it. + * `LegacyCliConfig` is provided to every layer that needs it. `legacyDockerRunLayer` + * is ALSO exposed directly (not just provided to `edgeRuntime`): the smart-target + * local-reset prompt now calls `legacyResetLocalDatabase` in-process (CLI-2062), + * whose PG15+ recreate reuses the same one-shot migrate jobs `db start`/`db reset` + * back with this same layer (see those commands' own `*.layers.ts`). */ const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); @@ -46,6 +50,7 @@ const seam = legacyDeclarativeSeamLayer.pipe(Layer.provide(cliConfig)); export const legacyDbSchemaDeclarativeGenerateRuntimeLayer = Layer.mergeAll( dbConfig, legacyDbConnectionLayer, + legacyDockerRunLayer, edgeRuntime, legacyPgDeltaSslProbeLayer, seam, diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md index 4a17e22697..35b3ac0534 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md @@ -30,7 +30,7 @@ as a new timestamped migration. | `supabase-go db __shadow --mode diff` (seam, unchanged) — shadow Postgres + `SetupDatabase` + apply migrations; the catalog itself is exported natively via edge-runtime (CLI-1959 — no longer the hidden `db schema declarative __catalog --mode migrations` subprocess) | migrations-catalog cache miss only | | `supabase-go db schema declarative __catalog --mode declarative --experimental` (seam) — shadow Postgres + `SetupDatabase` + apply declarative → catalog | always | | Edge-runtime container running the pg-delta diff Deno script, and (on a migrations-catalog cache miss) the pg-delta catalog-export Deno script | always / cache miss | -| `supabase-go db reset --local [--network-id ]` (seam) — only on the failed-apply recovery path; `db reset` is still Go-proxied (`wrapped`), so the reset itself shells out to the bundled binary | TTY only, apply failed, and the user confirms "reset and reapply" | +| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `legacyResetLocalDatabase` (CLI-2062: in-process, no `supabase-go` child) — only on the failed-apply recovery path | TTY only, apply failed, and the user confirms "reset and reapply" | ## Environment Variables @@ -79,8 +79,9 @@ are mutually exclusive. - The migration apply is native (connects to the local DB and records migration history). On apply failure a debug bundle is written under `supabase/.temp/pgdelta/debug/` and, in a TTY, a reset-and-reapply is offered - (the reset itself runs the bundled `supabase-go db reset --local`, since - `db reset` is still `wrapped`). + (the reset itself is native too — `legacyResetLocalDatabase`, CLI-2062 — run + in-process, sharing this command's own telemetry/linked-project-cache finalizer + cycle rather than firing a second one from a `supabase-go` child). - **Architecture:** the migrations-catalog diff source resolves natively (CLI-1959): the setup-inputs-folded cache key, the zero-local-migrations → platform-baseline reuse, and the pg-delta catalog export are all native TS; only the shadow-database diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts index d142ea3b3d..c7f0ac387a 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts @@ -2,7 +2,6 @@ import { Cause, Clock, Effect, Exit, FileSystem, Option, Path } from "effect"; import { LegacyDnsResolverFlag, - LegacyNetworkIdFlag, legacyResolveExperimentalWithProjectEnv, legacyResolveYesWithProjectEnv, } from "../../../../../../shared/legacy/global-flags.ts"; @@ -10,6 +9,7 @@ import { legacyPromptYesNo } from "../../../../../../shared/legacy/legacy-prompt import { Output } from "../../../../../../shared/output/output.service.ts"; import { Tty } from "../../../../../../shared/runtime/tty.service.ts"; import { LegacyCliConfig } from "../../../../../config/legacy-cli-config.service.ts"; +import { legacyResetLocalDatabase } from "../../../../../shared/db-bootstrap/reset-local-database.ts"; import { legacyBold, legacyRed, legacyYellow } from "../../../../../shared/legacy-colors.ts"; import { LegacyDbConnection } from "../../../../../shared/legacy-db-connection.service.ts"; import { legacyGetHostname } from "../../../../../shared/legacy-hostname.ts"; @@ -90,7 +90,6 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara // read `viper.GetBool("YES")` after `loadNestedEnv`, so the env var must // auto-confirm too, not just the flag (CLI-1974). const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); - const networkId = yield* LegacyNetworkIdFlag; const dnsResolver = yield* LegacyDnsResolverFlag; const seam = yield* LegacyDeclarativeSeam; const linkedProjectCache = yield* LegacyLinkedProjectCache; @@ -390,22 +389,20 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara { defaultValue: false }, ); if (shouldReset) { - // Forward --network-id: Go's in-process reset.Run honors the root viper - // network-id (`apps/cli-go/internal/utils/docker.go:267-271`), so the - // seam-spawned reset must carry it to stay on a custom network. - const code = yield* seam.execInherit([ - "db", - "reset", - "--local", - ...(Option.isSome(networkId) ? ["--network-id", networkId.value] : []), - ]); - if (code !== 0) { - // Go returns `resetErr` here (`apps/cli-go/cmd/db_schema_declarative.go:414-423`), - // surfacing the failure that actually blocked recovery — not the original - // apply error. The seam yields only an exit code, so build the reset error - // from it and use that one value for the message, debug bundle, and return. + // Go runs reset in-process (`cmd/db_schema_declarative.go:414-423`). + // `legacyResetLocalDatabase` now runs the same way — in-process, sharing this + // command's own context — rather than shelling out to a second `supabase-go` + // child (CLI-2062): it resolves `LegacyNetworkIdFlag` itself, so no + // argv-forwarding is needed to stay on a custom network. + const resetExit = yield* legacyResetLocalDatabase().pipe(Effect.exit); + if (Exit.isFailure(resetExit)) { + // Go returns `resetErr` here, surfacing the failure that actually blocked + // recovery — not the original apply error. Build the reset error from the + // real typed failure and use that one value for the message, debug bundle, + // and return. + const resetFailure = resetExit.cause.reasons.find(Cause.isFailReason)?.error; const resetError = new LegacyDeclarativeApplyError({ - message: `database reset failed (exit ${code})`, + message: `database reset failed: ${resetFailure?.message ?? "unknown error"}`, }); yield* output.raw( `${legacyRed(`Database reset also failed: ${resetError.message}`)}\n`, diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts index 72b4d43a6d..c4995d51df 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -5,20 +5,38 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Option } from "effect"; import { stripAnsi } from "../../../../../../../tests/helpers/ansi.ts"; -import { mockOutput, mockStdin, mockTty } from "../../../../../../../tests/helpers/mocks.ts"; +import { + alwaysReadyHttpClientLayer, + defaultLocalResetRoute, + legacyLocalResetCreateArgs, + legacyLocalResetRemovedContainers, + mockContainerCliSpawner, +} from "../../../../../../../tests/helpers/legacy-local-reset.ts"; +import { + mockOutput, + mockProcessControl, + mockRuntimeInfo, + mockStdin, + mockTty, +} from "../../../../../../../tests/helpers/mocks.ts"; import { mockLegacyCliConfig, mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApiService, mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../../../../shared/cli/cli-args.service.ts"; import { + LegacyDebugFlag, LegacyDnsResolverFlag, LegacyExperimentalFlag, LegacyNetworkIdFlag, LegacyYesFlag, } from "../../../../../../shared/legacy/global-flags.ts"; +import { LegacyPlatformApi } from "../../../../../auth/legacy-platform-api.service.ts"; +import { LegacyPlatformApiFactory } from "../../../../../auth/legacy-platform-api-factory.service.ts"; +import { legacyDockerRunLayer } from "../../../../../shared/legacy-docker-run.layer.ts"; import { LegacyDbConfigResolver } from "../../../../../shared/legacy-db-config.service.ts"; import { LegacyDbConnection } from "../../../../../shared/legacy-db-connection.service.ts"; import { @@ -51,7 +69,12 @@ interface SetupOpts { stdinIsTty?: boolean; diffSql?: string; applyFails?: boolean; - resetExitCode?: number; + /** + * Makes the recovery reset's `legacyResetLocalDatabase` fail immediately with + * `LegacyResetLocalDbNotRunningError` (the local `db` container reports as not + * running) instead of completing a real recreate. + */ + resetShouldFail?: boolean; promptConfirmResponses?: ReadonlyArray; promptSelectResponses?: ReadonlyArray; promptTextResponses?: ReadonlyArray; @@ -69,8 +92,14 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const telemetry = mockLegacyTelemetryStateTracked(); const cache = mockLegacyLinkedProjectCacheTracked(); - const execInheritCalls: ReadonlyArray[] = []; const localPostgresImageChecks: Array = []; + const platformApi = mockLegacyPlatformApiService({}); + // Backs `legacyResetLocalDatabase`'s real, native container-recreate — reached + // when the recovery-reset offer is accepted (CLI-2062: it now runs in-process + // instead of shelling out to a second `supabase-go` child). + const child = mockContainerCliSpawner( + defaultLocalResetRoute("test", { running: opts.resetShouldFail !== true }), + ); // Each catalog export records how many raw chunks had been emitted when it fired, // so tests can assert output ordering relative to the exports (e.g. the bootstrap's // written-to line lands after the declarative warm, before the diff's exports). @@ -88,11 +117,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { exportCatalogCalls.push({ mode, rawChunksAt: out.rawChunks.length }); return `supabase/.temp/pgdelta/${mode}.json`; }), - execInherit: (args) => - Effect.sync(() => { - execInheritCalls.push(args); - return opts.resetExitCode ?? 0; - }), ensureLocalDatabaseStarted: () => Effect.void, ensureLocalPostgresImageCurrent: () => Effect.sync(() => { @@ -209,19 +233,37 @@ function setup(workdir: string, opts: SetupOpts = {}) { opts.networkId === undefined ? Option.none() : Option.some(opts.networkId), ), Layer.succeed(LegacyDnsResolverFlag, "native"), + Layer.succeed(LegacyDebugFlag, false), // Sync diffs against the local DB, which refuses TLS → no SSL env injected. Layer.succeed(LegacyPgDeltaSslProbe, { requireSsl: () => Effect.succeed(false), requireSslForHost: () => Effect.succeed(false), }), + // The local-reset bucket-seed core statically requires the (lazy) Management-API + // factory; never invoked on the local recovery reset (projectRef === ""). + Layer.succeed(LegacyPlatformApiFactory, { + make: LegacyPlatformApi.pipe(Effect.provide(platformApi.layer)), + }), BunServices.layer, + // `child.layer` must be listed AFTER `BunServices.layer` — `Layer.mergeAll` + // resolves a duplicate service tag to whichever layer is listed LAST, so this + // mock overrides Bun's real `ChildProcessSpawner` instead of the reverse. + child.layer, + mockRuntimeInfo({ platform: "linux" }), + mockProcessControl().layer, + alwaysReadyHttpClientLayer, + legacyDockerRunLayer.pipe( + Layer.provide(child.layer), + Layer.provide(mockProcessControl().layer), + ), ); return { layer, out, - execInheritCalls, + child, dbExec, cache, + telemetry, localPostgresImageChecks, exportCatalogCalls, provisionShadowCalls, @@ -819,7 +861,8 @@ describe("legacy db schema declarative sync integration", () => { expect(s.dbExec.some((q) => q.includes("supabase_migrations.schema_migrations"))).toBe( true, ); - expect(s.execInheritCalls).toEqual([]); // no reset on success + // No reset on success — the recovery reset's container-remove never ran. + expect(legacyLocalResetRemovedContainers(s.child.spawned)).toEqual([]); expect(s.out.rawChunks.some((c) => c.text.includes("Migration applied successfully"))).toBe( true, ); @@ -843,29 +886,43 @@ describe("legacy db schema declarative sync integration", () => { }); it.effect( - "apply failure in a TTY offers reset+reapply and delegates reset to the Go binary", + "apply failure in a TTY offers reset+reapply and runs the reset natively in-process", () => { seedDeclarative(tmp.current); + // `legacyResetLocalDatabase`'s container-recreate resolves its own project id + // from `@supabase/config` (config.toml / real env), independently of the + // mocked `LegacyCliConfig.projectId` — pin it to "test" so the recreated + // container name matches the spawner route's assumption. + writeFileSync(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); const s = setup(tmp.current, { experimental: true, diffSql: "ALTER TABLE a ADD COLUMN b int;\n", applyFails: true, stdinIsTty: true, promptConfirmResponses: [true], // accept the reset offer - resetExitCode: 0, }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeSync(flags({ apply: Option.some(true) })); expect(s.out.rawChunks.some((c) => c.text.includes("Migration failed to apply"))).toBe( true, ); - expect(s.execInheritCalls).toEqual([["db", "reset", "--local"]]); + // The recovery reset actually ran — recreated the local `db` container + // (CLI-2062: in-process, not a `supabase-go` child) — proving it's a real + // effect, not just a tracked call. + expect(legacyLocalResetRemovedContainers(s.child.spawned)).toContain("supabase_db_test"); + expect(legacyLocalResetCreateArgs(s.child.spawned)).not.toBeUndefined(); + expect(s.out.rawChunks.some((c) => c.text.includes("Resetting local database"))).toBe(true); expect( s.out.rawChunks.some((c) => c.text.includes("Database reset and all migrations applied successfully"), ), ).toBe(true); expect(existsSync(join(tmp.current, "supabase", ".temp", "pgdelta", "debug"))).toBe(true); + // `legacyResetLocalDatabase`'s own body never touches telemetry — the outer + // `sync` command's single `Effect.ensuring` finalizer must still fire + // EXACTLY once, not twice, matching Go's single-process `reset.Run` (no + // second `PersistentPostRun` from a separate child process) (CLI-2062). + expect(s.telemetry.flushCount).toBe(1); }).pipe(Effect.provide(s.layer)); }, ); @@ -880,44 +937,48 @@ describe("legacy db schema declarative sync integration", () => { applyFails: true, stdinIsTty: true, promptConfirmResponses: [true], // accept the reset offer - resetExitCode: 1, // …and the reset itself fails + resetShouldFail: true, // …and the reset itself fails (local db not running) }); return Effect.gen(function* () { const exit = yield* Effect.exit( legacyDbSchemaDeclarativeSync(flags({ apply: Option.some(true) })), ); expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)).toMatchObject({ message: "database reset failed (exit 1)" }); + expect(failError(exit)).toMatchObject({ + message: "database reset failed: supabase start is not running.", + }); expect( s.out.rawChunks.some((c) => - c.text.includes("Database reset also failed: database reset failed (exit 1)"), + c.text.includes( + "Database reset also failed: database reset failed: supabase start is not running.", + ), ), ).toBe(true); + // A real failure, before any destructive container work. + expect(legacyLocalResetRemovedContainers(s.child.spawned)).toEqual([]); }).pipe(Effect.provide(s.layer)); }); it.effect("forwards --network-id to the recovery reset", () => { - // Go's in-process reset.Run honors the root viper network-id, so the - // seam-spawned reset must carry --network-id to stay on a custom network. + // `legacyResetLocalDatabase` resolves `LegacyNetworkIdFlag` itself from the + // shared context (CLI-2062) — no argv-forwarding needed — so the recreated + // container must land on the custom network directly. seedDeclarative(tmp.current); + writeFileSync(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); const s = setup(tmp.current, { experimental: true, diffSql: "ALTER TABLE a ADD COLUMN b int;\n", applyFails: true, stdinIsTty: true, promptConfirmResponses: [true], // accept the reset offer - resetExitCode: 0, networkId: "my_net", }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeSync(flags({ apply: Option.some(true) })); - expect(s.execInheritCalls).toContainEqual([ - "db", - "reset", - "--local", - "--network-id", - "my_net", - ]); + const createArgs = legacyLocalResetCreateArgs(s.child.spawned); + const networkIndex = createArgs?.indexOf("--network") ?? -1; + expect(networkIndex).toBeGreaterThanOrEqual(0); + expect(createArgs?.[networkIndex + 1]).toBe("my_net"); }).pipe(Effect.provide(s.layer)); }); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.layers.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.layers.ts index 0eb4fc8592..a54b47476d 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.layers.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.layers.ts @@ -20,7 +20,12 @@ import { legacyDeclarativeSeamLayer } from "../../../shared/legacy-pgdelta.seam. * smart-generate flow (Go's `runDeclarativeGenerate`), which can target local / * linked / custom — so it needs the db-config resolver too. `Output` / * `LegacyGoProxy` / global flags + the Bun platform come from the legacy root / - * `runCli`. + * `runCli`. `legacyDockerRunLayer` is ALSO exposed directly (not just provided to + * `edgeRuntime`): both the smart-target bootstrap's local-reset prompt and the + * failed-apply recovery reset now call `legacyResetLocalDatabase` in-process + * (CLI-2062), whose PG15+ recreate reuses the same one-shot migrate jobs `db + * start`/`db reset` back with this same layer (see those commands' own + * `*.layers.ts`). */ const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); @@ -43,6 +48,7 @@ const seam = legacyDeclarativeSeamLayer.pipe(Layer.provide(cliConfig)); export const legacyDbSchemaDeclarativeSyncRuntimeLayer = Layer.mergeAll( dbConfig, + legacyDockerRunLayer, edgeRuntime, legacyPgDeltaSslProbeLayer, seam, 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 67060f4233..9e97f7105e 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 @@ -125,31 +125,6 @@ export const legacyDeclarativeSeamLayer = Layer.effect( return new TextDecoder().decode(bytes).trim(); }), ), - execInherit: (args) => - Effect.gen(function* () { - if (!("found" in resolved)) { - return yield* Effect.fail( - new LegacyDeclarativeShadowDbError({ - message: "Could not find the supabase-go binary.", - }), - ); - } - const command = ChildProcess.make(resolved.found, args, { - cwd: cliConfig.workdir, - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - extendEnv: true, - detached: false, - }); - return yield* spawner - .exitCode(command) - .pipe( - Effect.mapError( - () => new LegacyDeclarativeShadowDbError({ message: "failed to run supabase-go." }), - ), - ); - }), ensureLocalDatabaseStarted: () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts index 1662d85696..4f5409c3a6 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts @@ -69,20 +69,6 @@ interface LegacyDeclarativeSeamShape { */ readonly projectRef?: string; }) => Effect.Effect; - /** - * Runs the bundled Go binary with the given args, inheriting stdio (so the - * user sees its output) and returning its exit code — without exiting the - * host process. Used for the sync apply-failure recovery, which shells out - * to the Go binary's own `db reset --local` (`declarative.smart-target.ts`) - * rather than calling the native TS `legacyDbReset` handler in-process — - * `db reset` itself is `ported`, but its handler isn't yet structured to be - * invoked from other TS commands rather than the CLI's own dispatch. Known, - * documented scope-leak (not a porting-status gap): two live `db reset` - * implementations remain until `legacyDbReset` is made in-process-callable. - */ - readonly execInherit: ( - args: ReadonlyArray, - ) => Effect.Effect; /** * Go's `ensureLocalDatabaseStarted` for the `--local` declarative paths * (`apps/cli-go/cmd/db_schema_declarative.go:190,249,291`): inspects the local diff --git a/apps/cli/src/legacy/commands/db/reset/await-storage-ready.ts b/apps/cli/src/legacy/shared/db-bootstrap/await-storage-ready.ts similarity index 81% rename from apps/cli/src/legacy/commands/db/reset/await-storage-ready.ts rename to apps/cli/src/legacy/shared/db-bootstrap/await-storage-ready.ts index 53ae7845ee..87d34a913a 100644 --- a/apps/cli/src/legacy/commands/db/reset/await-storage-ready.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/await-storage-ready.ts @@ -15,21 +15,24 @@ * 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). + * Hoisted to `legacy/shared/db-bootstrap/` (CLI-2062): originally lived in + * `commands/db/reset/` since `db reset`'s own handler was its only caller — the + * bucket-seeding health gate has no equivalent in `db start`/`supabase start` at + * all (CLI-1955 review follow-up). `legacyResetLocalDatabase` + * (`reset-local-database.ts`) is now a second caller (`db schema declarative`'s + * smart-target/sync recovery reset), so this moved alongside it. */ 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 { legacyInspectContainerState } from "../legacy-docker-lifecycle.ts"; +import { legacyServiceContainerName } from "../legacy-docker-ids.ts"; import { legacyWaitForHealthyServices, type LegacyHealthCheckTimeoutError, -} from "../../../shared/db-bootstrap/health-check.ts"; +} from "./health-check.ts"; type Spawner = ChildProcessSpawner["Service"]; diff --git a/apps/cli/src/legacy/commands/db/reset/await-storage-ready.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/await-storage-ready.unit.test.ts similarity index 98% rename from apps/cli/src/legacy/commands/db/reset/await-storage-ready.unit.test.ts rename to apps/cli/src/legacy/shared/db-bootstrap/await-storage-ready.unit.test.ts index 000f377f13..1ae10f8553 100644 --- a/apps/cli/src/legacy/commands/db/reset/await-storage-ready.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/await-storage-ready.unit.test.ts @@ -4,7 +4,7 @@ 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/db-bootstrap/health-check.ts"; +import { LegacyHealthCheckTimeoutError } from "./health-check.ts"; import { legacyAwaitStorageReady } from "./await-storage-ready.ts"; const unusedHttpClientLayer = Layer.succeed( 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 index c502abd700..c3305e2b67 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts @@ -215,7 +215,7 @@ const PG_INVALID_CATALOG_NAME = "3D000"; * 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` — + * + `Effect.forkChild` pattern as `db-bootstrap/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 diff --git a/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts new file mode 100644 index 0000000000..3bc87a9448 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts @@ -0,0 +1,244 @@ +/** + * A plain, full local-database reset — Go's `reset.Run(ctx, "", 0, flags.DbConfig, fsys)` + * called against the local target (`internal/db/reset/reset.go:57-77`), with an EMPTY + * version and NO `--last` filtering. Hoisted out of `commands/db/reset/reset.handler.ts`'s + * own `cfg.isLocal` branch (CLI-1955) so it is callable in-process by any Effect context + * that provides the services below (CLI-2062) — the two `db schema declarative` + * call sites (`declarative.smart-target.ts`'s local-reset prompt, + * `sync.handler.ts`'s failed-apply recovery reset) used to shell out to a SEPARATE + * `supabase-go` child process for this (`LegacyDeclarativeSeam.execInherit`), which is + * itself a divergence from real Go: Go's `db schema declarative`/`sync` call + * `reset.Run` as a plain in-process function, sharing the outer command's own + * `PersistentPostRun` (telemetry flush / linked-project-cache write) rather than firing + * a second, independent one from a child process's own `Execute()`. Calling this + * function in-process collapses back to that single-firing behavior. + * + * `db reset`'s own handler is the only caller that ever passes a non-empty + * `version`/`seedFlags` override (`--version`/`--last`/`--no-seed`/`--sql-paths`) — the + * declarative callers always want the plain full reset and call with no arguments. + * + * Resolves every service it needs (`LegacyDebugFlag`, `LegacyNetworkIdFlag`, + * `RuntimeInfo`, `ChildProcessSpawner`, `FileSystem`, `Path`, `LegacyCliConfig`, the + * project `.env` + `legacyResolveExperimentalWithProjectEnv` gate) itself via `yield*`, + * exactly like `legacyDbReset` did inline before this extraction — so it is + * self-contained and does not need `LegacyDbResetFlags`/`CliArgs`/ + * `resolveLegacyDbTargetFlags` (the top-level `db reset` command's own flag-parsing + * concerns, which stay in `reset.handler.ts`). + * + * Emits the exact same two stderr lines the removed `execInherit` subprocess used to + * produce via the Go child's inherited stdio (`Resetting local database...` / + * `Finished supabase db reset on branch .`) — always via `output.raw`, + * regardless of `output.format`, matching a child process's inherited stdio, which + * never receives `-o`/`--output-format` and always prints Go-native text. Deliberately + * does NOT emit the JSON `output.success(...)` envelope: that belongs to a real + * top-level `db reset` invocation only (`reset.handler.ts` emits it itself, after + * calling this function) — neither Go's in-process `reset.Run` nor the removed + * `execInherit` subprocess ever produced a machine-JSON envelope for this nested call. + */ + +import { Data, Effect, FileSystem, Option, Path } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { detectGitBranch } from "../../../shared/git/git-branch.ts"; +import { + LegacyDebugFlag, + LegacyNetworkIdFlag, + legacyResolveExperimentalWithProjectEnv, + legacyResolveYesWithProjectEnv, +} from "../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { legacyAqua, legacyYellow } from "../legacy-colors.ts"; +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { legacyCheckDbToml, legacyLoadProjectEnv } from "../legacy-db-config.toml-read.ts"; +import { legacySeedBucketsRun } from "../legacy-seed-buckets.ts"; +import { legacyAwaitStorageReady } from "./await-storage-ready.ts"; +import { legacyBuildLocalDbContainerInputs } from "./local-container-inputs.ts"; +import { legacyIsLocalDbRunning } from "./local-db-running.ts"; +import { legacyRecreateLocalDatabase } from "./recreate-local-database.ts"; + +/** + * The local database container is not running. Byte-matches Go's + * `utils.ErrNotRunning` (`internal/utils/misc.go:116`), `"supabase start + * is not running."`, returned by `AssertSupabaseDbIsRunning` before the local + * reset (`internal/db/reset/reset.go:57`). Not exported outside this module — + * callers discriminate this via the failure's own message, never by importing the + * class itself (same pattern as `recreate-local-database.ts`'s own + * `LegacyResetReplicationSlotsError`). + */ +class LegacyResetLocalDbNotRunningError extends Data.TaggedError( + "LegacyResetLocalDbNotRunningError", +)<{ + readonly message: string; +}> {} + +/** Go's `toLogMessage` (`internal/db/reset/reset.go:88-91`). */ +const toLogMessage = (version: string): string => + version.length > 0 ? ` to version: ${version}` : "..."; + +export interface LegacyResetLocalDatabaseInput { + /** The resolved reset migration version (`""` for every pending migration, `db reset`'s default). */ + readonly version: string; + /** `db reset`'s `--no-seed`/`--sql-paths` — see `legacyResolveResetSeedConfig`. */ + readonly seedFlags: { readonly noSeed: boolean; readonly sqlPaths: ReadonlyArray }; +} + +const PLAIN_FULL_RESET: LegacyResetLocalDatabaseInput = { + version: "", + seedFlags: { noSeed: false, sqlPaths: [] }, +}; + +/** + * Resets the local database in-process. See this module's own header for the full + * design rationale. Mirrors `internal/db/reset/reset.go:57-77`. + */ +export const legacyResetLocalDatabase = Effect.fnUntraced(function* ( + input: LegacyResetLocalDatabaseInput = PLAIN_FULL_RESET, +) { + const output = yield* Output; + const cliConfig = yield* LegacyCliConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeInfo = yield* RuntimeInfo; + const networkIdFlag = yield* LegacyNetworkIdFlag; + // Threaded into `legacyBuildLocalDbContainerInputs`'s own `setup.debug`, so a failed + // fresh-volume Realtime/Storage/Auth migrate job on the PG15 recreate path tees its own + // stderr, matching Go's `initSchema15` passing `utils.GetDebugLogger()` as that job's + // stderr writer (`start.go:349-353`) — reached by BOTH real Go callers of + // `SetupLocalDatabase` (`db start` and `db reset`'s PG15 recreate). + const debug = yield* LegacyDebugFlag; + + const workdir = cliConfig.workdir; + // Go's `ParseDatabaseConfig` runs `loadNestedEnv` (which `os.Setenv`s each project-.env key) + // before `reset.Run` reads `viper.GetBool("EXPERIMENTAL")`, so a `SUPABASE_EXPERIMENTAL` set + // only in `supabase/.env` is honored. Load the project env first and resolve against it, as + // `legacyDbReset` does for its own experimental gate. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, workdir); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); + const experimental = yield* legacyResolveExperimentalWithProjectEnv(projectEnv); + + // Go's `flags.LoadConfig` (root `PersistentPreRunE` → the local target's per-connType + // `LoadConfig`, `internal/utils/flags/db_url.go:77-80`) runs full config validation before + // `reset.Run` ever reaches `AssertSupabaseDbIsRunning` / the destructive `resetDatabase` + // (`internal/db/reset/reset.go:57-61`). Re-validate here as an explicit, independent gate + // (the same pattern `db start`/`db push` use), so "a malformed config aborts before the + // local database is recreated" is enforced by this function directly. + yield* legacyCheckDbToml(fs, path, workdir); + + // AssertSupabaseDbIsRunning — error if the local db container is down. + const running = yield* legacyIsLocalDbRunning( + spawner, + fs, + path, + workdir, + Option.getOrUndefined(cliConfig.projectId), + ); + if (!running) { + return yield* Effect.fail( + new LegacyResetLocalDbNotRunningError({ + message: `${legacyAqua("supabase start")} is not running.`, + }), + ); + } + // resetDatabase: "Resetting local database…" then recreate + migrate + seed. + yield* output.raw(`Resetting local database${toLogMessage(input.version)}\n`, "stderr"); + + // 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, + debug, + ); + 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: input.version, + seedFlags: input.seedFlags, + // `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* 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. + // + // `legacyCheckDbToml` above resolves `env(VAR)` via `legacyLoadProjectEnv`, which + // mirrors Go's full nested-env walk (`.env..local`, `.env.local`, + // `.env.`, `.env`, across both `supabase/` and the project root — + // `pkg/config/config.go:1220-1257`). This reload instead goes through + // `@supabase/config`'s `loadProjectConfig` → `loadProjectEnvironment`, which only + // ever reads `supabase/.env`/`.env.local` plus ambient env + // (`packages/config/src/project.ts:209-245`) — regardless of `goViperCompat`, which + // only widens `env(VAR)` matching, not the file set consulted. So a config whose + // `env(VAR)` reference is backed by e.g. `supabase/.env.development` is genuinely + // Go-valid (Go's `godotenv.Load` calls `os.Setenv`, so the value is real ambient env + // by the time Go resolves it — `config.go:1260-1261`) and already passed + // `legacyCheckDbToml` and the real recreate above, but this narrower reload can + // still reject it. A `LegacySeedConfigLoadError` here is that env-file-set gap, not + // a genuinely invalid config — and recreate already dropped/rebuilt the DB, so + // aborting now would leave the reset half-done; warn and skip buckets so the reset + // finishes like Go instead. + yield* legacySeedBucketsRun({ + projectRef: "", + emitSummary: false, + interactive: false, + // Go loads nested env before `buckets.Run`, so `SUPABASE_YES` in `supabase/.env` + // auto-confirms bucket/vector/analytics prune prompts. + yes, + }).pipe( + Effect.catchTag("LegacySeedConfigLoadError", (error) => + output.raw( + `${legacyYellow("WARNING:")} skipped seeding storage buckets: ${error.message}\n`, + "stderr", + ), + ), + ); + } + + // "Finished supabase db reset on branch ." (both Aqua). + const branch = Option.getOrElse(yield* detectGitBranch(workdir), () => "main"); + yield* output.raw( + `Finished ${legacyAqua("supabase db reset")} on branch ${legacyAqua(branch)}.\n`, + "stderr", + ); +}); diff --git a/apps/cli/tests/helpers/legacy-local-reset.ts b/apps/cli/tests/helpers/legacy-local-reset.ts new file mode 100644 index 0000000000..7749190d23 --- /dev/null +++ b/apps/cli/tests/helpers/legacy-local-reset.ts @@ -0,0 +1,177 @@ +import { Effect, Layer, PlatformError, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +/** + * A minimal `docker`/`podman` CLI spawner mock + default happy-path route for + * `legacyResetLocalDatabase`'s real, native container-recreate flow — used + * wherever a test now drives a REAL in-process local reset instead of a + * subprocess/seam stub (CLI-2062: `db schema declarative`'s smart-target/sync + * recovery reset). Mirrors `commands/db/reset/reset.integration.test.ts`'s own + * `mockContainerCliSpawner`/`defaultLocalResetRoute` (that file predates this + * hoist and keeps its own copy, adapted for its container-REMOVE-then-recreate + * assertions) — same shape here, hoisted for the two `db schema declarative` + * callers so they don't each duplicate it again. + */ + +export interface LegacySpawnRecord { + readonly args: ReadonlyArray; +} + +export type LegacyRouteResult = { + readonly exitCode?: number; + readonly stdout?: ReadonlyArray; + readonly stderr?: ReadonlyArray; +}; + +export function mockContainerCliSpawner(route: (args: ReadonlyArray) => LegacyRouteResult) { + 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({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }), + ); + } + + 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(6000 + spawned.length), + stdout: Stream.fromIterable(stdoutBytes), + stderr: Stream.fromIterable(stderrBytes), + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode ?? 0)), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ), + ); + + return { + layer, + get spawned() { + return spawned; + }, + }; +} + +export interface LegacyDefaultLocalResetRouteOpts { + readonly running?: boolean; + readonly kongMissing?: boolean; + readonly kongNotRunning?: boolean; + readonly storageMissing?: boolean; +} + +const HEALTHY_STATE = '{"Running":true,"Status":"running","Health":{"Status":"healthy"}}'; +const STOPPED_STATE = '{"Running":false,"Status":"exited"}'; + +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); +} + +/** + * A happy-path Docker CLI route for `legacyResetLocalDatabase`'s PG15+ + * container-recreate — everything succeeds (running, healthy, no restart + * failures) unless overridden. `projectId` must match the `LegacyCliConfig` + * mock's own `projectId` (both default to `"test"`), since container names are + * derived from it (`supabase_db_`, `supabase_kong_`, + * `supabase_storage_`). + */ +export function defaultLocalResetRoute( + projectId = "test", + opts: LegacyDefaultLocalResetRouteOpts = {}, +) { + const dbId = `supabase_db_${projectId}`; + const kongId = `supabase_kong_${projectId}`; + const storageId = `supabase_storage_${projectId}`; + return (args: ReadonlyArray): LegacyRouteResult => { + 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") return { exitCode: 0 }; + if (args[0] === "exec" && args[1] === kongId) return { exitCode: 0 }; + if (args[0] === "container" && args[1] === "inspect") { + const id = args[2] ?? ""; + if (id === kongId) { + if (opts.kongMissing === true) + return { exitCode: 1, stderr: [`Error: No such container: ${id}`] }; + return { stdout: [opts.kongNotRunning === true ? STOPPED_STATE : HEALTHY_STATE] }; + } + if (id === storageId) { + if (opts.storageMissing === true) + return { exitCode: 1, stderr: [`Error: No such container: ${id}`] }; + return { stdout: [HEALTHY_STATE] }; + } + if (id === dbId && opts.running === false) { + return { exitCode: 1, stderr: [`Error: No such container: ${id}`] }; + } + return { stdout: [HEALTHY_STATE] }; + } + if (args[0] === "logs") return { exitCode: 0 }; + if (args[0] === "ps") return { stdout: [] }; + return { exitCode: 0 }; + }; +} + +/** Selects the `docker create` argv for the recreated `db` container, if any. */ +export const legacyLocalResetCreateArgs = ( + spawned: ReadonlyArray, +): ReadonlyArray | undefined => spawned.find((s) => s.args[0] === "create")?.args; + +/** `docker container rm -f ` targets — the id is argv[3], after the `-f` flag at argv[2]. */ +export const legacyLocalResetRemovedContainers = ( + spawned: ReadonlyArray, +): ReadonlyArray => + spawned + .filter((s) => s.args[0] === "container" && s.args[1] === "rm") + .map((s) => s.args[3] ?? ""); + +/** + * An HTTP client that answers every request with an empty `200 OK` — satisfies + * `legacyAwaitStorageReady`'s static `HttpClient.HttpClient` requirement without + * this route ever really being reached (storage health is checked purely via + * the container-CLI spawner above). + */ +export const alwaysReadyHttpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))), + ), +); diff --git a/apps/cli/tests/helpers/legacy-mocks.ts b/apps/cli/tests/helpers/legacy-mocks.ts index 1242bd1d44..311ec8f621 100644 --- a/apps/cli/tests/helpers/legacy-mocks.ts +++ b/apps/cli/tests/helpers/legacy-mocks.ts @@ -267,11 +267,20 @@ export function mockLegacyLoginApi( export function mockLegacyTelemetryStateTracked(): { readonly layer: Layer.Layer; readonly flushed: boolean; + /** + * Number of `flush` calls — beyond the plain `flushed` boolean, this lets a + * test prove a command's own `Effect.ensuring` finalizer fired EXACTLY once + * even when its body calls an in-process helper (e.g. `legacyResetLocalDatabase`, + * CLI-2062) that could, if it wrongly owned a second finalizer, double the + * count instead of leaving it at 1. + */ + readonly flushCount: number; readonly stitchedDistinctId: string | undefined; readonly clearedDistinctId: boolean; readonly identityReset: boolean; } { let flushed = false; + let flushCount = 0; let stitchedDistinctId: string | undefined; let clearedDistinctId = false; let identityReset = false; @@ -279,6 +288,7 @@ export function mockLegacyTelemetryStateTracked(): { get flush() { return Effect.sync(() => { flushed = true; + flushCount += 1; }); }, stitchLogin: (distinctId: string) => @@ -301,6 +311,9 @@ export function mockLegacyTelemetryStateTracked(): { get flushed() { return flushed; }, + get flushCount() { + return flushCount; + }, get stitchedDistinctId() { return stitchedDistinctId; }, @@ -316,11 +329,14 @@ export function mockLegacyTelemetryStateTracked(): { export function mockLegacyLinkedProjectCacheTracked(): { readonly layer: Layer.Layer; readonly cached: boolean; + /** Number of `cache` calls — see {@link mockLegacyTelemetryStateTracked}'s own `flushCount`. */ + readonly cacheCount: number; readonly cachedRef: string | undefined; readonly cachedApiUrl: string | undefined; readonly cachedAccessToken: Option.Option> | undefined; } { let cached = false; + let cacheCount = 0; let cachedRef: string | undefined; let cachedApiUrl: string | undefined; let cachedAccessToken: Option.Option> | undefined; @@ -333,6 +349,7 @@ export function mockLegacyLinkedProjectCacheTracked(): { ) => Effect.sync(() => { cached = true; + cacheCount += 1; cachedRef = ref; cachedApiUrl = apiUrl; cachedAccessToken = accessToken; @@ -343,6 +360,9 @@ export function mockLegacyLinkedProjectCacheTracked(): { get cached() { return cached; }, + get cacheCount() { + return cacheCount; + }, get cachedRef() { return cachedRef; }, From ef499aa1a403f21782d71c93c80afeef6c71c976 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 7 Aug 2026 15:48:31 +0100 Subject: [PATCH 47/48] fix(cli): pass --nginx-conf on kong reload and use normalized schema_paths in db reset Bare `kong reload` in the local-db reset path regenerated nginx.conf from Kong's default template, dropping the custom email_templates listener and reintroducing #6059. Go's reloadKong (reset.go:269) always passes --nginx-conf /home/kong/custom_nginx.template, same as the functions serve reload path already does. The PG14 declarative reset also passed the raw, unresolved db.migrations.schema_paths into MigrateAndSeed instead of the normalized toml.schemaPaths the PG15 path already uses, so schema-path patterns weren't supabase/-prefix-resolved or SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS-overridden. Addresses https://github.com/supabase/cli/pull/6026#pullrequestreview-4883348016 --- .../db/reset/reset.integration.test.ts | 26 +++++++++++++++++++ .../db-bootstrap/recreate-local-database.ts | 2 +- .../shared/db-bootstrap/restart-services.ts | 18 +++++++++---- .../restart-services.unit.test.ts | 20 +++++++++++++- 4 files changed, 59 insertions(+), 7 deletions(-) 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 b5a837dac1..21287fbcf8 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 @@ -1134,6 +1134,32 @@ describe("legacy db reset", () => { }); }, ); + + it.live( + "resolves db.migrations.schema_paths against supabase/ before applying it on an experimental PG14 reset", + () => { + // `legacyRecreateLocalDatabase14` must pass the NORMALIZED `toml.schemaPaths` + // (`supabase/`-prefix-resolved by `legacyCheckDbToml`) into the final + // `legacyMigrateAndSeed` call, not the raw, unresolved config value — the raw + // `["schema.sql"]` pattern would glob-match against the WORKDIR root (where no + // such file exists), failing the whole reset, instead of `supabase/schema.sql` + // (where this test actually places the file). + const { layer, conn } = setup(tmp.current, { + toml: + 'project_id = "test"\n[db]\nmajor_version = 14\n[db.migrations]\nschema_paths = ["schema.sql"]\n', + files: { "supabase/schema.sql": "create table schema_paths_marker ();" }, + args: ["db", "reset", "--local"], + isLocal: true, + experimental: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect( + conn.execs.some((sql) => sql.includes("create table schema_paths_marker ()")), + ).toBe(true); + }); + }, + ); }); describe("local reset — health timeouts", () => { 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 index c3305e2b67..8db1504e85 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts @@ -460,7 +460,7 @@ const legacyRecreateLocalDatabase14 = ( seed: legacyResolveResetSeedConfig(toml.seed, input.seedFlags, path), experimental: setup.experimental, pgDeltaEnabled: toml.pgDelta.enabled, - schemaPaths: setup.config.db.migrations.schema_paths, + schemaPaths: toml.schemaPaths, }); }), ); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/restart-services.ts b/apps/cli/src/legacy/shared/db-bootstrap/restart-services.ts index c440aa255d..fdc0df74b0 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/restart-services.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/restart-services.ts @@ -182,10 +182,13 @@ function legacyExecCaptureCombined( * 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. + * no stale cache to flush (`return nil`); otherwise `docker exec kong reload + * --nginx-conf /home/kong/custom_nginx.template` (the flag is required — a bare + * `kong reload` regenerates nginx.conf from Kong's default template and drops the + * custom `email_templates` server, reintroducing #6059), 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, @@ -204,7 +207,12 @@ function legacyReloadKong( ); } if (!inspected.success.running) return; - const result = yield* legacyExecCaptureCombined(spawner, kongId, ["kong", "reload"]); + const result = yield* legacyExecCaptureCombined(spawner, kongId, [ + "kong", + "reload", + "--nginx-conf", + "/home/kong/custom_nginx.template", + ]); if (result.exitCode !== 0) { const trimmed = result.output.trim(); // Go's `DockerExecOnceWithStream` (`utils/docker.go:646-648`) sets a FIXED constant 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 index f7ff729eb0..f2a05154d1 100644 --- 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 @@ -102,7 +102,14 @@ describe("legacyRestartServicesAndReloadKong", () => { "supabase_pooler_proj", ]), ); - expect(mock.spawned.some((args) => args[0] === "exec" && args[1] === KONG_ID)).toBe(true); + expect(mock.spawned).toContainEqual([ + "exec", + KONG_ID, + "kong", + "reload", + "--nginx-conf", + "/home/kong/custom_nginx.template", + ]); }), ); }); @@ -282,6 +289,17 @@ describe("legacyRestartServicesAndReloadKong", () => { 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}`); + // Pins the `--nginx-conf` flag (reset.go:269, reset_test.go:512) — a bare + // `kong reload` regenerates nginx.conf from Kong's default template and + // drops the custom `email_templates` server, reintroducing #6059. + expect(mock.spawned).toContainEqual([ + "exec", + KONG_ID, + "kong", + "reload", + "--nginx-conf", + "/home/kong/custom_nginx.template", + ]); }), ); }); From 9df1d7b2f09475d3a5d8b1251b327e80375ab0f9 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 7 Aug 2026 15:54:51 +0100 Subject: [PATCH 48/48] style(cli): fix oxfmt formatting in reset.integration.test.ts Collapses the toml fixture string in the schema_paths regression test onto one line per oxfmt's line-width rule. --- .../cli/src/legacy/commands/db/reset/reset.integration.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 21287fbcf8..fd703e747d 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 @@ -1145,8 +1145,7 @@ describe("legacy db reset", () => { // such file exists), failing the whole reset, instead of `supabase/schema.sql` // (where this test actually places the file). const { layer, conn } = setup(tmp.current, { - toml: - 'project_id = "test"\n[db]\nmajor_version = 14\n[db.migrations]\nschema_paths = ["schema.sql"]\n', + toml: 'project_id = "test"\n[db]\nmajor_version = 14\n[db.migrations]\nschema_paths = ["schema.sql"]\n', files: { "supabase/schema.sql": "create table schema_paths_marker ();" }, args: ["db", "reset", "--local"], isLocal: true,