diff --git a/apps/cli/docs/binary-distribution.md b/apps/cli/docs/binary-distribution.md index 9a5e9bf098..ee7dceb9ce 100644 --- a/apps/cli/docs/binary-distribution.md +++ b/apps/cli/docs/binary-distribution.md @@ -101,7 +101,7 @@ This: ### Removed commands -`apps/cli-go/internal/start` (Go's `supabase start` implementation) was deleted outright (CLI-1966), not just excluded from the shipped binary. Native TS `start` talks to Docker directly and never proxies to Go for it, and no other still-live TS→Go delegation seam (`db test`, `db branch`/`db remote`, `db diff --use-pgadmin`/`--use-pg-schema`, `db pull --experimental`, the hidden `db __catalog` seam (baseline/declarative modes only — the migrations mode was removed by CLI-1959, and the sibling hidden `db __shadow` seam was removed outright by CLI-1956) — the sibling hidden `db __db-bootstrap` seam was removed outright by CLI-1955, once native `db reset --local` became its last remaining caller — etc.) ever called into `internal/start` either — a repo-wide `grep` for the import confirmed the only reference anywhere in `apps/cli-go` was `start`'s own cobra registration. `internal/start` alone previously accounted for roughly half the shipped Go binary's size via its exclusive dependency tree (docker-compose/v2, buildx, buildkit, k8s client-go, aws-sdk-go-v2, notary, secret-detector), which `go mod tidy` dropped entirely once the package was deleted. `cmd/start.go` keeps `start`'s cobra registration and flag surface (needed by the `__complete` passthrough) but its `RunE` is a permanent stub returning a "not available in supabase-go" error — see `apps/cli-go/cmd/start_test.go` for the pinned error text. There is no longer a `bundled` build tag: with no second implementation to select between, the Go CLI's `cmd` package has only one `start`. +`apps/cli-go/internal/start` (Go's `supabase start` implementation) was deleted outright (CLI-1966), not just excluded from the shipped binary. Native TS `start` talks to Docker directly and never proxies to Go for it, and no other still-live TS→Go delegation seam (`db test`, `db branch`/`db remote`, `db diff --use-pg-schema`, `db pull --experimental`, the hidden `db __catalog` seam (baseline/declarative modes only — the migrations mode was removed by CLI-1959, and the sibling hidden `db __shadow` seam was removed outright by CLI-1956) — the sibling hidden `db __db-bootstrap` seam was removed outright by CLI-1955, once native `db reset --local` became its last remaining caller — etc.) ever called into `internal/start` either — a repo-wide `grep` for the import confirmed the only reference anywhere in `apps/cli-go` was `start`'s own cobra registration. `internal/start` alone previously accounted for roughly half the shipped Go binary's size via its exclusive dependency tree (docker-compose/v2, buildx, buildkit, k8s client-go, aws-sdk-go-v2, notary, secret-detector), which `go mod tidy` dropped entirely once the package was deleted. `cmd/start.go` keeps `start`'s cobra registration and flag surface (needed by the `__complete` passthrough) but its `RunE` is a permanent stub returning a "not available in supabase-go" error — see `apps/cli-go/cmd/start_test.go` for the pinned error text. There is no longer a `bundled` build tag: with no second implementation to select between, the Go CLI's `cmd` package has only one `start`. ## See Also diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 6dd1135b63..a50ce961df 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -82,7 +82,7 @@ These commands exist in the TS CLI today but have no direct top-level equivalent | 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 natively-provisioned live shadow (CLI-1956 removed the last Go delegation on shadow-database provisioning — the hidden `db __shadow` seam no longer exists); `--use-pgadmin` / `--use-pg-schema` still 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` and the other in-flight M9 issues are done. | +| `db diff` | `ported` | `legacy/commands/db/diff/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra / pgAdmin diff engines, all against a natively-provisioned live shadow (CLI-1956 removed the last Go delegation on shadow-database provisioning — the hidden `db __shadow` seam no longer exists; CLI-1968 ported `--use-pgadmin` itself to a native differ-container invocation). `--use-pg-schema` is now the CLI's sole remaining Go delegation on this command, and 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. | | `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. | @@ -224,7 +224,7 @@ Legend: - `ported`: Phase 1+ native TS implementation exists (Effect-based business logic in `.handler.ts`). An internal, flag-gated seam that still shells out to the Go - binary for one specific sub-path (e.g. `db diff --use-pgadmin`, `db pull --experimental`) + binary for one specific sub-path (e.g. `db diff --use-pg-schema`, `db pull --experimental`) does not disqualify a command from `ported` — what matters is whether the handler itself is native, not whether every code path is Go-binary-free. - `wrapped`: Phase 0 proxy wrapper — the handler's own body forwards the whole @@ -319,7 +319,7 @@ Legend: | `test db` | `ported` | [`../src/legacy/commands/test/db/db.command.ts`](../src/legacy/commands/test/db/db.command.ts) | | `test new` | `ported` | [`../src/legacy/commands/test/new/new.command.ts`](../src/legacy/commands/test/new/new.command.ts) | | `seed buckets` | `ported` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | -| `db diff` | `ported` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) — native pg-delta / migra; `--use-pgadmin` / `--use-pg-schema` delegate to Go. `--use-pg-schema` is deprecated (CLI-1960) — a keep-in-Go exception (in-process `stripe/pg-schema-diff` library, no TS/container equivalent), not yet the sole remaining Go delegation (`--use-pgadmin` and other in-flight M9 issues still delegate too); migrate to the pg-delta engine or the default migra engine. | +| `db diff` | `ported` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) — native pg-delta / migra / pgAdmin (CLI-1968 ported `--use-pgadmin` to a native differ-container invocation); `--use-pg-schema` is now the sole remaining Go delegation on this command. It is deprecated (CLI-1960) — a keep-in-Go exception (in-process `stripe/pg-schema-diff` library, no TS/container equivalent); migrate to the pg-delta engine or the default migra engine. | | `db dump` | `ported` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | | `db push` | `ported` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | | `db pull` | `ported` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — native pg-delta / migra; `--declarative` (deprecated alias `--use-pg-delta`) + `--diff-engine` (migra\|pg-delta); initial-migra pull dumps the schema natively (`pg_dump`) + appends the diff; `--experimental` structured dump still delegates to Go, deprecated in favor of `--declarative` (CLI-1957) | diff --git a/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md index c27fae89dd..4e30cb87c4 100644 --- a/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md @@ -1,33 +1,36 @@ # `supabase db diff` Native Effect port. Diffs the local project's expected schema (a throwaway shadow -database) against a target database (local / linked / `--db-url`), using either -the native pg-delta or migra engine (both run inside Docker via edge-runtime). The -`--use-pgadmin` / `--use-pg-schema` engines delegate to the bundled Go binary. +database) against a target database (local / linked / `--db-url`), using one of +three native engines: pg-delta or migra (both run inside Docker via edge-runtime), +or pgAdmin (CLI-1968 — a native `docker run` of the differ container, no +edge-runtime involved). `--use-pg-schema` is the CLI's sole remaining Go +delegation on this command — a documented keep-in-Go exception (CLI-1960), not a +pending port. ## Files Read -| Path | Format | When | -| ------------------------------------------------------------------------------------------------------------------------------------ | ---------- | ----------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (db port/password, `[experimental.pgdelta]`, deno_version) | -| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | shadow provisioning (all native targets, and the explicit `--from/--to migrations` cache miss) | -| `api.tls.cert_path` / `api.tls.key_path` (under `/supabase/`) | PEM | shadow provisioning, when `api.enabled && api.tls.enabled` | -| `/supabase/migrations/*.sql` | SQL | shadow provisioning (applied to the shadow source) | -| `/supabase/roles.sql` | SQL | shadow provisioning, PG14 and PG15 alike (unlike `db reset`'s PG15-only local path); missing file tolerated | -| `[db.migrations].schema_paths` globs / `/supabase/database/**` (pg-delta declarative dir) / `/supabase/schemas/**` | SQL | local target: 3-source declarative-schema fallback ladder, first non-empty source wins | -| `~/.supabase/access-token` | plain text | `--linked` / `--db-url` with no `SUPABASE_ACCESS_TOKEN` | -| `/supabase/.temp/project-ref` | plain text | `--linked` ref resolution | -| `/supabase/.temp/pgdelta/*.json` | JSON | explicit `--from/--to migrations` catalog (cache) | +| Path | Format | When | +| ------------------------------------------------------------------------------------------------------------------------------------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/supabase/config.toml` | TOML | always (db port/password, `[experimental.pgdelta]`, deno_version) | +| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | shadow provisioning (all native targets, and the explicit `--from/--to migrations` cache miss) | +| `api.tls.cert_path` / `api.tls.key_path` (under `/supabase/`) | PEM | shadow provisioning, when `api.enabled && api.tls.enabled` | +| `/supabase/migrations/*.sql` | SQL | shadow provisioning (applied to the shadow source) — `--use-pgadmin` too, via the SAME `legacyMigrateShadowDatabase` | +| `/supabase/roles.sql` | SQL | shadow provisioning, PG14 and PG15 alike (unlike `db reset`'s PG15-only local path); missing file tolerated | +| `[db.migrations].schema_paths` globs / `/supabase/database/**` (pg-delta declarative dir) / `/supabase/schemas/**` | SQL | local target: 3-source declarative-schema fallback ladder, first non-empty source wins — `--use-pgadmin` never reads this ladder (Go's `pgadmin.go` calls `MigrateShadowDatabase` directly, never `PrepareShadowSource`) | +| `~/.supabase/access-token` | plain text | `--linked` / `--db-url` with no `SUPABASE_ACCESS_TOKEN` | +| `/supabase/.temp/project-ref` | plain text | `--linked` ref resolution | +| `/supabase/.temp/pgdelta/*.json` | JSON | explicit `--from/--to migrations` catalog (cache) | ## Files Written -| Path | Format | When | -| ----------------------------------------------------------- | ------ | ----------------------------------------------- | -| `/supabase/migrations/_.sql` | SQL | `--file ` and the diff is non-empty | -| `` (from `--output` / `-o`) | SQL | explicit `--from/--to` mode with `--output` | -| `/supabase/.temp/pgdelta/*.json` | JSON | explicit `--from/--to migrations` catalog cache | -| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | -| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | +| Path | Format | When | +| ----------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/migrations/_.sql` | SQL | `--file ` and the diff is non-empty (also `--use-pgadmin --file`: always exactly one file — pgAdmin never produces a multi-unit plan) | +| `` (from `--output` / `-o`) | SQL | explicit `--from/--to` mode with `--output` | +| `/supabase/.temp/pgdelta/*.json` | JSON | explicit `--from/--to migrations` catalog cache | +| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | ## Docker @@ -42,8 +45,19 @@ the native pg-delta or migra engine (both run inside Docker via edge-runtime). T (`legacyResolveMigrationsCatalogRef` -> `exportViaShadowCatalog`, `legacy-pgdelta.cache.ts`), called with `targetLocal: false`/`usePgDelta: false` to skip the declarative-schema-override branch — not a second, `__catalog`-specific shadow, and not a shared `mode: "diff"` parameter - (that seam-era concept no longer exists). + (that seam-era concept no longer exists). `--use-pgadmin` provisions its OWN shadow via a + narrower composition — `legacyCreateShadowDatabase` -> health-wait -> `legacyMigrateShadowDatabase` + directly (`diff.handler.ts`'s pgadmin branch) — with no declarative-schema-override branch and + no `targetUrlOverride`, matching Go's `pgadmin.go` calling `MigrateShadowDatabase` directly + rather than `PrepareShadowSource`. - `supabase/migra` container — the migra OOM bash fallback only. +- **Differ container** (`--use-pgadmin`, CLI-1968) — `supabase/pgadmin-schema-diff:cli-0.0.5` + (`dockerfileServiceImage("differ")`). One `docker run --rm` when no `--schema` is given; one + run per `--schema` value, in flag order. Runs on the project's Docker network (`--network-id` + or the generated `supabase_network_` — never the host network, unlike the migra + bash fallback), with `--add-host host.docker.internal:host-gateway` on Linux only, and both + `com.supabase.cli.project`/`com.docker.compose.project` labels — no env vars, bind mounts, or + working-directory override. ## API Routes (linked path, via the db-config resolver) @@ -54,27 +68,55 @@ the native pg-delta or migra engine (both run inside Docker via edge-runtime). T | GET/DELETE | `/v1/projects/{ref}/network-bans` | Bearer | Unban during pooler login retry | | GET | `/v1/projects/{ref}` | Bearer | Linked-project cache (post-run) | +`--use-pgadmin --linked` performs every one of these calls in TS now (CLI-1968): Go's +`RunPgAdmin` used to run entirely inside the delegated Go binary, so the temp-role +mint / pooler fallback / network-ban retry happened in the Go child; they now run +natively as part of this command's own target resolve, ahead of the differ container. + ## Environment Variables -| Variable | Purpose | Required? | -| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | --------- | -| `SUPABASE_ACCESS_TOKEN` | auth for `--linked` | no | -| `SUPABASE_DB_PASSWORD` | remote DB password (linked) | no | -| `SUPABASE_DB_SHADOW_PORT` | shadow container's host port (`db.shadow_port`) — NOT `SUPABASE_DB_PORT`, which the shadow never reads | no | -| `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | shadow container-config overrides, same as `db start`/`db reset` | no | -| `SUPABASE_PROJECT_ID` | overrides the shadow container's project id/labels, same as `db start`/`db reset` (`utils.DbId`) | no | -| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the shadow container/network onto an existing Docker network | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | force pg-delta engine | no | -| `PGDELTA_DEBUG` | pg-delta debug capture | no | -| `PGDELTA_NPM_REGISTRY` | scoped `@supabase` npm registry for edge-runtime | no | -| `SUPABASE_SSL_DEBUG` | migra SSL debug logging | no | +| Variable | Purpose | Required? | +| ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_ACCESS_TOKEN` | auth for `--linked` | no | +| `SUPABASE_DB_PASSWORD` | remote DB password (linked) | no | +| `SUPABASE_DB_SHADOW_PORT` | shadow container's host port (`db.shadow_port`) — NOT `SUPABASE_DB_PORT`, which the shadow never reads | no | +| `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | shadow container-config overrides, same as `db start`/`db reset` | no | +| `SUPABASE_PROJECT_ID` | overrides the shadow container's project id/labels, same as `db start`/`db reset` (`utils.DbId`) | no | +| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the shadow container/network onto an existing Docker network | no | +| `SUPABASE_EXPERIMENTAL_PG_DELTA` | force pg-delta engine | no | +| `PGDELTA_DEBUG` | pg-delta debug capture | no | +| `PGDELTA_NPM_REGISTRY` | scoped `@supabase` npm registry for edge-runtime | no | +| `SUPABASE_SSL_DEBUG` | migra SSL debug logging | no | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the differ's / shadow's image registry (shell **or** project `.env`, applied for the run via `legacyApplyProjectEnv`, matching `db push`/`db pull`/`db dump`) | no | + +`SUPABASE_DB_SHADOW_PORT`/`SUPABASE_NETWORK_ID`/`--network-id`/`SUPABASE_PROJECT_ID`/ +`SUPABASE_DB_HEALTH_TIMEOUT` all apply to `--use-pgadmin` too — its shadow is provisioned +through the same primitives. + +`SUPABASE_EXPERIMENTAL_PG_DELTA` is **read, no effect** on the pgadmin path: the pg-delta +engine-selection lookup (`legacyShouldUsePgDelta`) runs unconditionally, before the +`--use-pgadmin` branch, but the pgadmin branch is chosen first and never consults the +resulting `useDelta` value. + +`SUPABASE_INTERNAL_IMAGE_REGISTRY` applies to the differ's own image resolution too. The +docker-run layer's resolver (`legacy-docker-run.layer.ts`) is built once, statically, with +no `projectEnvValues` in scope, so it falls back to reading `process.env` directly at +`runCapture` call time — the handler's own `legacyApplyProjectEnv(cfg.projectEnv)` call +(right after the config load) is what makes a registry override set only in +`supabase/.env`/project-root dotenv (not the ambient shell) visible to it by then, mirroring +Go's `loadNestedEnv` `os.Setenv`ing the project `.env` during config load +(`pkg/config/config.go:788-791`) before `GetRegistry()` +(`internal/utils/docker.go:221-231,244-246`) ever reads it. + +Explicitly **not** read by `--use-pgadmin`: `PGDELTA_*`, `SUPABASE_SSL_DEBUG` (both +migra/pg-delta-engine-specific). ## Exit Codes -| Code | Condition | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------- | -| `0` | success; empty diff ("No schema changes found") | -| `1` | `--from` without `--to`; engine-flag mutex; target mutex; unknown explicit target; connection/shadow/engine failure; file IO error | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success; empty diff ("No schema changes found") | +| `1` | `--from` without `--to`; engine-flag mutex; target mutex; unknown explicit target; connection/shadow/engine failure; file IO error; local db not running (`--use-pgadmin`); differ container non-zero exit; unparseable `--json-diff` output | ## Output @@ -88,16 +130,50 @@ explicit `--output` is set. ### `--output-format json` / `stream-json` Progress strings still go to stderr; stdout carries a single structured envelope -`{ diff, file, schemas, engine, dropStatements }` instead of the raw SQL. +`{ diff, file, files, schemas, engine, dropStatements }` instead of the raw SQL. + +### `--use-pgadmin` (CLI-1968) + +- **Status lines go to STDOUT in text mode, not stderr** — Go's NON-TTY `fakeProgram` prints + `StatusMsg` via `fmt.Println` (`tea.go:57-70`), unlike the migra/pg-delta path's + `fmt.Fprintln(os.Stderr, …)` diagnostics. So `db diff --use-pgadmin > out.sql` captures them, + exactly as Go's non-TTY invocation does — **this claim holds for non-TTY invocations only**; + on a real terminal Go instead runs the `bubbletea` renderer, repainting ephemeral frames + rather than appending printed lines, which this port has no equivalent for and does not + target. In `json`/`stream-json` mode these are diagnostics, not payload, so they redirect to + STDERR instead — see below. +- **Progress-streaming UX delta**: Go live-streams progress as the differ emits it — + `NewDiffStream` pipes the container's stderr through an `io.Pipe`, scanned by a goroutine + WHILE the container is still running, so a status line prints the instant its underlying + stderr line arrives. This port batches instead: `LegacyDockerRun.runStream` only exposes an + `onStdout` hook (no `onStderr` equivalent), so this port buffers each run's stderr via + `runCapture` and only filters/emits its status lines once that run's container has already + exited — one status BATCH per `--schema` run, not a continuous stream. That batch is + processed and emitted BEFORE this port's own exit-code check, matching Go's stderr goroutine + running concurrently with (i.e. ahead of) the container's own exit — so a run that goes on to + exit non-zero still has its own captured statuses printed first, not dropped. See + `legacy-pgadmin-diff.ts`'s own doc comment on `legacyDiffSchemaPgAdmin` for the full + rationale and the possible follow-up (adding an `onStderr` hook to `runStream`). +- Order: `Creating shadow database...` → shadow setup diagnostics (stderr, shared + with the migra/pg-delta path) → `Diffing local database with current migrations...` + → per-schema `Diffing schema: ` + filtered progress statuses → the SQL / + `No schema changes found` / the `--file` write warning. +- **No** `Finished supabase db diff on branch .` line and **no** drop-statement + warning — both live in Go's `diff.Run` (`diff.go:38-47`), which the pgadmin path + bypasses entirely. +- `json`/`stream-json`: status lines redirect to STDERR instead of STDOUT (stdout stays + payload-only, CLI-1546); envelope + `{ diff, file, files, schemas, engine: "pgadmin", dropStatements: [] }` — + `dropStatements` is always empty because Go performs no drop scan on this engine. ## Notes / Delegation - `--use-migra` (default), `--use-pgadmin`, `--use-pg-schema`, `--use-pg-delta` are a mutually-exclusive engine group; `--db-url` / `--linked` / `--local` are a mutually-exclusive target group (default `--local`). -- `--use-pgadmin` and `--use-pg-schema` rebuild the argv and exec the bundled Go - binary (their side effects are Go's); the Go child's telemetry is disabled so the - single `cli_command_executed` event comes from this TS command. +- `--use-pg-schema` rebuilds the argv and exec's the bundled Go binary (its side + effects are Go's); the Go child's telemetry is disabled so the single + `cli_command_executed` event comes from this TS command. - Explicit `--from`/`--to` mode always uses pg-delta and writes to `--output` (or stdout). - The explicit `migrations` target resolves natively (CLI-1959): a bare migrations-content hash cache lookup (`/supabase/.temp/pgdelta/catalog-local-migrations--.json`, @@ -106,6 +182,74 @@ Progress strings still go to stderr; stdout carries a single structured envelope no longer the `db __shadow` seam) plus a native pg-delta catalog export. No hidden Go `db schema declarative __catalog` subprocess runs for this path any more. +### `--use-pgadmin` parity quirks and deliberate divergence (CLI-1968) + +- `source`/`target` are INVERTED relative to the migra/pg-delta path: `source` is the + USER'S db, `target` is the SHADOW (Go's `pgadmin.go:85-86`). +- The shadow `target` URL is a raw `Sprintf`, not `legacyToPostgresURL`: hardcoded + `127.0.0.1` and `postgres:postgres`, ignoring `SUPABASE_SERVICES_HOSTNAME`/`[db] password`. +- `AssertSupabaseDbIsRunning` runs for `--linked`/`--db-url` too, and AFTER config load + + target resolution — every other engine on this command never runs this check at all. +- The `NOTE: …DESKTOP mode.` prefix (`supabase/pgadmin4#24`) is trimmed from the front of + EACH run's own stdout independently (each run is parsed on its own — see the "Deliberate + divergence" entry below), not just the front of a single, first run's buffer. +- The differ's stderr is filtered by the progress-line regex and non-matching lines are + dropped, so a differ failure surfaces only `error running container: exit ` — even + under `--debug`. +- `(.*)([0-9]{2,3})%` greedy-submatch quirk (e.g. `Diffing 100%` → status `Diffing 1`, + progress silently dropped). +- Internal-schema filtering is exact string membership, not glob expansion — a + `group_name`/`source_schema_name` of literal `pg_catalog` is KEPT, since only the + literal string `"pg_*"` (not a real glob) is in the list. +- JSON-parse error text cannot byte-match Go's `encoding/json` message; this port + prefixes it with the stable string `failed to parse schema diff output:`. + +**Deliberate divergence, not bug-for-bug parity:** Go's `DiffStream` (`container_output.go:79,87`) +declares `Stdout()`/`Collect()` on a VALUE receiver, so every call operates on its OWN copy of +the struct — the differ's stdout, written via one call's `Stdout()`, is never visible to a +LATER `Collect()` call's own (separate, always-empty) copy. The practical effect: the real Go +CLI's `--use-pgadmin` ALWAYS reports "No schema changes found" (exit 0) — it never writes a +migration file and never hits a JSON-parse error, regardless of the differ's actual output or +`--schema` count. (`Stderr()`/progress is unaffected — `c.w` is a `*io.PipeWriter`, a reference +type shared across copies.) This port implements the INTENDED algorithm instead: every run's +stdout is genuinely parsed and its kept DDL entries are aggregated into one final diff, which +is what `NewDiffStream`'s own design clearly intends — so wherever the real Go binary silently +discards a genuine diff, this port produces it (or a real per-run parse failure). + +Getting there took two rounds. The first, literal-minded reading of "one shared buffer" glued +every run's raw stdout BYTES together before parsing once — which is neither Go-as-shipped +(always an empty, successful diff, since `Collect()` never sees real bytes at all) nor +Go-as-written-but-unreachable (which, had `Collect()` ever actually run against accumulated +bytes, would itself have failed to parse `>=2` concatenated JSON arrays the exact same way). +Both of those are nonsensical outcomes nobody would design for, so round two completes the +INTENDED algorithm instead of literally reproducing either one: each run's OWN stdout is +parsed independently (`legacyParsePgAdminDiffEntries`, trimming that run's own DESKTOP-mode +NOTE prefix off its own buffer), and every run's filtered DDLs are aggregated into a single +list before the header is rendered once (`legacyRenderPgAdminDiff`). A multi-`--schema` diff +where every run's own `--json-diff` output is independently well-formed now succeeds — Go's +own `[]DiffEntry`-per-run JSON shape was never designed to be concatenated and parsed as one +document, so a per-run parse is the evident intent, not literal buffer-sharing. A genuinely +malformed run (or a Go-parity-preserving concatenation WITHIN a single run's own buffer — see +`legacyProcessPgAdminDiffOutput`'s own doc comment, still exercised by this file's unit tests) +still fails with `invalid_output`, same as before. + +**Network reachability (settled, static ruling):** with the differ container on the project's +default Docker network (the compose bridge `supabase_network_`), `127.0.0.1` inside +it resolves to the differ's OWN loopback — so both the hardcoded shadow `target` and a local +`source` (`GetHostname()` → `127.0.0.1`) are unreachable from inside the differ container, in +BOTH implementations: identical argv, identical network, and identical hosts produce an +identical (unreachable) outcome on either binary, so this needs no live spot-check to settle. +`--network-id host` alone does NOT rescue the golden path either (see +`diff.live.test.ts`): the network override applies to every container the command starts, +including the shadow, whose `54320→5432` port publication is discarded under host +networking — so the hardcoded `target` at `127.0.0.1:` stays unreachable +while only the host-published `source` becomes reachable. Reaching both databases requires +`--network-id host` **plus** a `[db] shadow_port = 5432` config override — a contrived +setup no default user runs (identically contrived on the Go binary). That configuration is +where the `DiffStream` divergence above becomes user-visible: the real Go CLI still +reports "No schema changes found" no matter what the (now reachable) differ actually finds, +while this port reports the real diff. + ### `--use-pg-schema` is deprecated (CLI-1960) — keep-in-Go exception `--use-pg-schema` wraps the in-process Go library `stripe/pg-schema-diff` @@ -113,8 +257,8 @@ Progress strings still go to stderr; stdout carries a single structured envelope than a pending port because: - it runs **in-process** inside the Go binary, with no container/binary boundary - to re-invoke from TS — unlike `--use-pgadmin`, which shells out to a - container/binary path that could in principle be called from TS; + to re-invoke from TS — unlike `--use-pgadmin` (now native, CLI-1968), which shelled + out to a container/binary path that could in principle be called from TS; - no TS binding and no WASM build of the library exists, or is reasonably buildable, within the M9 "Final Cleanup — Go Removal" milestone's scope; - this specific exception (`db diff --use-pg-schema`) was pre-named when the M9 @@ -122,10 +266,10 @@ than a pending port because: The decision record is Linear issue CLI-1960 and the pull request that introduced this deprecation notice; re-open only if a TS/WASM binding for -`stripe/pg-schema-diff` ships. It will become the CLI's sole remaining Go delegation -once `--use-pgadmin`'s delegation and the rest of the M9 milestone's in-flight issues -are done — it is not there yet (the sibling `db __db-bootstrap` seam was already -removed outright by CLI-1955, and the `db __shadow` seam by CLI-1956). +`stripe/pg-schema-diff` ships. It **is** the CLI's sole remaining Go delegation on +`db diff` now that `--use-pgadmin`'s delegation is gone (CLI-1968) — the sibling +`db __db-bootstrap` seam was already removed outright by CLI-1955, and the +`db __shadow` seam by CLI-1956. Given that, the flag is now deprecated rather than ported: diff --git a/apps/cli/src/legacy/commands/db/diff/diff.errors.ts b/apps/cli/src/legacy/commands/db/diff/diff.errors.ts index 6da5113849..fb6cab412d 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.errors.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.errors.ts @@ -75,3 +75,83 @@ export class LegacyDbDiffWriteError extends Data.TaggedError("LegacyDbDiffWriteE return actionability.permission; } } + +/** + * The local database container is not running, or inspecting it failed — + * Go's `utils.ErrNotRunning` / `"failed to inspect service: %w"` via + * `AssertSupabaseDbIsRunning` (`apps/cli-go/internal/db/diff/pgadmin.go:51`, + * `internal/utils/misc.go:151-166`). Unlike every other engine on this command, + * `--use-pgadmin` runs this check even for `--linked`/`--db-url` — see + * `diff.handler.ts`'s pgadmin branch. + */ +export class LegacyDbDiffDbNotRunningError extends Data.TaggedError( + "LegacyDbDiffDbNotRunningError", +)<{ + readonly message: string; + readonly daemonDown?: boolean; + readonly suggestion?: string; +}> { + // Must stay character-identical to `LegacyLocalDbRunningError`'s classification + // (`legacy-db-bootstrap`'s equivalent local-db-not-running check) — the two are + // deliberately duplicated for this command's own `AssertSupabaseDbIsRunning` + // parity target, not shared, so keep them in sync by hand. + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.daemonDown === true + ? { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" } + : actionability.startStack; // same preset `reset-local-database.ts` uses + } +} + +/** + * Classic "assertNever" exhaustiveness helper: with every literal of + * `LegacyDbDiffPgAdminError["reason"]` handled by its own `case` below, `reason` + * narrows to `never` by the time it reaches this call — so a FUTURE reason added + * to the union without a matching `case` is a compile error here (its residual + * type inside `default:` would no longer be `never`), not a silently-absorbed + * classification. The parameter is intentionally unused at runtime: the drift + * guard (`error-actionability-coverage.unit.test.ts`) evaluates every getter + * against a field-less probe (`Object.create(prototype)`, no constructor args), + * so `this.reason` is genuinely runtime-`undefined` there, bypassing the type + * system entirely — this must still degrade to a valid declaration rather than + * `undefined`/a crash, so it returns the SAME fallback as the "differ" case. + */ +function legacyPgAdminUnreachableReason(_reason: never): CliErrorActionabilityDeclaration { + return actionability.dbFinding; +} + +/** + * The pgAdmin differ container failed to run, or its `--json-diff` output could + * not be parsed. `reason` is a closed union set at the docker/parse boundary — + * never inferred from `message` text. + */ +export class LegacyDbDiffPgAdminError extends Data.TaggedError("LegacyDbDiffPgAdminError")<{ + readonly message: string; + readonly reason: + | "differ" + | "invalid_output" + | "docker_daemon" + | "registry_pull" + | "image_inspect"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + switch (this.reason) { + case "docker_daemon": + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + case "registry_pull": + return { ...actionability.externalNetwork, fingerprint_suffix: "registry_pull" }; + // Malformed pinned-differ wire output is an internal contract violation, not a + // user input mistake — same precedent as pg-delta's own malformed-subprocess- + // output branch (`legacy-pgdelta.apply.ts`'s `"output_parse"` case). + case "invalid_output": + return { ...actionability.impossibleState, fingerprint_suffix: "invalid_content" }; + case "image_inspect": + return { ...actionability.invalidConfig, fingerprint_suffix: "image_inspect" }; + // "differ": a failing container is the user's own schema/connection, matching + // `LegacyMigraDiffError`'s default classification for the equivalent engine failure. + case "differ": + return actionability.dbFinding; + default: + return legacyPgAdminUnreachableReason(this.reason); + } + } +} diff --git a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts index 7e652e0b6f..73e461dedb 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -13,17 +13,24 @@ import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts" import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { legacyAqua, legacyYellow } from "../../../shared/legacy-colors.ts"; -import { legacyReadDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; +import { + legacyApplyProjectEnv, + legacyReadDbToml, +} from "../../../shared/legacy-db-config.toml-read.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import type { LegacyDbConnType } from "../../../shared/legacy-db-target-flags.ts"; import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; import { legacyMakeDir } from "../../../shared/legacy-make-dir.ts"; +import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts"; import { legacySchemaToCsvField } from "../../../shared/legacy-schema-flags.ts"; import { legacyFindDropStatements } from "../../../shared/legacy-sql-split.ts"; import { legacyBuildLocalDbContainerInputs } from "../../../shared/db-bootstrap/local-container-inputs.ts"; +import { legacyIsLocalDbRunning } from "../../../shared/db-bootstrap/local-db-running.ts"; +import { legacyWaitForHealthyServices } from "../../../shared/db-bootstrap/health-check.ts"; import { legacyCreateShadowDatabase, + legacyMigrateShadowDatabase, legacyRemoveShadowDatabase, } from "../../../shared/db-bootstrap/shadow-database.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; @@ -54,12 +61,14 @@ import { import type { LegacyDbDiffFlags } from "./diff.command.ts"; import { legacyClassifyExplicitRef, legacyUnknownTargetMessage } from "./diff.explicit.ts"; import { + LegacyDbDiffDbNotRunningError, LegacyDbDiffEngineConflictError, LegacyDbDiffExplicitFlagsError, LegacyDbDiffTargetFlagsError, LegacyDbDiffUnknownTargetError, LegacyDbDiffWriteError, } from "./diff.errors.ts"; +import { legacyDiffSchemaPgAdmin } from "./legacy-pgadmin-diff.ts"; // Go's `warnDiff` (`apps/cli-go/internal/db/diff/pgadmin.go:17`), shown after a // `--file` migration is written. @@ -77,18 +86,17 @@ Run ${legacyAqua("supabase db reset")} to verify that the new migration does not const warnPgSchemaDeprecated = `${legacyYellow("WARNING:")} "--use-pg-schema" is deprecated. Use the pg-delta engine ([experimental.pgdelta] enabled = true / --use-pg-delta) or the default migra engine instead.`; /** - * Rebuilds the `db diff` argv for the pgAdmin / pg-schema delegate path. Flags - * stay flags (the Go-proxy channel-parity rule). The explicit `--from`/`--to` and - * engine mutex are already handled before this runs, so it just forwards the - * engine flag that won plus the target / schema / file flags the user passed. + * Rebuilds the `db diff` argv for the `--use-pg-schema` delegate path — the CLI's + * sole remaining Go delegation on this command (CLI-1960's keep-in-Go exception: + * the in-process `stripe/pg-schema-diff` library has no TS/container equivalent; + * `--use-pgadmin` is native as of CLI-1968). Flags stay flags (the Go-proxy + * channel-parity rule). The explicit `--from`/`--to` and engine mutex are already + * handled before this runs, and the mutex guarantees `--use-migra`/`--use-pgadmin`/ + * `--use-pg-delta` are all unset whenever this is reached, so it just forwards + * `--use-pg-schema` plus the target / schema / file flags the user passed. */ -const rebuildDelegateArgs = (flags: LegacyDbDiffFlags): Array => { - const args = ["db", "diff"]; - const pushBool = (name: string, value: Option.Option) => { - // Engine flags act on their value, so only an explicitly-true one is - // meaningful; `Some(false)` equals the cobra default. - if (Option.isSome(value) && value.value) args.push(`--${name}`); - }; +const rebuildPgSchemaDelegateArgs = (flags: LegacyDbDiffFlags): Array => { + const args = ["db", "diff", "--use-pg-schema"]; const pushTarget = (name: string, value: Option.Option) => { // Target flags (linked/local) are *selectors*: Go's ParseDatabaseConfig keys // off `flag.Changed` before the value (`internal/utils/flags/db_url.go`), so a @@ -98,10 +106,6 @@ const rebuildDelegateArgs = (flags: LegacyDbDiffFlags): Array => { // different default target than the one the native path resolved. if (Option.isSome(value)) args.push(value.value ? `--${name}` : `--${name}=false`); }; - pushBool("use-migra", flags.useMigra); - pushBool("use-pgadmin", flags.usePgAdmin); - pushBool("use-pg-schema", flags.usePgSchema); - pushBool("use-pg-delta", flags.usePgDelta); if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); pushTarget("linked", flags.linked); pushTarget("local", flags.local); @@ -345,49 +349,45 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy return; } - // pgAdmin / pg-schema delegate to the bundled Go binary (Go's `RunPgAdmin` / - // `DiffPgSchema` are not ported). They are explicit engine selections that do - // not depend on config, so they short-circuit before the target resolve. - // Disable the child's telemetry so the single `cli_command_executed` event - // comes from this TS command's instrumentation. + // `--use-pg-schema` delegates to the bundled Go binary (Go's `DiffPgSchema` is not + // ported — CLI-1960 keep-in-Go exception). It is an explicit engine selection that + // does not depend on config, so it short-circuits before the target resolve. + // Disable the child's telemetry so the single `cli_command_executed` event comes + // from this TS command's instrumentation. `--use-pgadmin` no longer short-circuits + // here (CLI-1968): unlike `--use-pg-schema`, Go resolves the target in the root + // `PersistentPreRunE` *before* `RunPgAdmin` ever runs (`cmd/db.go:110` → + // `cmd/db.go:115`), so config validation, the `[remotes.]` merge print, and + // the linked temp-role mint all still happen for `--use-pgadmin` — see the native + // pgadmin branch further down, which reuses this function's own target resolve. const usePgAdmin = Option.getOrElse(flags.usePgAdmin, () => false); const usePgSchema = Option.getOrElse(flags.usePgSchema, () => false); - // Runs the delegated engine via the Go binary. In machine-output mode the - // child's stdout is captured and re-emitted as a structured envelope, so - // scripted callers get valid JSON instead of the Go child's raw SQL on stdout - // (CLI-1546: stdout is payload-only in machine mode). The delegated child owns - // any `--file` write, so the written migration path isn't introspectable here - // (reported as `file: null`). - const delegateDiff = (engine: "pgadmin" | "pg-schema") => - Effect.gen(function* () { - const env = { SUPABASE_TELEMETRY_DISABLED: "1" }; - if (output.format !== "text") { - const captured = yield* proxy.execCapture(rebuildDelegateArgs(flags), { - env, - suppressChildTelemetry: true, - }); - yield* output.success("Diff complete.", { - diff: captured, - file: null, - schemas: flags.schema, - engine, - }); - return; - } - yield* proxy.exec(rebuildDelegateArgs(flags), { env, suppressChildTelemetry: true }); - }); - if (usePgAdmin) { - yield* delegateDiff("pgadmin"); - return; - } if (usePgSchema) { // CLI-1960: TS-only deprecation notice, printed before delegating (in both // text and machine output modes — diagnostics stay stderr-only per CLI-1546). // The delegated Go `db diff --use-pg-schema` still prints its own experimental // warning itself in its RunE (`cmd/db.go`); this is additive, not a - // replacement, so don't drop it. Mirror the --use-pgadmin branch above. + // replacement, so don't drop it. yield* output.raw(`${warnPgSchemaDeprecated}\n`, "stderr"); - yield* delegateDiff("pg-schema"); + const env = { SUPABASE_TELEMETRY_DISABLED: "1" }; + // In machine-output mode the child's stdout is captured and re-emitted as a + // structured envelope, so scripted callers get valid JSON instead of the Go + // child's raw SQL on stdout (CLI-1546: stdout is payload-only in machine mode). + // The delegated child owns any `--file` write, so the written migration path + // isn't introspectable here (reported as `file: null`). + if (output.format !== "text") { + const captured = yield* proxy.execCapture(rebuildPgSchemaDelegateArgs(flags), { + env, + suppressChildTelemetry: true, + }); + yield* output.success("Diff complete.", { + diff: captured, + file: null, + schemas: flags.schema, + engine: "pg-schema", + }); + return; + } + yield* proxy.exec(rebuildPgSchemaDelegateArgs(flags), { env, suppressChildTelemetry: true }); return; } @@ -425,6 +425,15 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy linkedRefForCache = linkedRef; } const cfg = yield* legacyReadDbToml(fs, path, cliConfig.workdir, linkedRef); + // Make an allowlisted `supabase/.env` registry override visible to the + // synchronous `process.env` reader the pgAdmin differ's (and the migra/pg-delta + // shadow's) own image resolver falls back to, reverted when this scope closes. + // Go's `loadNestedEnv` `os.Setenv`s the project `.env` during config load + // (`pkg/config/config.go:788-791`), before `GetRegistry()` + // (`internal/utils/docker.go:221-231,244-246`) ever reads it — unlike every + // other native engine on this command, `db diff` never applied project env + // until now. + yield* legacyApplyProjectEnv(cfg.projectEnv); if (cfg.appliedRemote !== undefined) { yield* output.raw(`Loading config override: [remotes.${cfg.appliedRemote}]\n`, "stderr"); } @@ -509,118 +518,233 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy pgDeltaDefault, }); - yield* output.raw("Creating shadow database...\n", "stderr"); - const resolvedShadowImage = yield* localInputs.resolvePostgresImage; - const shadowInput = { - ...legacyShadowRunInputFromLocalContainerInputs( + // pgAdmin's own text-mode status lines go to STDOUT, not stderr: only Go's NON-TTY + // `fakeProgram` prints StatusMsg via `fmt.Println` (`tea.go:57-70`) — on a TTY Go instead + // runs the real `bubbletea` renderer (ephemeral repainted frames, with no TS equivalent + // and not a parity target; non-TTY is) — unlike the migra/pg-delta path's + // `fmt.Fprintln(os.Stderr, …)` diagnostics below. In machine output modes (json/stream-json) + // these are diagnostics, not payload, so they redirect to STDERR instead of being dropped — + // the repo's stdout-payload-only invariant (CLI-1546), matching the sibling migra/pg-delta + // banner below, which keeps its own banner on stderr in every mode. + const emitStatus = (line: string) => + output.raw(`${line}\n`, output.format === "text" ? "stdout" : "stderr"); + + // Shared by both branches below (pgAdmin's `shadowBase` and the migra/pg-delta + // `shadowInput`'s own spread) — resolving the image is the actual provisioning work each + // branch's own "Creating shadow database..." banner announces, so every call site still + // emits its banner FIRST and only then invokes this (preserved, verified-parity ordering). + const resolveShadowRunInput = Effect.fnUntraced(function* () { + const resolvedShadowImage = yield* localInputs.resolvePostgresImage; + return legacyShadowRunInputFromLocalContainerInputs( localInputs, resolvedShadowImage, cfg, fs, path, - ), - targetLocal: resolved.isLocal, - usePgDelta: useDelta, - // `cfg.schemaPathPatterns`, NOT `localInputs.context.config.db.migrations.schema_paths`: - // the latter is the raw `@supabase/config` field, which never applies - // `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` (`@supabase/config` has no viper-`AutomaticEnv` - // equivalent) — `cfg` above (`legacyReadDbToml`) already resolves that env override the - // same way Go's `utils.Config.Db.Migrations.SchemaPaths` does (review: PRRT_kwDOErm0O86XDr4S). - schemaPaths: cfg.schemaPathPatterns, - pgDelta: cfg.pgDelta, - ctx, + ); + }); + + let diffResult: { + readonly sql: string; + readonly files: ReadonlyArray<{ readonly name: string; readonly sql: string }> | undefined; }; - // `Effect.acquireUseRelease`, NOT a separate `yield* legacyCreateShadowDatabase(...)` - // followed by a later `.pipe(Effect.ensuring(...))`: the latter shape leaves a real gap - // between the shadow's successful creation and the `Effect.ensuring` finalizer actually - // being attached — a fiber interrupt landing in that gap (between the two `yield*` - // statements) would skip `legacyRemoveShadowDatabase` entirely, leaking the live shadow - // container and leaving the shadow port occupied. `acquireUseRelease` closes that: - // `acquire` runs inside an `uninterruptibleMask`, and the release finalizer is registered - // in the SAME uninterruptible continuation `acquire` resolves into, matching Go's `defer - // DockerRemove` immediately after successful creation (review: PRRT_kwDOErm0O86XDr4Y). - // This does NOT make removal unconditional, though — see `legacyCreateShadowDatabase`'s - // own doc comment (`shadow-database.ts`) for the still-present, deliberate-Go-parity leak - // window when `acquire` itself fails partway through (a `docker create` success followed - // by a `docker cp`/`docker start` failure). - // - // `acquire` here is ONLY `legacyCreateShadowDatabase` (container creation) — NOT the - // health-wait/migrate/declarative-apply `legacyPrepareShadowSource` performs. Those run - // inside the `use` phase below instead, where a SIGINT can still interrupt them (matching - // Go's single cancellable `ctx` threaded through the equivalent calls); passing all of - // `legacyPrepareShadowSource` as `acquire` made that whole sequence uninterruptible too, - // since `acquireUseRelease`'s `uninterruptibleMask` has no `restore` around `acquire` — - // see `legacy-shadow-source.ts`'s own doc comment on `legacyPrepareShadowSource` for the - // full rationale (review: PRRT_kwDOErm0O86XMrID). - const diffResult = yield* Effect.acquireUseRelease( - legacyCreateShadowDatabase(spawner, shadowInput), - (handle) => - Effect.gen(function* () { - const shadow = yield* legacyPrepareShadowSource(spawner, handle, shadowInput); - const target = shadow.targetUrlOverride ?? targetUrl; - yield* output.raw( - flags.schema.length > 0 - ? `Diffing schemas: ${flags.schema.join(",")}\n` - : "Diffing schemas...\n", - "stderr", - ); - if (useDelta) { - // With PGDELTA_DEBUG set, export the shadow's baseline catalog before diffing - // (Go's `DiffDatabase`, `internal/db/diff/diff.go:228-244`, shared by `db diff` - // AND `db pull`) — the snapshot itself is unused here (unlike `db pull`'s - // `legacySaveEmptyPgDeltaPullDebug`, `db diff` has no debug-bundle consumer for - // it); a failed export only warns and the diff continues. - if (legacyIsPgDeltaDebugEnabled()) { - yield* legacyExportCatalogPgDelta(ctx, { - targetRef: shadow.sourceUrl, - role: "postgres", - }).pipe( - Effect.catch((error) => - output.raw( - `Warning: failed to export shadow pg-delta catalog: ${error.message}\n`, - "stderr", + if (usePgAdmin) { + // Go's `RunPgAdmin` (`pgadmin.go:49-63`): `AssertSupabaseDbIsRunning` runs AFTER the + // config load + target resolve above, and — unlike every other engine on this command — + // runs for `--linked`/`--db-url` too, not just the local target. `ctx.projectId` + // (already remote-merge-resolved, see its own doc comment above), not the raw + // `cliConfig.projectId` env reader: Go's `UpdateDockerIds` runs AFTER the linked + // remote merge, so `DbId` derives from the resolved `Config.ProjectId` singleton, + // not the ungated `SUPABASE_PROJECT_ID` env var (`config_path.go:10-15`, + // `pkg/config/config.go:604-610`, `internal/utils/config.go:57-65`). + const running = yield* legacyIsLocalDbRunning( + spawner, + fs, + path, + cliConfig.workdir, + ctx.projectId, + ).pipe( + Effect.mapError( + (cause) => + new LegacyDbDiffDbNotRunningError({ + message: cause.message, + daemonDown: cause.daemonDown, + suggestion: cause.suggestion, + }), + ), + ); + if (!running) { + return yield* Effect.fail( + new LegacyDbDiffDbNotRunningError({ + message: `${legacyAqua("supabase start")} is not running.`, + }), + ); + } + yield* emitStatus("Creating shadow database..."); + const shadowBase = yield* resolveShadowRunInput(); + const shadowConnConfig: LegacyPgConnInput = { + host: shadowBase.hostname, + port: shadowBase.shadowPort, + user: "postgres", + password: shadowBase.password, + database: "postgres", + }; + // Same `acquireUseRelease` rationale as the migra/pg-delta branch below: `acquire` is + // ONLY container creation (uninterruptible, matching Go's `defer DockerRemove` + // immediately after a successful `DockerStart`); the health-wait + migrate + diff run + // inside the interruptible `use` phase, mirroring Go's own single cancellable `ctx` + // (review: PRRT_kwDOErm0O86XMrID). `acquire` here is ONLY `legacyCreateShadowDatabase` — + // NOT `legacyPrepareShadowSource` (no `--target-local` declarative-schema branch, no + // `targetUrlOverride`, no pg-delta apply: Go's `pgadmin.go` calls `MigrateShadowDatabase` + // directly, never `PrepareShadowSource`). + const sql = yield* Effect.acquireUseRelease( + legacyCreateShadowDatabase(spawner, shadowBase), + (handle) => + Effect.gen(function* () { + yield* legacyWaitForHealthyServices(spawner, [handle.containerId], { + timeoutSeconds: shadowBase.healthTimeoutSeconds, + }); + yield* legacyMigrateShadowDatabase(spawner, { + fs, + path, + workdir: cliConfig.workdir, + projectId: shadowBase.projectId, + container: handle.containerId, + networkId: shadowBase.networkId, + connConfig: shadowConnConfig, + setup: shadowBase.setup, + }); + yield* emitStatus("Diffing local database with current migrations..."); + return yield* legacyDiffSchemaPgAdmin({ + // Go's `source`/`target` are INVERTED relative to the migra/pg-delta path + // below: `source` is the USER'S db, `target` is the SHADOW (`pgadmin.go:85-86`). + source: targetUrl, + // A raw `Sprintf`, not `legacyToPostgresURL` — Go hardcodes `127.0.0.1` and + // `postgres:postgres`, ignoring `SUPABASE_SERVICES_HOSTNAME`/`[db] password` + // (`pgadmin.go:86`, deliberate Go parity, not a bug to fix). + target: `postgresql://postgres:postgres@127.0.0.1:${shadowBase.shadowPort}/postgres`, + schema: flags.schema, + projectId: shadowBase.projectId, + networkId: shadowBase.networkId, + extraHosts: shadowBase.extraHosts, + emitStatus, + }); + }), + (handle) => legacyRemoveShadowDatabase(spawner, handle.containerId), + ); + diffResult = { sql, files: undefined }; + } else { + yield* output.raw("Creating shadow database...\n", "stderr"); + const shadowInput = { + ...(yield* resolveShadowRunInput()), + targetLocal: resolved.isLocal, + usePgDelta: useDelta, + // `cfg.schemaPathPatterns`, NOT `localInputs.context.config.db.migrations.schema_paths`: + // the latter is the raw `@supabase/config` field, which never applies + // `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` (`@supabase/config` has no viper-`AutomaticEnv` + // equivalent) — `cfg` above (`legacyReadDbToml`) already resolves that env override the + // same way Go's `utils.Config.Db.Migrations.SchemaPaths` does (review: PRRT_kwDOErm0O86XDr4S). + schemaPaths: cfg.schemaPathPatterns, + pgDelta: cfg.pgDelta, + ctx, + }; + // `Effect.acquireUseRelease`, NOT a separate `yield* legacyCreateShadowDatabase(...)` + // followed by a later `.pipe(Effect.ensuring(...))`: the latter shape leaves a real gap + // between the shadow's successful creation and the `Effect.ensuring` finalizer actually + // being attached — a fiber interrupt landing in that gap (between the two `yield*` + // statements) would skip `legacyRemoveShadowDatabase` entirely, leaking the live shadow + // container and leaving the shadow port occupied. `acquireUseRelease` closes that: + // `acquire` runs inside an `uninterruptibleMask`, and the release finalizer is registered + // in the SAME uninterruptible continuation `acquire` resolves into, matching Go's `defer + // DockerRemove` immediately after successful creation (review: PRRT_kwDOErm0O86XDr4Y). + // This does NOT make removal unconditional, though — see `legacyCreateShadowDatabase`'s + // own doc comment (`shadow-database.ts`) for the still-present, deliberate-Go-parity leak + // window when `acquire` itself fails partway through (a `docker create` success followed + // by a `docker cp`/`docker start` failure). + // + // `acquire` here is ONLY `legacyCreateShadowDatabase` (container creation) — NOT the + // health-wait/migrate/declarative-apply `legacyPrepareShadowSource` performs. Those run + // inside the `use` phase below instead, where a SIGINT can still interrupt them (matching + // Go's single cancellable `ctx` threaded through the equivalent calls); passing all of + // `legacyPrepareShadowSource` as `acquire` made that whole sequence uninterruptible too, + // since `acquireUseRelease`'s `uninterruptibleMask` has no `restore` around `acquire` — + // see `legacy-shadow-source.ts`'s own doc comment on `legacyPrepareShadowSource` for the + // full rationale (review: PRRT_kwDOErm0O86XMrID). + diffResult = yield* Effect.acquireUseRelease( + legacyCreateShadowDatabase(spawner, shadowInput), + (handle) => + Effect.gen(function* () { + const shadow = yield* legacyPrepareShadowSource(spawner, handle, shadowInput); + const target = shadow.targetUrlOverride ?? targetUrl; + yield* output.raw( + flags.schema.length > 0 + ? `Diffing schemas: ${flags.schema.join(",")}\n` + : "Diffing schemas...\n", + "stderr", + ); + if (useDelta) { + // With PGDELTA_DEBUG set, export the shadow's baseline catalog before diffing + // (Go's `DiffDatabase`, `internal/db/diff/diff.go:228-244`, shared by `db diff` + // AND `db pull`) — the snapshot itself is unused here (unlike `db pull`'s + // `legacySaveEmptyPgDeltaPullDebug`, `db diff` has no debug-bundle consumer for + // it); a failed export only warns and the diff continues. + if (legacyIsPgDeltaDebugEnabled()) { + yield* legacyExportCatalogPgDelta(ctx, { + targetRef: shadow.sourceUrl, + role: "postgres", + }).pipe( + Effect.catch((error) => + output.raw( + `Warning: failed to export shadow pg-delta catalog: ${error.message}\n`, + "stderr", + ), ), - ), - ); + ); + } + const result = yield* legacyDiffPgDelta(ctx, { + sourceRef: shadow.sourceUrl, + targetRef: target, + schema: flags.schema, + formatOptions, + }); + // Keep the per-unit plan files so a multi-unit plan can be written as one + // migration file each (Go's `DatabaseDiff.Files`); `sql` stays the flattened + // join for stdout review + machine payloads. + return { sql: result.sql, files: result.files }; } - const result = yield* legacyDiffPgDelta(ctx, { - sourceRef: shadow.sourceUrl, - targetRef: target, + const sql = yield* legacyDiffMigra(ctx, { + source: shadow.sourceUrl, + target, schema: flags.schema, - formatOptions, + connectOptions: { isLocal: resolved.isLocal, dnsResolver }, }); - // Keep the per-unit plan files so a multi-unit plan can be written as one - // migration file each (Go's `DatabaseDiff.Files`); `sql` stays the flattened - // join for stdout review + machine payloads. - return { sql: result.sql, files: result.files }; - } - const sql = yield* legacyDiffMigra(ctx, { - source: shadow.sourceUrl, - target, - schema: flags.schema, - connectOptions: { isLocal: resolved.isLocal, dnsResolver }, - }); - // The migra engine has no execution-aware plan units, so it always writes a - // single migration file (Go's `SaveDiff` single-file path). - return { sql, files: undefined }; - }), - (handle) => legacyRemoveShadowDatabase(spawner, handle.containerId), - ); + // The migra engine has no execution-aware plan units, so it always writes a + // single migration file (Go's `SaveDiff` single-file path). + return { sql, files: undefined }; + }), + (handle) => legacyRemoveShadowDatabase(spawner, handle.containerId), + ); + } const out = diffResult.sql; - // Detect the branch from the resolved workdir, not the caller's CWD: Go - // chdirs into --workdir in PersistentPreRunE before GetGitBranch - // (`cmd/root.go`), so `supabase --workdir … db diff` must report the - // project's branch, not the directory the command was invoked from. - const branch = Option.getOrElse(yield* detectGitBranch(cliConfig.workdir), () => "main"); - yield* output.raw( - `Finished ${legacyAqua("supabase db diff")} on branch ${legacyAqua(branch)}.\n\n`, - "stderr", - ); + // Go's `RunPgAdmin` returns straight to `SaveDiff` — no branch banner, no drop scan (both + // live in `diff.Run`, `diff.go:38-47`, which the pgadmin path bypasses entirely). + if (!usePgAdmin) { + // Detect the branch from the resolved workdir, not the caller's CWD: Go + // chdirs into --workdir in PersistentPreRunE before GetGitBranch + // (`cmd/root.go`), so `supabase --workdir … db diff` must report the + // project's branch, not the directory the command was invoked from. + const branch = Option.getOrElse(yield* detectGitBranch(cliConfig.workdir), () => "main"); + yield* output.raw( + `Finished ${legacyAqua("supabase db diff")} on branch ${legacyAqua(branch)}.\n\n`, + "stderr", + ); + } - // Go's `SaveDiff` (`pgadmin.go:20`) + the drop-statement warning (`diff.go:44`). - const engine = useDelta ? "pg-delta" : "migra"; - const drops = legacyFindDropStatements(out); + // Go's `SaveDiff` (`pgadmin.go:20`) + the drop-statement warning (`diff.go:44`, bypassed + // by the pgadmin path). + const engine = usePgAdmin ? "pgadmin" : useDelta ? "pg-delta" : "migra"; + const drops: ReadonlyArray = usePgAdmin ? [] : legacyFindDropStatements(out); const writtenFiles: Array = []; if (out.length < 2) { yield* output.raw("No schema changes found\n", "stderr"); @@ -693,5 +817,8 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy ), ), Effect.ensuring(telemetryState.flush), + // Scope the `SUPABASE_INTERNAL_IMAGE_REGISTRY`-from-`.env` apply above to this + // command run: `legacyApplyProjectEnv` registers a finalizer that reverts it. + Effect.scoped, ); }); 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 309c894a37..01c74c6ce3 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 @@ -19,6 +19,7 @@ import { useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; +import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyDebugFlag, @@ -35,7 +36,11 @@ import { type LegacyDbSession, type LegacyPgConnInput, } from "../../../shared/legacy-db-connection.service.ts"; -import { LegacyDockerRun } from "../../../shared/legacy-docker-run.service.ts"; +import { LegacyDockerRunError } from "../../../shared/legacy-docker-run.errors.ts"; +import { + LegacyDockerRun, + type LegacyDockerRunOpts, +} from "../../../shared/legacy-docker-run.service.ts"; import { LegacyEdgeRuntimeScriptError } from "../../../shared/legacy-edge-runtime-script.errors.ts"; import { type LegacyEdgeRuntimeRunOpts, @@ -44,6 +49,10 @@ import { import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; import type { LegacyDbDiffFlags } from "./diff.command.ts"; import { legacyDbDiff } from "./diff.handler.ts"; +import { + LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX, + LEGACY_PGADMIN_DIFF_HEADER, +} from "./legacy-pgadmin-diff.ts"; interface SetupOpts { readonly format?: OutputFormat; @@ -73,6 +82,32 @@ interface SetupOpts { // `Option.some("test")`; pass `Option.none()` to exercise the config.toml/workdir-basename // fallback `legacyResolveLocalProjectId` provides for the pg-delta edge-runtime cache bind. readonly projectId?: Option.Option; + // --- CLI-1968 (native --use-pgadmin) --- + // Per-differ-run `--json-diff` stdout, one entry per `runCapture` call to the differ + // image (index 0 = the no-`--schema` run, or the 1st `--schema` run; index 1 = the + // 2nd `--schema` run; …). Falls back to `""` (an empty/"No schema changes" diff) once + // exhausted, so a single-run test only needs a one-element array. + readonly pgadminStdout?: ReadonlyArray; + // Per-differ-run stderr (the raw text `legacyProcessPgAdminDiffProgress` filters). + // Falls back to `""` once exhausted. + readonly pgadminStderr?: ReadonlyArray; + // Applied to every differ `runCapture` call (the failure tests below only ever drive + // a single, no-`--schema` run, so one number covers them). + readonly pgadminExitCode?: number; + // Makes every differ `runCapture` call fail at the docker boundary instead of + // returning a result — `"spawn"` (daemon unreachable) or `"pull"` (registry failure). + readonly pgadminDockerFail?: "spawn" | "pull"; + // Makes the pre-flight `docker container inspect supabase_db_` probe + // (`legacyIsLocalDbRunning`, run before `--use-pgadmin` provisions anything) report + // "container not found" — Go's `supabase start is not running.`. + readonly dbNotRunning?: boolean; + // Makes that SAME probe fail with a daemon-unreachable stderr instead — the + // `daemonDown: true` classification branch. Mutually exclusive with `dbNotRunning`. + readonly dbInspectFailsWith?: string; + // `RuntimeInfo.platform` — drives the differ's `--add-host host.docker.internal: + // host-gateway` (Linux-only). Defaults to `"linux"` (every other test's implicit + // baseline); pass `"darwin"`/`"win32"` to exercise the no-add-host branch. + readonly platform?: NodeJS.Platform; } const alwaysReadyHttpClientLayer = Layer.succeed( @@ -116,6 +151,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { // session backs the shadow's own platform-baseline/migration/declarative setup. const shadowSpawner = mockLegacyShadowContainerCliSpawner({ neverHealthy: opts.neverHealthyShadow ?? false, + dbNotRunning: opts.dbNotRunning ?? false, + dbInspectFailsWith: opts.dbInspectFailsWith, }); const shadowDbConnection = fakeShadowDbConnection(); @@ -170,10 +207,44 @@ function setup(workdir: string, opts: SetupOpts = {}) { // (their `env`, notably `DB_HOST`, is the one shadow-specific parameterization // CLI-1956 exists to get right). const dockerCalls: unknown[] = []; + // The pgAdmin differ's own `runCapture` calls (CLI-1968), tracked separately from + // `dockerCalls` (the migra OOM bash fallback's image) so pgadmin tests never + // conflate the two — both go through the SAME `LegacyDockerRun.runCapture` seam, + // distinguished only by `image`. + const differCalls: Array = []; + // The `runCapture` SECOND (options) argument for every differ call, parallel to + // `differCalls` — pinned `undefined` below, since Go never tees the differ's raw + // stderr to the parent terminal (see `legacy-pgadmin-diff.ts`'s own doc comment). + const differCaptureOpts: Array<{ readonly teeStderr?: boolean } | undefined> = []; + // Snapshots `process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]` at the moment each + // differ `runCapture` call is made — the real `legacyDockerRunLayer`'s own image + // resolver reads that key straight off `process.env` at call time (no + // `projectEnvValues` threaded through), so this stands in for it here. + const differRegistryEnvAtCall: Array = []; const shadowSetupJobCalls: Array<{ readonly env: Readonly> }> = []; const docker = Layer.succeed(LegacyDockerRun, { run: () => Effect.die("run unused"), - runCapture: (dockerOpts) => { + runCapture: (dockerOpts, captureOpts) => { + if (dockerOpts.image.includes("pgadmin-schema-diff")) { + differCalls.push(dockerOpts); + differCaptureOpts.push(captureOpts); + differRegistryEnvAtCall.push(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]); + if (opts.pgadminDockerFail !== undefined) { + return Effect.fail( + new LegacyDockerRunError({ + message: "failed to run docker: not found", + reason: opts.pgadminDockerFail, + daemonDown: opts.pgadminDockerFail === "spawn", + }), + ); + } + const i = differCalls.length - 1; + return Effect.succeed({ + exitCode: opts.pgadminExitCode ?? 0, + stdout: new TextEncoder().encode(opts.pgadminStdout?.[i] ?? ""), + stderr: opts.pgadminStderr?.[i] ?? "", + }); + } dockerCalls.push(dockerOpts); return Effect.succeed({ exitCode: 0, @@ -265,7 +336,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.succeed(LegacyExperimentalFlag, false), Layer.succeed(LegacyDebugFlag, false), Layer.succeed(CliArgs, { args: [] }), - mockRuntimeInfo(), + mockRuntimeInfo({ platform: opts.platform ?? "linux" }), ); // Merged last so its `FileSystem` overrides everything above (last-wins). const layer = @@ -283,6 +354,9 @@ function setup(workdir: string, opts: SetupOpts = {}) { proxyCalls, proxyCaptureCalls, dockerCalls, + differCalls, + differCaptureOpts, + differRegistryEnvAtCall, shadowSetupJobCalls, shadowSpawned: shadowSpawner.spawned, shadowConnectedDatabases: shadowDbConnection.connectedDatabases, @@ -322,6 +396,29 @@ const stderr = (out: ReturnType) => const tmp = useLegacyTempWorkdir(); +// --- CLI-1968 (native --use-pgadmin) fixtures --- + +/** Go's `DiffEntry` (`container_output.go:127-134`) shape, defaulting to a kept entry. */ +function pgadminEntry(overrides: Record = {}) { + return { + type: "table", + status: "Different", + diff_ddl: "ALTER TABLE test;", + group_name: "public", + ...overrides, + }; +} + +/** `legacyProcessPgAdminDiffOutput`'s exact output for a single default `pgadminEntry()`. */ +const PGADMIN_DIFF_SQL = `${LEGACY_PGADMIN_DIFF_HEADER}\n\nALTER TABLE test;\n`; + +// The default `resolver`/shadow-port fixtures in `setup()` below (conn +// 127.0.0.1:54322, shadow port 54320) — Go's `source` (the user's db, via +// `legacyToPostgresURL`) and `target` (the shadow, a raw, hardcoded `Sprintf`). +const PGADMIN_SOURCE_URL = + "postgresql://postgres:postgres@127.0.0.1:54322/postgres?connect_timeout=10"; +const PGADMIN_TARGET_URL = "postgresql://postgres:postgres@127.0.0.1:54320/postgres"; + describe("legacy db diff", () => { it.effect("diffs local with the default migra engine and prints SQL to stdout", () => { const s = setup(tmp.current, { diffSql: "create table players ();\n" }); @@ -668,32 +765,126 @@ describe("legacy db diff", () => { }, ); - it.effect("delegates --use-pgadmin to the Go binary (telemetry disabled on the child)", () => { - const s = setup(tmp.current); - return Effect.gen(function* () { - yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); - expect(s.proxyCalls).toHaveLength(1); - expect(s.proxyCalls[0]?.args).toEqual(["db", "diff", "--use-pgadmin"]); - expect(s.proxyCalls[0]?.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); - // The pgadmin/pg-schema delegate short-circuits before ever creating a shadow. - expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toEqual([]); - }).pipe(Effect.provide(s.layer)); - }); + it.effect( + "diffs with the native pgAdmin engine: shadow create/rm, one differ run, no Go proxy call", + () => { + const s = setup(tmp.current, { pgadminStdout: [JSON.stringify([pgadminEntry()])] }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); + // CLI-1968: --use-pgadmin no longer delegates to the bundled Go binary. + expect(s.proxyCalls).toEqual([]); + expect(s.proxyCaptureCalls).toEqual([]); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + expect(s.differCalls).toHaveLength(1); + // Status lines go to STDOUT (Go's fakeProgram fmt.Println), not stderr. + expect(stdout(s.out)).toBe( + `Creating shadow database...\nDiffing local database with current migrations...\n${PGADMIN_DIFF_SQL}\n`, + ); + // Stderr still carries the SHARED shadow-setup diagnostics (revoke-api-privileges, + // roles.sql seeding — identical on every diff engine), but none of pgAdmin's own + // status lines, which are on stdout instead, and none of the migra/pg-delta-only + // "Diffing schemas..."/"Finished ... on branch" lines (`diff.Run`-only, bypassed). + const err = stderr(s.out); + expect(err).not.toContain("Creating shadow database..."); + expect(err).not.toContain("Diffing local database with current migrations..."); + expect(err).not.toContain("Diffing schemas"); + expect(err).not.toContain("Finished"); + }).pipe(Effect.provide(s.layer)); + }, + ); - it.effect("a delegated --use-pgadmin does not validate the base config first", () => { - // The delegate forwards the whole command to the Go child, which loads config - // itself (with the linked ref). So the TS path must NOT read/validate the base - // config up front — otherwise a project that's only valid after a [remotes.] - // merge (here: base db.major_version=16 is invalid) fails before delegating, - // even though Go validates the remote-merged config and succeeds. - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "config.toml"), "[db]\nmajor_version = 16\n"); - const s = setup(tmp.current, { isLocal: false, linkedRef: "abcdefghijklmnopqrst" }); - return Effect.gen(function* () { - yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true), linked: Option.some(true) })); - expect(s.proxyCalls).toHaveLength(1); - }).pipe(Effect.provide(s.layer)); - }); + it.effect( + "--use-pgadmin --linked succeeds when only the [remotes.] override fixes an invalid base config", + () => { + // CLI-1968: pgadmin now shares the SAME target resolve as migra/pg-delta (Go + // resolves the target in the root PersistentPreRunE, strictly before + // RunPgAdmin), so it validates the remote-merged config, prints the override + // line, and succeeds — unlike the old Go-delegate era, where the whole + // command (config load included) ran inside the delegated child. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[db]", + "major_version = 16", + "", + "[remotes.staging]", + 'project_id = "abcdefghijklmnopqrst"', + "", + "[remotes.staging.db]", + "major_version = 15", + "", + ].join("\n"), + ); + const s = setup(tmp.current, { + isLocal: false, + linkedRef: "abcdefghijklmnopqrst", + pgadminStdout: [JSON.stringify([pgadminEntry()])], + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true), linked: Option.some(true) })); + expect(stderr(s.out)).toContain("Loading config override: [remotes.staging]"); + expect(s.proxyCalls).toEqual([]); + expect(s.differCalls).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "--use-pgadmin --linked's preflight probe targets the resolved LINKED project id, not the base config's", + () => { + // Go's `UpdateDockerIds` runs AFTER the linked remote merge, so `DbId` derives + // from the resolved `Config.ProjectId` singleton (`config_path.go:10-15`, + // `pkg/config/config.go:604-610`, `internal/utils/config.go:57-65`), NOT the + // base config's own `project_id` — the matched `[remotes.]` block's own + // `project_id` must suppress it. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + 'project_id = "test"', + "", + "[remotes.staging]", + 'project_id = "abcdefghijklmnopqrst"', + "", + ].join("\n"), + ); + const s = setup(tmp.current, { + isLocal: false, + linkedRef: "abcdefghijklmnopqrst", + pgadminStdout: [JSON.stringify([pgadminEntry()])], + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true), linked: Option.some(true) })); + // `mockLegacyShadowContainerCliSpawner` distinguishes this SEPARATE + // `legacyIsLocalDbRunning` preflight probe from the shadow's own (64-hex-id) + // health-check inspect by the `supabase_db_` container-name prefix. + const inspectTargets = s.shadowSpawned + .filter((c) => c.args[0] === "container" && c.args[1] === "inspect") + .map((c) => c.args[2]); + expect(inspectTargets).toContain("supabase_db_abcdefghijklmnopqrst"); + expect(inspectTargets).not.toContain("supabase_db_test"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "--use-pgadmin fails on an invalid base config when no [remotes.] override exists (parity with the native local path)", + () => { + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "config.toml"), "[db]\nmajor_version = 16\n"); + const s = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(s.resolverCalls).toHaveLength(0); + expect(s.differCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }, + ); it.effect("a native local diff still validates the base config", () => { // Control for the delegate case: the local/db-url native path reads the base @@ -742,19 +933,36 @@ describe("legacy db diff", () => { }, ); - it.effect("re-quotes a comma-containing schema when delegating the diff", () => { + it.effect("re-quotes a comma-containing schema when delegating --use-pg-schema", () => { // flags.schema holds the single parsed value `tenant,one`; forwarding it raw // would let the Go child's pflag StringSlice CSV-split it into two schemas, so - // it must be re-encoded as a quoted CSV field. + // it must be re-encoded as a quoted CSV field. `--use-pg-schema` is the only + // remaining delegate path (CLI-1968 cut pgadmin's own delegation). const s = setup(tmp.current); return Effect.gen(function* () { - yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true), schema: ["tenant,one"] })); + yield* legacyDbDiff(flags({ usePgSchema: Option.some(true), schema: ["tenant,one"] })); const args = s.proxyCalls[0]?.args ?? []; const idx = args.indexOf("--schema"); expect(args[idx + 1]).toBe('"tenant,one"'); }).pipe(Effect.provide(s.layer)); }); + it.effect( + "forwards a comma-containing --schema value to the differ raw, with no CSV re-quoting (native path)", + () => { + // Unlike the --use-pg-schema delegate above, the native differ argv is never + // re-parsed by a pflag StringSlice, so the single parsed value reaches the + // container unchanged. + const s = setup(tmp.current, { pgadminStdout: [JSON.stringify([pgadminEntry()])] }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true), schema: ["tenant,one"] })); + const call = s.differCalls[0]; + const idx = call?.cmd.indexOf("--schema") ?? -1; + expect(call?.cmd[idx + 1]).toBe("tenant,one"); + }).pipe(Effect.provide(s.layer)); + }, + ); + it.effect( "delegates --use-pg-schema to the Go binary, printing a deprecation warning without duplicating Go's own warning", () => { @@ -772,6 +980,9 @@ describe("legacy db diff", () => { expect(stderr(s.out)).not.toContain("--use-pg-schema flag is experimental"); // Delegation to Go is unchanged besides the new warning. expect(s.proxyCalls[0]?.args).toEqual(["db", "diff", "--use-pg-schema"]); + // The child's own telemetry is disabled so the single `cli_command_executed` + // event comes from this TS command's instrumentation, not the delegated child. + expect(s.proxyCalls[0]?.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); }).pipe(Effect.provide(s.layer)); }, ); @@ -785,9 +996,9 @@ describe("legacy db diff", () => { }); it.effect( - "does not print the --use-pg-schema deprecation warning when delegating --use-pgadmin", + "does not print the --use-pg-schema deprecation warning on the native --use-pgadmin path", () => { - const s = setup(tmp.current); + const s = setup(tmp.current, { pgadminStdout: [JSON.stringify([pgadminEntry()])] }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); expect(stderr(s.out)).not.toContain('"--use-pg-schema" is deprecated'); @@ -795,24 +1006,67 @@ describe("legacy db diff", () => { }, ); - it.effect("--use-pgadmin in json mode wraps the captured SQL in a structured envelope", () => { - // Regression: the delegated child inherited stdout and returned without - // output.success, so machine-mode stdout carried the Go child's raw SQL - // instead of a JSON envelope (CLI-1546). Now the child's stdout is captured - // and re-emitted as the structured payload. - const s = setup(tmp.current, { format: "json", delegateStdout: "create table d ();\n" }); + it.effect( + "emits a json envelope for --use-pgadmin with status lines redirected to stderr (payload-only stdout)", + () => { + const s = setup(tmp.current, { + format: "json", + pgadminStdout: [JSON.stringify([pgadminEntry()])], + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); + // stdout stays payload-only in machine mode — no status lines leak into it. + expect(stdout(s.out)).toBe(""); + // The status lines are diagnostics, not payload, so machine mode redirects + // them to stderr instead of dropping them (repo invariant: stdout is + // payload-only, diagnostics go to stderr — CLI-1546). + const err = stderr(s.out); + expect(err).toContain("Creating shadow database..."); + expect(err).toContain("Diffing local database with current migrations..."); + expect(s.proxyCalls).toEqual([]); + expect(s.proxyCaptureCalls).toEqual([]); + const success = s.out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ + diff: PGADMIN_DIFF_SQL, + file: null, + files: [], + schemas: [], + engine: "pgadmin", + dropStatements: [], + }); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "a json-mode --use-pgadmin --file reports the written migration path instead of null (regression vs the old delegate)", + () => { + const s = setup(tmp.current, { + format: "json", + pgadminStdout: [JSON.stringify([pgadminEntry()])], + }); + return Effect.gen(function* () { + yield* legacyDbDiff( + flags({ usePgAdmin: Option.some(true), file: Option.some("pgadmin_diff") }), + ); + const success = s.out.messages.find((m) => m.type === "success"); + const data = success?.data as { file: string; files: ReadonlyArray }; + expect(data.file).toMatch(/\d{14}_pgadmin_diff\.sql$/); + expect(data.files).toEqual([data.file]); + expect(existsSync(data.file)).toBe(true); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("delivers the pgadmin payload as a stream-json result event too", () => { + const s = setup(tmp.current, { + format: "stream-json", + pgadminStdout: [JSON.stringify([pgadminEntry()])], + }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); - // stdout stays payload-only; the child's SQL was captured, not inherited. - expect(stdout(s.out)).toBe(""); - expect(s.proxyCalls).toHaveLength(0); - expect(s.proxyCaptureCalls).toHaveLength(1); const success = s.out.messages.find((m) => m.type === "success"); - expect(success?.data).toMatchObject({ - diff: "create table d ();\n", - file: null, - engine: "pgadmin", - }); + expect(success?.data).toMatchObject({ diff: PGADMIN_DIFF_SQL, engine: "pgadmin" }); }).pipe(Effect.provide(s.layer)); }); @@ -828,6 +1082,8 @@ describe("legacy db diff", () => { // stderr in machine output mode (CLI-1546) rather than being dropped or // leaking into the stdout payload. expect(stderr(s.out)).toContain('"--use-pg-schema" is deprecated'); + // The child's own telemetry is disabled here too, same as the text-mode delegate. + expect(s.proxyCaptureCalls[0]?.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); }).pipe(Effect.provide(s.layer)); }); @@ -1021,16 +1277,20 @@ describe("legacy db diff", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("forwards an explicit --linked=false target flag to the delegated child", () => { - // Target flags are selectors keyed on flag.Changed in Go; dropping Some(false) - // would make the child default to local instead of the linked target the - // native path selected. - const s = setup(tmp.current); - return Effect.gen(function* () { - yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true), linked: Option.some(false) })); - expect(s.proxyCalls[0]?.args).toEqual(["db", "diff", "--use-pgadmin", "--linked=false"]); - }).pipe(Effect.provide(s.layer)); - }); + it.effect( + "forwards an explicit --linked=false target flag to the delegated pg-schema child", + () => { + // Target flags are selectors keyed on flag.Changed in Go; dropping Some(false) + // would make the child default to local instead of the linked target the + // native path selected. `--use-pg-schema` is the only remaining delegate path + // (CLI-1968 cut pgadmin's own delegation). + const s = setup(tmp.current); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgSchema: Option.some(true), linked: Option.some(false) })); + expect(s.proxyCalls[0]?.args).toEqual(["db", "diff", "--use-pg-schema", "--linked=false"]); + }).pipe(Effect.provide(s.layer)); + }, + ); it.effect( "an empty --file value prints to stdout instead of writing a nameless migration", @@ -1423,4 +1683,583 @@ describe("legacy db diff", () => { }); }, ); + + describe("--use-pgadmin (native differ, CLI-1968)", () => { + it.effect( + "prints 'No schema changes found' and writes nothing when the differ output is empty", + () => { + const s = setup(tmp.current, { pgadminStdout: [""] }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); + expect(stderr(s.out)).toContain("No schema changes found"); + expect(stdout(s.out)).toBe( + "Creating shadow database...\nDiffing local database with current migrations...\n", + ); + const migrationsDir = join(tmp.current, "supabase", "migrations"); + expect(existsSync(migrationsDir) ? readdirSync(migrationsDir) : []).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "prints 'No schema changes found' when every diff entry is filtered out (all Identical)", + () => { + const s = setup(tmp.current, { + pgadminStdout: [JSON.stringify([pgadminEntry({ status: "Identical" })])], + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); + expect(stderr(s.out)).toContain("No schema changes found"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("writes a timestamped migration for --use-pgadmin --file instead of printing", () => { + const s = setup(tmp.current, { pgadminStdout: [JSON.stringify([pgadminEntry()])] }); + return Effect.gen(function* () { + yield* legacyDbDiff( + flags({ usePgAdmin: Option.some(true), file: Option.some("pgadmin_diff") }), + ); + expect(stdout(s.out)).not.toContain("ALTER TABLE"); + expect(stderr(s.out)).toContain("WARNING: The diff tool is not foolproof"); + const dir = join(tmp.current, "supabase", "migrations"); + const files = readdirSync(dir); + expect(files).toHaveLength(1); + expect(files[0]).toMatch(/^\d{14}_pgadmin_diff\.sql$/); + expect(readFileSync(join(dir, files[0]!), "utf8")).toBe(PGADMIN_DIFF_SQL); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("creates nested parent directories for a nested --use-pgadmin --file name", () => { + const s = setup(tmp.current, { pgadminStdout: [JSON.stringify([pgadminEntry()])] }); + return Effect.gen(function* () { + yield* legacyDbDiff( + flags({ usePgAdmin: Option.some(true), file: Option.some("snapshots/remote") }), + ); + const migrationsRoot = join(tmp.current, "supabase", "migrations"); + const dirs = readdirSync(migrationsRoot); + expect(dirs).toHaveLength(1); + expect(dirs[0]).toMatch(/^\d{14}_snapshots$/); + expect(readdirSync(join(migrationsRoot, dirs[0]!))).toEqual(["remote.sql"]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "an empty --use-pgadmin --file value falls through to stdout instead of writing", + () => { + const s = setup(tmp.current, { pgadminStdout: [JSON.stringify([pgadminEntry()])] }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true), file: Option.some("") })); + expect(stdout(s.out)).toContain("ALTER TABLE test;"); + const migrationsDir = join(tmp.current, "supabase", "migrations"); + expect(existsSync(migrationsDir) ? readdirSync(migrationsDir) : []).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "never prints the 'Finished ... on branch' banner or a drop-statement warning, even with a DROP in the SQL", + () => { + const s = setup(tmp.current, { + pgadminStdout: [JSON.stringify([pgadminEntry({ diff_ddl: "drop table gone;" })])], + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); + expect(stderr(s.out)).not.toContain("Finished"); + expect(stderr(s.out)).not.toContain("Found drop statements"); + expect(stdout(s.out)).toContain("drop table gone;"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "invokes the differ with the exact argv, image, network, labels, and empty env/binds (no --schema)", + () => { + const s = setup(tmp.current, { pgadminStdout: [JSON.stringify([pgadminEntry()])] }); + // `LegacyCliConfig.projectId` only feeds pg-delta's own project id (a + // SEPARATE mechanism); the shadow/differ's docker network+labels come from + // `legacyLoadLocalProjectContext`'s REAL resolution (no config.toml + // `project_id`/`SUPABASE_PROJECT_ID` here), which falls back to the workdir + // basename — same as the pg-delta Deno-cache-volume tests above. + const projectId = basename(tmp.current); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); + expect(s.differCalls).toHaveLength(1); + const call = s.differCalls[0] as LegacyDockerRunOpts; + expect(call.image).toBe(dockerfileServiceImage("differ")); + expect(call.image).toBe("supabase/pgadmin-schema-diff:cli-0.0.5"); + expect(call.cmd).toEqual(["--json-diff", PGADMIN_SOURCE_URL, PGADMIN_TARGET_URL]); + expect(call.env).toEqual({}); + expect(call.binds).toEqual([]); + expect(call.securityOpt).toEqual([]); + expect(call.workingDir).toEqual(Option.none()); + expect(call.entrypoint).toBeUndefined(); + expect(call.network).toEqual({ _tag: "named", name: `supabase_network_${projectId}` }); + expect(call.labels).toEqual({ + "com.supabase.cli.project": projectId, + "com.docker.compose.project": projectId, + }); + expect(call.extraHosts).toEqual(["host.docker.internal:host-gateway"]); + // Go never tees the differ's raw stderr to the parent terminal — the + // `runCapture` options argument must stay unset. + expect(s.differCaptureOpts[0]).toBeUndefined(); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("--network-id forwards to the differ's --network, same as the shadow", () => { + const s = setup(tmp.current, { + pgadminStdout: [JSON.stringify([pgadminEntry()])], + networkId: "custom-net", + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); + const call = s.differCalls[0] as LegacyDockerRunOpts; + expect(call.network).toEqual({ _tag: "named", name: "custom-net" }); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "omits --add-host on a non-Linux host (Go's docker_darwin.go/docker_windows.go)", + () => { + const s = setup(tmp.current, { + pgadminStdout: [JSON.stringify([pgadminEntry()])], + platform: "darwin", + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); + const call = s.differCalls[0] as LegacyDockerRunOpts; + expect(call.extraHosts).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "hardcodes the shadow target's postgres:postgres credentials, ignoring a configured [db] password (Go pgadmin.go quirk)", + () => { + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + '[db]\npassword = "distinctive-pw"\n', + ); + const s = setup(tmp.current, { pgadminStdout: [JSON.stringify([pgadminEntry()])] }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); + const call = s.differCalls[0] as LegacyDockerRunOpts; + expect(call.cmd.at(-1)).toBe(PGADMIN_TARGET_URL); + expect(call.cmd.join(" ")).not.toContain("distinctive-pw"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "a supabase/.env-only SUPABASE_INTERNAL_IMAGE_REGISTRY reaches the differ's image resolver during the run, and reverts after", + () => { + // TS `db diff` never applied project env at all before this fix (unlike `db + // push`/`pull`/`dump`/`reset`/`bootstrap`) — Go's `loadNestedEnv` `os.Setenv`s + // the project `.env` during config load (`pkg/config/config.go:788-791`), + // before `GetRegistry()` (`internal/utils/docker.go:221-231,244-246`) ever + // reads it. `legacyDockerRunLayer`'s own image resolver has no + // `projectEnvValues` in scope, so it falls back to reading `process.env` + // directly at `runCapture` call time; this mock docker layer records that + // same read (`differRegistryEnvAtCall`) since it replaces the real resolver + // wholesale and can't observe an already-rewritten image. + const prev = process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", ".env"), + "SUPABASE_INTERNAL_IMAGE_REGISTRY=registry.example.com\n", + ); + const s = setup(tmp.current, { pgadminStdout: [JSON.stringify([pgadminEntry()])] }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); + expect(s.differRegistryEnvAtCall).toEqual(["registry.example.com"]); + // Reverted once the handler's scope closes — no leak into a later command + // (or a later test) sharing this process. + expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + else process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"] = prev; + }), + ), + Effect.provide(s.layer), + ); + }, + ); + + it.effect( + "filters differ stderr through ProcessDiffProgress, printing only the matched status text to stdout", + () => { + const s = setup(tmp.current, { + pgadminStdout: [JSON.stringify([pgadminEntry()])], + pgadminStderr: [ + "Starting schema diff...\nComparing Tables 45%\nnoise line\nDiffing 100%\n", + ], + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); + const text = stdout(s.out); + expect(text).toContain("Comparing Tables \n"); + expect(text).toContain("Diffing 1\n"); + expect(text).not.toContain("Starting schema diff..."); + expect(text).not.toContain("noise line"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("still parses --json-diff output prefixed with the DESKTOP-mode NOTE line", () => { + const s = setup(tmp.current, { + pgadminStdout: [`${LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX}${JSON.stringify([pgadminEntry()])}`], + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); + expect(stdout(s.out)).toContain("ALTER TABLE test;"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "loops one differ run per --schema, in flag order, with per-run 'Diffing schema:' status lines", + () => { + const s = setup(tmp.current, { + pgadminStdout: [JSON.stringify([pgadminEntry({ diff_ddl: "create table pub ();" })]), ""], + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true), schema: ["public", "app"] })); + expect(s.differCalls).toHaveLength(2); + expect((s.differCalls[0] as LegacyDockerRunOpts).cmd).toEqual([ + "--schema", + "public", + "--json-diff", + PGADMIN_SOURCE_URL, + PGADMIN_TARGET_URL, + ]); + expect((s.differCalls[1] as LegacyDockerRunOpts).cmd).toEqual([ + "--schema", + "app", + "--json-diff", + PGADMIN_SOURCE_URL, + PGADMIN_TARGET_URL, + ]); + const text = stdout(s.out); + const idxPublic = text.indexOf("Diffing schema: public"); + const idxApp = text.indexOf("Diffing schema: app"); + expect(idxPublic).toBeGreaterThanOrEqual(0); + expect(idxApp).toBeGreaterThan(idxPublic); + expect(text).toContain("create table pub ();"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + ">=2 --schema runs each emitting a diff array succeed, aggregating every run's DDL under ONE header (CLI-1968 round 2: parsed per run, not concatenated then parsed once)", + () => { + // Completes the intended shared-buffer algorithm's own purpose (see + // `legacy-pgadmin-diff.ts`'s own header comment): each run's stdout is + // parsed on its own, so >=2 `--schema` runs that each emit a full JSON + // array no longer concatenate into one buffer and fail a single + // `JSON.parse` — every run's own DESKTOP-mode NOTE prefix (`pgadmin4#24`) + // is trimmed from that run's own buffer too, not just the very first run's. + const s = setup(tmp.current, { + pgadminStdout: [ + `${LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX}${JSON.stringify([pgadminEntry({ diff_ddl: "create table pub ();" })])}`, + `${LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX}${JSON.stringify([pgadminEntry({ diff_ddl: "create table app ();" })])}`, + ], + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true), schema: ["public", "app"] })); + const text = stdout(s.out); + // A single header, not one per run. + expect(text.split(LEGACY_PGADMIN_DIFF_HEADER)).toHaveLength(2); + expect(text).toContain( + `${LEGACY_PGADMIN_DIFF_HEADER}\n\ncreate table pub ();\n\ncreate table app ();\n`, + ); + // Per-run "Diffing schema:" ordering is preserved. + const idxPublic = text.indexOf("Diffing schema: public"); + const idxApp = text.indexOf("Diffing schema: app"); + expect(idxPublic).toBeGreaterThanOrEqual(0); + expect(idxApp).toBeGreaterThan(idxPublic); + // Neither run's raw NOTE prefix leaked into the rendered diff. + expect(text).not.toContain("NOTE: Configuring authentication for DESKTOP mode."); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("fails with invalid_output when a run's own --json-diff stdout doesn't parse", () => { + const s = setup(tmp.current, { pgadminStdout: ["not valid json"] }); + return Effect.gen(function* () { + const error = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ + _tag: "LegacyDbDiffPgAdminError", + reason: "invalid_output", + }); + expect((error as { message: string }).message).toContain( + "failed to parse schema diff output:", + ); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "emits a failed run's captured progress statuses before the container-error surfaces", + () => { + // Go's stderr goroutine (`NewDiffStream`'s `io.Pipe`) scans progress + // concurrently WHILE the container runs, so a run that later exits non-zero + // still had its status lines printed already. This port batches stderr via + // `runCapture` instead of streaming it, so parity requires processing/ + // emitting that batch BEFORE the exit-code check, not after returning early. + const s = setup(tmp.current, { + pgadminExitCode: 1, + pgadminStderr: ["Comparing Tables 45%\nDiffing 100%\n"], + }); + return Effect.gen(function* () { + const error = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ + _tag: "LegacyDbDiffPgAdminError", + reason: "differ", + message: "error running container: exit 1", + }); + const text = stdout(s.out); + expect(text).toContain("Comparing Tables \n"); + expect(text).toContain("Diffing 1\n"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "in stream-json mode, a failed run's captured progress statuses redirect to stderr (CLI-1546) but are still emitted before the container-error result", + () => { + const s = setup(tmp.current, { + format: "stream-json", + pgadminExitCode: 1, + pgadminStderr: ["Comparing Tables 45%\n"], + }); + return Effect.gen(function* () { + const error = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ _tag: "LegacyDbDiffPgAdminError", reason: "differ" }); + expect(stderr(s.out)).toContain("Comparing Tables \n"); + expect(stdout(s.out)).toBe(""); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "fails with 'error running container: exit 1' when the differ exits non-zero, and still removes the shadow", + () => { + const s = setup(tmp.current, { + pgadminExitCode: 1, + pgadminStderr: ["some differ crash text\n"], + }); + return Effect.gen(function* () { + const error = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ + _tag: "LegacyDbDiffPgAdminError", + reason: "differ", + message: "error running container: exit 1", + }); + // The differ's own stderr never reaches the error message (Go quirk — it + // only ever fed the progress-line filter). + expect((error as { message: string }).message).not.toContain("some differ crash text"); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("fails with 'error running container: exit 137' on an OOM-killed differ", () => { + const s = setup(tmp.current, { pgadminExitCode: 137 }); + return Effect.gen(function* () { + const error = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ + _tag: "LegacyDbDiffPgAdminError", + reason: "differ", + message: "error running container: exit 137", + }); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("classifies a differ spawn failure as docker_daemon", () => { + const s = setup(tmp.current, { pgadminDockerFail: "spawn" }); + return Effect.gen(function* () { + const error = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ _tag: "LegacyDbDiffPgAdminError", reason: "docker_daemon" }); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("classifies a differ image-pull failure as registry_pull", () => { + const s = setup(tmp.current, { pgadminDockerFail: "pull" }); + return Effect.gen(function* () { + const error = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ _tag: "LegacyDbDiffPgAdminError", reason: "registry_pull" }); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "fails with 'supabase start is not running.' before ever creating a shadow, but after the target resolve", + () => { + const s = setup(tmp.current, { dbNotRunning: true }); + return Effect.gen(function* () { + const error = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ _tag: "LegacyDbDiffDbNotRunningError" }); + expect(stripAnsi((error as { message: string }).message)).toBe( + "supabase start is not running.", + ); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toEqual([]); + expect(s.differCalls).toEqual([]); + // The target was still resolved BEFORE the running-check failed — Go + // resolves the target in the root PersistentPreRunE, strictly before + // RunPgAdmin's AssertSupabaseDbIsRunning. + expect(s.resolverCalls.length).toBeGreaterThan(0); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "classifies a daemon-unreachable local-db inspect as daemonDown with the Docker install suggestion", + () => { + const s = setup(tmp.current, { + dbInspectFailsWith: + "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", + }); + return Effect.gen(function* () { + const error = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ _tag: "LegacyDbDiffDbNotRunningError", daemonDown: true }); + expect((error as { suggestion?: string }).suggestion).toContain("Docker Desktop"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "propagates a failed shadow platform-baseline job and still removes the shadow (pgAdmin path)", + () => { + const s = setup(tmp.current, { failShadowSetupJob: true }); + return Effect.gen(function* () { + const exit = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "fails with LegacyDbDiffWriteError when writing the pgAdmin --file migration fails", + () => { + // Call #1 is the shadow's own `revoke-api-privileges.sql` write + // (`legacyApplyApiPrivileges`, shared by every diff engine); call #2 is the + // pgAdmin diff-file write itself. + const s = setup(tmp.current, { + pgadminStdout: [JSON.stringify([pgadminEntry()])], + failWriteOnCall: 2, + }); + return Effect.gen(function* () { + const error = yield* legacyDbDiff( + flags({ usePgAdmin: Option.some(true), file: Option.some("pgadmin_diff") }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "LegacyDbDiffWriteError" }); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "fails on engine-flag conflict (--use-pgadmin with --use-pg-delta), byte-exact cobra message", + () => { + const s = setup(tmp.current); + return Effect.gen(function* () { + const error = yield* legacyDbDiff( + flags({ usePgAdmin: Option.some(true), usePgDelta: Option.some(true) }), + ).pipe(Effect.flip); + expect((error as { message: string }).message).toBe( + "if any flags in the group [use-migra use-pgadmin use-pg-schema use-pg-delta] are set none of the others can be; [use-pg-delta use-pgadmin] were all set", + ); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "fails on target mutex when --use-pgadmin is combined with --linked and --local", + () => { + const s = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* legacyDbDiff( + flags({ + usePgAdmin: Option.some(true), + linked: Option.some(true), + local: Option.some(true), + }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "explicit --from/--to wins over --use-pgadmin (pgadmin is ignored, pg-delta runs)", + () => { + const s = setup(tmp.current, { isLocal: false, diffSql: "create table explicit ();\n" }); + return Effect.gen(function* () { + yield* legacyDbDiff( + flags({ + usePgAdmin: Option.some(true), + from: Option.some("local"), + to: Option.some("linked"), + }), + ); + expect(s.differCalls).toEqual([]); + expect(s.edgeCalls[0]?.script).toContain("renderPlanFiles"); + expect(stdout(s.out)).toBe("create table explicit ();\n"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.live( + "removes the shadow container on interruption during the health wait for --use-pgadmin too", + () => { + const s = setup(tmp.current, { neverHealthyShadow: true }); + return Effect.gen(function* () { + const fiber = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.provide(s.layer), + Effect.forkChild({ startImmediately: true }), + ); + // Wait for the SHADOW's own health probe specifically (its 64-hex id) — + // the pgadmin path's separate `supabase_db_test` "is running" probe fires + // first and would otherwise satisfy a looser check immediately. + while ( + !s.shadowSpawned.some( + (c) => + c.args[0] === "container" && + c.args[1] === "inspect" && + c.args[2] === LEGACY_FAKE_SHADOW_CONTAINER_ID, + ) + ) { + yield* Effect.sleep("5 millis"); + } + yield* Fiber.interrupt(fiber); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + expect(s.differCalls).toEqual([]); + }); + }, + ); + }); }); diff --git a/apps/cli/src/legacy/commands/db/diff/diff.layers.ts b/apps/cli/src/legacy/commands/db/diff/diff.layers.ts index 257a0b3746..b47fe46396 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.layers.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.layers.ts @@ -17,16 +17,19 @@ import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-s * Runtime layer for `supabase db diff`. * * Mirrors `db schema declarative generate` (`generate.layers.ts`): the db-config - * resolver plus the native pg-delta / migra stack — the edge-runtime runner, the - * SSL probe, and `HttpClient` (the native shadow's health-check wait). Shadow - * provisioning (both `db diff`'s own and the explicit `--from migrations`/`--to - * migrations` catalog shadow) is fully native (CLI-1956/CLI-1959) — see - * `commands/db/shared/legacy-shadow-source.ts` and `shared/legacy-pgdelta.cache.ts` - * — so no `LegacyDeclarativeSeam` layer is needed here (`--use-pgadmin`/ - * `--use-pg-schema` delegate through `LegacyGoProxy` instead, not this seam). + * resolver plus the native pg-delta / migra / pgAdmin stack — the edge-runtime + * runner, the SSL probe, and `HttpClient` (the native shadow's health-check wait). + * Shadow provisioning (`db diff`'s own — migra/pg-delta AND pgadmin alike — plus + * the explicit `--from migrations`/`--to migrations` catalog shadow) is fully + * native (CLI-1956/CLI-1959/CLI-1968) — see `commands/db/shared/ + * legacy-shadow-source.ts` and `shared/legacy-pgdelta.cache.ts` — so no + * `LegacyDeclarativeSeam` layer is needed here. `--use-pg-schema` is now the + * only engine that delegates through `LegacyGoProxy` (CLI-1960's keep-in-Go + * exception); `--use-pgadmin` uses `LegacyDockerRun` natively instead, the same + * service the migra OOM bash fallback already needed. * `LegacyDockerRun` is exposed in the merge (not just provided to the - * edge-runtime layer) because the migra OOM bash fallback runs the - * `supabase/migra` container directly. + * edge-runtime layer) because both the migra OOM bash fallback and the pgadmin + * differ container run their own container directly. * Per the "provide doesn't share to siblings" rule, `LegacyCliConfig` is provided * to every layer that needs it. */ diff --git a/apps/cli/src/legacy/commands/db/diff/diff.live.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.live.test.ts index 35adf5826b..8d9c0cf9b2 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.live.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.live.test.ts @@ -1,12 +1,21 @@ +import { execFile } from "node:child_process"; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { promisify } from "node:util"; import { afterEach, expect, test } from "vitest"; import { describeLive, runSupabaseLive } from "../../../../../tests/helpers/live.ts"; +const execFileAsync = promisify(execFile); + const START_TIMEOUT_MS = 280_000; +// Lifecycle allowance for scenarios that run TWO full-budget subprocesses (`start` +// then the command under test) plus init/inspection overhead — same shape as +// `start.live.test.ts`. A single shared `START_TIMEOUT_MS` test budget would let a +// slow-but-valid `start` starve the command under test before it ever runs. +const LIFECYCLE_OVERHEAD_MS = 90_000; // CLI-1947 regression: pg-delta's `filterPublicBuiltInDefaults()` unconditionally // treated PUBLIC's implicit built-in privilege as a no-op on both sides of a diff, @@ -101,3 +110,115 @@ revoke execute on function public.probe_fn() from public; }, ); }); + +// CLI-1968: `--use-pgadmin` is a native `docker run` of the differ container, no +// edge-runtime and no Go delegation involved. Golden-path smoke coverage only — the +// pure filtering/progress logic and the docker-run argv are covered exhaustively by +// `legacy-pgadmin-diff.unit.test.ts` and `diff.integration.test.ts`; this just proves +// the real container actually runs against a real local stack and cleans up after +// itself either way. +// +// The real, reachable outcome here is a FAILURE, not a golden diff, and by design in +// BOTH CLIs: the differ container joins the project's own bridge network +// (`supabase_network_`, `docker.go:378-382`), and Go hardcodes both diff +// endpoints as loopback URLs from that container's own point of view — `source` +// (`utils.ToPostgresURL`, resolving `GetHostname()` to `127.0.0.1` for a local target) +// and `target` (`postgresql://postgres:postgres@127.0.0.1:/postgres`, +// `pgadmin.go:85-86`). Inside a bridge-attached container, `127.0.0.1` is the +// container's OWN loopback, not the host's — so neither the local db nor the shadow is +// reachable from inside the differ, and the container exits non-zero. This holds +// identically for the real Go CLI (identical argv, identical network, identical +// hardcoded hosts), so there is no live A/B needed to establish it — see +// `SIDE_EFFECTS.md`'s "Network reachability" entry for the full static ruling. (The +// `DiffStream` value-receiver divergence documented there — the real Go CLI always +// reporting "No schema changes found" regardless of the differ's actual output — only +// ever engages when the differ container exits 0; it plays no role in this failure +// path.) Note that a plain `--network-id host` does NOT rescue a golden run here: it +// also rewires the SHADOW container onto host networking, discarding its own +// `54320->5432` port publish that `target` depends on — so `source` would become +// reachable but `target` would not, still failing the diff. This suite therefore +// verifies the real, always-reachable failure mode end-to-end, plus that both the +// differ AND the shadow container it provisions are still cleaned up. +describeLive("supabase db diff (live, --use-pgadmin native differ container)", () => { + let projectDir: string | undefined; + let projectId: string | undefined; + + afterEach(async () => { + if (projectDir === undefined) return; + // Best-effort cleanup even if an assertion above failed mid-lifecycle — a + // leaked local stack would otherwise pollute the CI runner for later jobs. + await runSupabaseLive(["stop", "--no-backup"], { cwd: projectDir }).catch(() => undefined); + await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); + projectDir = undefined; + projectId = undefined; + }); + + test( + "runs the native differ container against the real stack, surfaces Go's error running container failure, and leaves no differ container behind", + { timeout: START_TIMEOUT_MS * 2 + LIFECYCLE_OVERHEAD_MS }, + async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "sb-db-diff-pgadmin-live-")); + // No `project_id` override, so the cli resolves it from the workdir basename — + // matching Go's precedence exactly (see legacy-docker-ids.ts), same as + // `stop.live.test.ts`. + projectId = path.basename(projectDir); + + const init = await runSupabaseLive(["init"], { cwd: projectDir }); + expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); + + // Exclude the heaviest, least relevant services — `db diff --use-pgadmin` only + // needs the local Postgres container reachable, same rationale as stop/status. + const start = await runSupabaseLive( + ["start", "--exclude", "studio", "--exclude", "analytics", "--exclude", "vector"], + { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, + ); + expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); + + const diff = await runSupabaseLive(["db", "diff", "--use-pgadmin"], { + cwd: projectDir, + exitTimeoutMs: START_TIMEOUT_MS, + }); + // Both hardcoded loopback endpoints are unreachable from inside the + // bridge-attached differ container (see this suite's own header comment for the + // full, static ruling) — the differ exits non-zero and the CLI surfaces Go's own + // wrapper message. The differ's own exit code isn't pinned: only that the differ + // ran and failed, not the shadow/connection machinery around it. + expect(diff.exitCode, `stdout:\n${diff.stdout}\nstderr:\n${diff.stderr}`).toBe(1); + expect(diff.stderr).toContain("error running container: exit "); + + // The differ is a one-shot `docker run --rm` — real Docker must agree that no + // container survives it, the same "the daemon must agree" check + // `stop.live.test.ts` runs against `com.supabase.cli.project`. + const { stdout: remainingDiffer } = await execFileAsync("docker", [ + "ps", + "-a", + "--filter", + "ancestor=supabase/pgadmin-schema-diff:cli-0.0.5", + "--format", + "{{.ID}}", + ]); + expect(remainingDiffer.trim()).toBe(""); + + // This failure path exercises the shadow's `acquireUseRelease` teardown for + // real (the differ error propagates out of the `use` phase after the shadow was + // already created) — the shadow itself is created with no `--name` (Docker + // auto-generates one), unlike every real stack container, which is always named + // `supabase__`. So a leaked shadow shows up as a + // project-labeled container whose name does NOT carry that fixed prefix. + const { stdout: projectContainers } = await execFileAsync("docker", [ + "ps", + "-a", + "--filter", + `label=com.supabase.cli.project=${projectId}`, + "--format", + "{{.Names}}", + ]); + const names = projectContainers + .trim() + .split("\n") + .filter((name) => name.length > 0); + expect(names.length).toBeGreaterThan(0); + expect(names.every((name) => name.startsWith("supabase_"))).toBe(true); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/diff/legacy-pgadmin-diff.ts b/apps/cli/src/legacy/commands/db/diff/legacy-pgadmin-diff.ts new file mode 100644 index 0000000000..47d5d441aa --- /dev/null +++ b/apps/cli/src/legacy/commands/db/diff/legacy-pgadmin-diff.ts @@ -0,0 +1,443 @@ +/** + * Native port of Go's pgAdmin schema-diff engine + * (`apps/cli-go/internal/db/diff/pgadmin.go`, `apps/cli-go/internal/utils/container_output.go`) + * — CLI-1968. `db diff` is the only caller in Go (`cmd/db.go:115` is the sole `RunPgAdmin` call + * site), so this stays colocated with the command rather than under `commands/db/shared/`; move + * it there (and split the error into its own `legacy-pgadmin-diff.errors.ts`, mirroring + * `legacy-migra.ts`/`legacy-migra.errors.ts`) if a second command ever needs it. + * + * Covers the two pure halves — `ProcessDiffProgress`/`ProcessDiffOutput` + * (`container_output.go:94-201`, split here into `legacyParsePgAdminDiffEntries` + + * `legacyRenderPgAdminDiff`, recomposed as `legacyProcessPgAdminDiffOutput` for a single + * whole buffer) — and the container-invocation loop, `DiffSchemaPgAdmin` (`pgadmin.go:91-121`). + * Shadow provisioning, the `Creating shadow database...`/`Diffing local database with + * current migrations...` status lines, and `SaveDiff` all stay in `diff.handler.ts`, + * matching Go's own module boundary. + * + * **Deliberate divergence, not bug-for-bug parity (`container_output.go:79,87`):** Go's + * `DiffStream` declares `Stdout()`/`Collect()` on a VALUE receiver (`func (c DiffStream) + * ...`, not `*DiffStream`), so every call runs against its OWN COPY of the struct's `o + * bytes.Buffer` field. `Stdout()` returns `&c.o` of the copy made for THAT call, and the + * differ's stdout is written into it — a buffer `Collect()` (called later, on a DIFFERENT + * copy) never sees. `Collect()`'s own `c.o` is therefore always the zero-value empty buffer, + * so `ProcessDiffOutput` always receives zero bytes — the real Go CLI's `--use-pgadmin` + * ALWAYS reports "No schema changes found" (exit 0), regardless of the differ's actual + * output: it never writes a migration file and never hits a JSON-parse error, on ANY schema + * count. (`Stderr()`/progress DOES work: `c.w` is a `*io.PipeWriter`, a reference type, so + * every copy shares the same pipe.) This port implements the INTENDED algorithm — the one + * `NewDiffStream`'s own comments and `Collect`'s call to `ProcessDiffOutput` clearly intend + * — but completes it by parsing EACH run's own real stdout separately and aggregating the + * kept DDLs across runs, rather than gluing every run's raw bytes into one buffer and parsing + * that once: a shared *byte* buffer was never the intent behind `NewDiffStream`'s design, only + * a means to see every run's output at all, and concatenating raw JSON arrays before parsing + * turns a multi-`--schema` diff whose every run individually parses fine into a spurious + * `JSON.parse` "trailing data" failure — the worst of both worlds, matching neither Go-as- + * shipped (always an empty, successful diff) nor this algorithm's own evident purpose. So + * where the real Go binary silently reports an empty diff no matter what the differ produced, + * this port produces the actual, aggregated diff across every run (or a real per-run + * JSON-parse error — see `legacyParsePgAdminDiffEntries`'s own doc comment). Ruling: keep this + * port's (correct) implementation rather than reproducing the empty-buffer bug; see + * `SIDE_EFFECTS.md`'s "Deliberate divergence" entry for the user-facing framing. + */ + +import { Effect, Option, Result } from "effect"; + +import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; +import { LEGACY_COMPOSE_PROJECT_LABEL } from "../../../shared/db-bootstrap/container-lifecycle.ts"; +import { LEGACY_CLI_PROJECT_LABEL } from "../../../shared/legacy-docker-ids.ts"; +import { LegacyDockerRun } from "../../../shared/legacy-docker-run.service.ts"; +import { legacyTrimGoSpace } from "../shared/legacy-go-string.ts"; +import { LEGACY_INTERNAL_SCHEMAS } from "../shared/legacy-pg-dump.env.ts"; +import { LegacyDbDiffPgAdminError } from "./diff.errors.ts"; + +/** Go's `config.Images.Differ` (`pkg/config/templates/Dockerfile:18`, `FROM … AS differ`). */ +const LEGACY_DIFFER_IMAGE = dockerfileServiceImage("differ"); + +/** + * Go's `ProcessDiffOutput` (`container_output.go:142-143`) trims this front-anchored only + * (`bytes.TrimPrefix`, not a global strip). `legacyParsePgAdminDiffEntries` runs once per + * differ run now, so each run's OWN copy of this note (a real pgAdmin4 quirk, + * `supabase/pgadmin4#24`) is trimmed off the front of that run's own buffer — + * `legacyProcessPgAdminDiffOutput`, applied to a single whole buffer, still only strips the + * very front of whatever string it's given. + */ +export const LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX = + "NOTE: Configuring authentication for DESKTOP mode.\n"; + +/** Go's `diffHeader` (`container_output.go:136-139`), verbatim. */ +export const LEGACY_PGADMIN_DIFF_HEADER = `-- This script was generated by the Schema Diff utility in pgAdmin 4 +-- For the circular dependencies, the order in which Schema Diff writes the objects is not very sophisticated +-- and may require manual changes to the script to ensure changes are applied in the correct order. +-- Please report an issue for any failure with the reproduction steps.`; + +/** Go's `switch diffEntry.Type` allow-list (`container_output.go:160-165`). */ +const LEGACY_PGADMIN_DIFF_TYPES = new Set([ + "extension", + "function", + "mview", + "table", + "trigger_function", + "type", + "view", +]); + +/** + * Go's `(.*)([[:digit:]]{2,3})%` (`container_output.go:96`), compiled with the `s` + * (dotAll) flag: Go's RE2 `.` matches every character except `\n` — INCLUDING `\r` + * — when no `(?s)` flag is set, but JS's `.` excludes every line-terminator code + * point (`\r`, `\n`, U+2028, U+2029) unless `s` is set. `legacyScanLines` only splits + * on `\n` (matching `bufio.ScanLines`), so a line can still carry embedded `\r`s from + * a `\r`-driven progress bar (multiple updates overwriting the same terminal line); + * without `s`, this pattern would stop matching at the first embedded `\r` in JS but + * not in Go. No alternation, so RE2's leftmost-longest overall match still coincides + * with JS's leftmost-first greedy backtracking for the digit/percent suffix — + * verified empirically against the real Go binary (`go run` with this exact + * pattern): both engines pick the SAME (surprising, greedy) submatch, e.g. + * `"Diffing 100%"` → group 1 `"Diffing 1"`, group 2 `"00"`. + */ +const LEGACY_PGADMIN_PROGRESS_RE = /(.*)([0-9]{2,3})%/s; + +/** + * Splits `stderr` the way Go's `bufio.NewScanner(out).Scan()` does with the default + * `ScanLines` split function: `\r\n`/`\n`-terminated lines with the trailing `\r` (if any) + * stripped, and a final, non-newline-terminated fragment still emitted as its own line. An + * empty input yields zero lines (Go's scanner returns `false` on the very first `Scan()`). + * One known divergence: Go's scanner aborts (`bufio.Scanner: token too long`) on any line + * exceeding `bufio.MaxScanTokenSize` (64KiB) — this function has no such limit, so an + * abnormally long differ progress line is still scanned here where Go would give up. + */ +function legacyScanLines(text: string): ReadonlyArray { + if (text.length === 0) return []; + const lines = text.split("\n"); + // A trailing `\n` produces one trailing empty element from `split` that Go's scanner never + // emits as a token of its own — the `\n` itself already terminated the prior line. + const withoutTrailingNewline = text.endsWith("\n") ? lines.slice(0, -1) : lines; + return withoutTrailingNewline.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line)); +} + +/** + * Port of Go's `ProcessDiffProgress` (`container_output.go:94-124`) — the StatusMsg lines + * `fakeProgram`/`tea.Program` would print (progress percentages themselves are dropped: + * `ProgressMsg` never prints in either program mode). `"Starting schema diff..."` and any + * non-matching line (Go's `// TODO: emit actual error statements`) produce nothing, matching + * Go's `continue`. Only Go's NON-TTY `fakeProgram` actually prints a StatusMsg via + * `fmt.Println` (`tea.go:57-70`) — on a TTY Go instead runs the real `bubbletea` renderer, + * which repaints ephemeral frames rather than appending printed lines. This port's stdout + * emission (`diff.handler.ts`'s `emitStatus`) targets the non-TTY `fakeProgram` behavior; a + * TTY session's frame-by-frame rendering has no TS equivalent and isn't a parity target. + */ +export function legacyProcessPgAdminDiffProgress(stderr: string): ReadonlyArray { + const statuses: Array = []; + for (const line of legacyScanLines(stderr)) { + const match = LEGACY_PGADMIN_PROGRESS_RE.exec(line); + if (match === null) continue; + statuses.push(match[1] ?? ""); + } + return statuses; +} + +/** + * Go's `DiffDependencies` (`container_output.go:123-125`). Field kept snake_case (the literal + * wire key), not camelCased, so the guard below reads the parsed JSON 1:1. + */ +interface LegacyPgAdminDiffDependency { + readonly type?: string | null; +} + +/** Go's `DiffEntry` (`container_output.go:127-134`) — one `--json-diff` array element. */ +interface LegacyPgAdminDiffEntry { + readonly type?: string | null; + readonly status?: string | null; + readonly diff_ddl?: string | null; + readonly group_name?: string | null; + readonly dependencies?: ReadonlyArray | null; + readonly source_schema_name?: string | null; +} + +/** + * Go's `DiffDependencies` has no custom unmarshaler, so a present field's type is checked + * exactly like every other `DiffEntry` scalar below — see {@link legacyIsPgAdminDiffEntryElement}'s + * own doc comment for the shared "null tolerated per field" rule and its empirical verification. + */ +function legacyIsPgAdminDiffDependencyElement( + value: unknown, +): value is LegacyPgAdminDiffDependency | null { + if (value === null) return true; + if (typeof value !== "object" || Array.isArray(value)) return false; + if ("type" in value && value.type !== null && typeof value.type !== "string") return false; + return true; +} + +/** + * Structural guard for Go's `DiffEntry` JSON shape, applied to an untrusted `JSON.parse` of + * the differ's stdout. Verified empirically against the real Go struct (`encoding/json`), + * one throwaway `go run` per row: + * - a bare `null` array element unmarshals into the zero-valued struct (every field absent/""), + * so it is accepted here too — the caller normalizes it away before this guard ever sees it; + * - a non-null, non-object element (`{}`/`"x"`/`1`/`true`/an array) always fails Go's whole + * `[]DiffEntry` unmarshal, not just that one entry — rejected here the same way; + * - `null` for an individual DECLARED scalar field (`type`/`status`/`diff_ddl`/`group_name`/ + * `source_schema_name`, all plain `string`/`*string`, no custom unmarshaler) is tolerated + * with no error, leaving the zero value — so `{"status":null}` is accepted, not rejected; + * - a MISTYPED declared field (`{"type":123}`, `{"dependencies":{}}`, `{"dependencies":[1]}`, + * a `dependencies[].type` that isn't a string) fails the whole unmarshal, so every array + * field's own elements are validated too, not just its own top-level shape. + */ +function legacyIsPgAdminDiffEntryElement(value: unknown): value is LegacyPgAdminDiffEntry | null { + if (value === null) return true; + if (typeof value !== "object" || Array.isArray(value)) return false; + if ("type" in value && value.type !== null && typeof value.type !== "string") return false; + if ("status" in value && value.status !== null && typeof value.status !== "string") return false; + if ("diff_ddl" in value && value.diff_ddl !== null && typeof value.diff_ddl !== "string") { + return false; + } + if ("group_name" in value && value.group_name !== null && typeof value.group_name !== "string") { + return false; + } + if ( + "source_schema_name" in value && + value.source_schema_name !== null && + typeof value.source_schema_name !== "string" + ) { + return false; + } + if ("dependencies" in value && value.dependencies !== null) { + if ( + !Array.isArray(value.dependencies) || + !value.dependencies.every(legacyIsPgAdminDiffDependencyElement) + ) { + return false; + } + } + return true; +} + +/** + * Port of the parse/filter half of Go's `ProcessDiffOutput` (`container_output.go:141-201`) + * — pure, no Effect. Trims the DESKTOP-mode NOTE prefix off the FRONT of `stdout` (a real + * pgAdmin4 quirk, `supabase/pgadmin4#24`), then parses and filters it into the ordered list + * of kept, trimmed DDL strings (Go's `[]DiffEntry` unmarshal + the `switch diffEntry.Type` + * allow-list + internal-schema/extension-dependency filtering). Rendering the header and + * joining is `legacyRenderPgAdminDiff`'s job, kept separate so `legacyDiffSchemaPgAdmin`'s + * run loop can parse EACH run's own buffer (trimming that run's own DESKTOP-mode note, if + * any) and aggregate every run's DDLs before rendering once — completing the intended + * shared-buffer algorithm's purpose (see this module's own header comment) without the + * round-1 regression of gluing raw bytes together first, which turned a multi-`--schema` + * diff where every run individually parsed fine into one spurious `JSON.parse` "trailing + * data" failure. + */ +export function legacyParsePgAdminDiffEntries( + stdout: string, +): Result.Result, { readonly message: string }> { + const trimmed = stdout.startsWith(LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX) + ? stdout.slice(LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX.length) + : stdout; + if (trimmed.length === 0) return Result.succeed([]); + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch (cause) { + return Result.fail({ + message: `failed to parse schema diff output: ${cause instanceof Error ? cause.message : String(cause)}`, + }); + } + // `json.Unmarshal` into a non-pointer `[]DiffEntry` accepts a top-level JSON `null` as a + // no-op (nil slice) — normalize it to `[]` before the array guard below, matching + // `legacyIsPgDeltaApplyResult`'s identical `null` handling. + const entries: unknown = parsed === null ? [] : parsed; + if (!Array.isArray(entries) || !entries.every(legacyIsPgAdminDiffEntryElement)) { + return Result.fail({ + message: "failed to parse schema diff output: not a valid schema-diff entry array", + }); + } + + const filteredDdls: Array = []; + for (const rawEntry of entries) { + const entry = rawEntry ?? {}; + const status = entry.status ?? ""; + const diffDdl = entry.diff_ddl ?? ""; + if (status === "Identical" || diffDdl === "") continue; + if (!LEGACY_PGADMIN_DIFF_TYPES.has(entry.type ?? "")) continue; + const dependencies = entry.dependencies ?? []; + if (dependencies.some((dep) => (dep?.type ?? "") === "extension")) continue; + const groupName = entry.group_name ?? ""; + const sourceSchemaName = entry.source_schema_name ?? undefined; + if ( + LEGACY_INTERNAL_SCHEMAS.includes(groupName) || + (sourceSchemaName !== undefined && LEGACY_INTERNAL_SCHEMAS.includes(sourceSchemaName)) + ) { + continue; + } + const trimmedDdl = legacyTrimGoSpace(diffDdl); + if (trimmedDdl.length > 0) filteredDdls.push(trimmedDdl); + } + + return Result.succeed(filteredDdls); +} + +/** Go's `diffHeader`-plus-join half of `ProcessDiffOutput` (`container_output.go:196-200`). */ +export function legacyRenderPgAdminDiff(ddls: ReadonlyArray): string { + if (ddls.length === 0) return ""; + return `${LEGACY_PGADMIN_DIFF_HEADER}\n\n${ddls.join("\n\n")}\n`; +} + +/** + * Parse-then-render composition of the two halves above, applied to a SINGLE, whole buffer + * — kept for callers (and this file's own unit tests) that want Go's `ProcessDiffOutput` as + * one function over one buffer. `legacyDiffSchemaPgAdmin`'s run loop calls + * `legacyParsePgAdminDiffEntries`/`legacyRenderPgAdminDiff` directly instead, once per run, + * so this function's own single-buffer semantics (including the multi-JSON-array + * "trailing data" failure on a buffer that concatenates >=1 complete arrays) are unchanged + * but no longer reachable from a multi-`--schema` diff. + */ +export function legacyProcessPgAdminDiffOutput( + stdout: string, +): Result.Result { + return Result.map(legacyParsePgAdminDiffEntries(stdout), legacyRenderPgAdminDiff); +} + +/** + * Maps `LegacyDockerRunError`'s own three-way docker-boundary discriminant onto this + * command's `reason` union — mirrors `legacyDbSetupDockerReason` (`db-setup.ts`): a spawn + * failure or a detected daemon-down message means the daemon itself is unreachable, a failed + * image inspect is a config/registry-availability issue distinct from a pull failure, and + * everything else at this boundary is a registry-pull failure. `diffMigraBash` + * (`legacy-migra.ts`) keeps its own pre-existing two-way collapse (`inspect` folded into + * `pull`) — out of scope for this port. + */ +function legacyPgAdminDockerReason( + reason: "spawn" | "inspect" | "pull", + daemonDown: boolean, +): "docker_daemon" | "image_inspect" | "registry_pull" { + if (reason === "spawn" || daemonDown) return "docker_daemon"; + if (reason === "pull") return "registry_pull"; + return "image_inspect"; +} + +export interface LegacyDiffSchemaPgAdminParams { + /** Go's `source` — the USER'S db (`ToPostgresURL(flags.DbConfig)`, `pgadmin.go:85`). */ + readonly source: string; + /** Go's `target` — the SHADOW, a raw `Sprintf` (`pgadmin.go:86`), not `ToPostgresURL`. */ + readonly target: string; + readonly schema: ReadonlyArray; + /** Merged onto both docker labels, matching every other container this codebase creates. */ + readonly projectId: string; + /** + * Already `--network-id`/`SUPABASE_NETWORK_ID`/`supabase_network_`-resolved by + * the caller (`legacyResolveNetworkId`, via `legacyBuildLocalDbContainerInputs`'s + * `localInputs.networkId`) — never empty, so this function does no second resolution and, + * unlike `legacy-migra.ts`'s `diffMigraBash`, never falls back to a host network: Go's + * differ always joins a user-defined bridge (`docker.go:379-383`). + */ + readonly networkId: string; + /** Linux-only `host.docker.internal:host-gateway` (`docker_linux.go`); empty elsewhere. */ + readonly extraHosts: ReadonlyArray; + /** Text-mode stdout sink for the `Diffing schema: ` / progress status lines; no-op in machine output modes. */ + readonly emitStatus: (line: string) => Effect.Effect; +} + +/** + * Port of Go's `DiffSchemaPgAdmin` (`pgadmin.go:91-121`) — one differ container run when no + * `--schema` is given, else one run per `--schema` (in flag order), each preceded by its own + * `Diffing schema: ` status. `runCapture`, not `runStream`, because the differ's progress + * lines arrive on STDERR, and `LegacyDockerRun.runStream` only exposes an `onStdout` streaming + * hook — there is no `onStderr` equivalent to observe stderr incrementally through this + * service today. Go, by contrast, DOES live-stream: `NewDiffStream` pipes the container's + * stderr through an `io.Pipe`, with a goroutine scanning `ProcessDiffProgress` off the read end + * WHILE the container is still running, so a status line prints the instant its underlying + * stderr line arrives. This port instead buffers each run's stderr in full via `runCapture` and + * only filters/flushes it (`legacyProcessPgAdminDiffProgress` + `emitStatus`, below) once that + * run's container has already exited — so a multi-`--schema` diff still gets one status batch + * per run, but within a single run every one of its status lines appears together, after the + * fact, instead of as the differ actually emits them. A real fix would add an `onStderr` + * streaming hook to `runStream`, mirroring `onStdout`, and switch this function to it. + * `teeStderr` stays off regardless (Go never tees the differ's raw stderr to the parent + * terminal). The image is passed raw (not pre-resolved via `legacyGetRegistryImageUrl`, unlike + * `diffMigraBash`): `legacyDockerRunLayer`'s own resolver builds the ECR→GHCR→docker.io + * candidate ladder from it — reading `SUPABASE_INTERNAL_IMAGE_REGISTRY` straight off + * `process.env` at call time (no `projectEnvValues` passed through). It is the caller's + * (`diff.handler.ts`) own `legacyApplyProjectEnv` scope, applied right after the config + * load, that makes a registry override set only in the project's `supabase/.env` (not + * the ambient shell) visible to that resolver by the time this function's `runCapture` + * call reaches it. + */ +export const legacyDiffSchemaPgAdmin = ( + params: LegacyDiffSchemaPgAdminParams, +): Effect.Effect => + Effect.gen(function* () { + const docker = yield* LegacyDockerRun; + const labels = { + [LEGACY_CLI_PROJECT_LABEL]: params.projectId, + [LEGACY_COMPOSE_PROJECT_LABEL]: params.projectId, + }; + const network = { _tag: "named" as const, name: params.networkId }; + const runs: ReadonlyArray = + params.schema.length === 0 ? [undefined] : params.schema; + + const ddls: Array = []; + for (const s of runs) { + if (s !== undefined) yield* params.emitStatus(`Diffing schema: ${s}`); + const cmd = + s === undefined + ? ["--json-diff", params.source, params.target] + : ["--schema", s, "--json-diff", params.source, params.target]; + const result = yield* docker + .runCapture({ + image: LEGACY_DIFFER_IMAGE, + cmd, + env: {}, + binds: [], + workingDir: Option.none(), + securityOpt: [], + extraHosts: params.extraHosts, + network, + labels, + }) + .pipe( + Effect.mapError( + (cause) => + new LegacyDbDiffPgAdminError({ + message: cause.message, + reason: legacyPgAdminDockerReason(cause.reason, cause.daemonDown), + }), + ), + ); + // Emitted BEFORE the exit-code check below, matching Go's stderr goroutine: it scans + // `ProcessDiffProgress` off the container's stderr concurrently with the container + // still running (`NewDiffStream`'s `io.Pipe`), so a failed run's own status lines still + // print ahead of the container error surfacing. Returning early on a nonzero exit + // before reaching this would silently drop that run's already-captured statuses. + for (const line of legacyProcessPgAdminDiffProgress(result.stderr)) { + yield* params.emitStatus(line); + } + if (result.exitCode !== 0) { + // Go's `error running container: exit %d` (`docker.go:582-590`) — the differ's own + // stderr is never surfaced beyond the progress-line filter above; any non-matching + // line is silently dropped, even under `--debug`. + return yield* Effect.fail( + new LegacyDbDiffPgAdminError({ + message: `error running container: exit ${result.exitCode}`, + reason: "differ", + }), + ); + } + const stdout = new TextDecoder().decode(result.stdout); + // Parsed per run — completing the intended shared-buffer algorithm's actual purpose + // (see this module's own header comment) rather than round 1's literal-minded + // concatenate-then-parse-once, which turned a multi-`--schema` diff whose every run + // individually parsed fine into a spurious "trailing data" `JSON.parse` failure. + const parsed = legacyParsePgAdminDiffEntries(stdout); + if (Result.isFailure(parsed)) { + return yield* Effect.fail( + new LegacyDbDiffPgAdminError({ + message: parsed.failure.message, + reason: "invalid_output", + }), + ); + } + ddls.push(...parsed.success); + } + + return legacyRenderPgAdminDiff(ddls); + }); diff --git a/apps/cli/src/legacy/commands/db/diff/legacy-pgadmin-diff.unit.test.ts b/apps/cli/src/legacy/commands/db/diff/legacy-pgadmin-diff.unit.test.ts new file mode 100644 index 0000000000..b533eb8cfb --- /dev/null +++ b/apps/cli/src/legacy/commands/db/diff/legacy-pgadmin-diff.unit.test.ts @@ -0,0 +1,348 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Result } from "effect"; + +import { + LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX, + LEGACY_PGADMIN_DIFF_HEADER, + legacyParsePgAdminDiffEntries, + legacyProcessPgAdminDiffOutput, + legacyProcessPgAdminDiffProgress, + legacyRenderPgAdminDiff, +} from "./legacy-pgadmin-diff.ts"; + +/** Go's `DiffEntry` (`container_output.go:127-134`) shape, defaulting to a kept entry. */ +function entry(overrides: Record = {}) { + return { + type: "table", + status: "Different", + diff_ddl: "ALTER TABLE test;", + group_name: "public", + ...overrides, + }; +} + +const headerPlus = (ddl: string) => `${LEGACY_PGADMIN_DIFF_HEADER}\n\n${ddl}\n`; + +describe("legacyProcessPgAdminDiffOutput", () => { + describe("filtering rules (container_output.go:154-195)", () => { + it("keeps DDL from every whitelisted entry type, joined under the exact 4-line pgAdmin header", () => { + // Go test parity: `TestProcessDiffOutput/processes valid diff entries`. + const types = ["extension", "function", "mview", "table", "trigger_function", "type", "view"]; + const entries = types.map((type, i) => entry({ type, diff_ddl: `DDL_${i};` })); + const result = legacyProcessPgAdminDiffOutput(JSON.stringify(entries)); + const expectedDdls = types.map((_, i) => `DDL_${i};`).join("\n\n"); + expect(result).toEqual(Result.succeed(headerPlus(expectedDdls))); + }); + + it("skips an entry whose status is Identical, even with a non-empty diff_ddl", () => { + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([entry({ status: "Identical" })]), + ); + expect(result).toEqual(Result.succeed("")); + }); + + it("skips an entry whose diff_ddl is empty", () => { + const result = legacyProcessPgAdminDiffOutput(JSON.stringify([entry({ diff_ddl: "" })])); + expect(result).toEqual(Result.succeed("")); + }); + + it("skips an entry whose diff_ddl is only whitespace after Go's TrimSpace", () => { + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([entry({ diff_ddl: " \n\t " })]), + ); + expect(result).toEqual(Result.succeed("")); + }); + + it("skips entries whose type is outside the pgAdmin allow-list (e.g. sequence, index)", () => { + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([entry({ type: "sequence" }), entry({ type: "index" })]), + ); + expect(result).toEqual(Result.succeed("")); + }); + + it('skips an entry with no type field at all, given a non-empty diff_ddl (defaults to "", outside the allow-list)', () => { + // Distinct from the `[{"unknown":1}]` acceptance-rule case below, whose empty + // `diff_ddl` short-circuits at the PRIOR `status === "Identical" || diff_ddl === ""` + // check — this covers the `type` fallback itself. + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([ + { status: "Different", diff_ddl: "ALTER TABLE test;", group_name: "public" }, + ]), + ); + expect(result).toEqual(Result.succeed("")); + }); + + it("keeps an entry with no group_name field at all (empty group name is not an internal schema)", () => { + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([{ type: "table", status: "Different", diff_ddl: "ALTER TABLE test;" }]), + ); + expect(result).toEqual(Result.succeed(headerPlus("ALTER TABLE test;"))); + }); + + it("skips an entry when any dependency has type extension", () => { + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([entry({ dependencies: [{ type: "table" }, { type: "extension" }] })]), + ); + expect(result).toEqual(Result.succeed("")); + }); + + it("keeps an entry whose dependencies are all non-extension types", () => { + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([entry({ dependencies: [{ type: "table" }, { type: "view" }] })]), + ); + expect(result).toEqual(Result.succeed(headerPlus("ALTER TABLE test;"))); + }); + + it("skips an entry whose group_name is an internal schema (auth)", () => { + // Go test parity: `TestProcessDiffOutput/filters out internal schemas`. + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([entry({ group_name: "auth" })]), + ); + expect(result).toEqual(Result.succeed("")); + }); + + it("skips a trigger_function entry whose source_schema_name is an internal schema", () => { + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([ + entry({ type: "trigger_function", group_name: "public", source_schema_name: "auth" }), + ]), + ); + expect(result).toEqual(Result.succeed("")); + }); + + it("keeps group_name pg_catalog — internal-schema filtering is exact-string, not a pg_* glob", () => { + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([entry({ group_name: "pg_catalog" })]), + ); + expect(result).toEqual(Result.succeed(headerPlus("ALTER TABLE test;"))); + }); + + it("trims each kept DDL with Go's TrimSpace before joining", () => { + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([entry({ diff_ddl: " ALTER TABLE test; \n" })]), + ); + expect(result).toEqual(Result.succeed(headerPlus("ALTER TABLE test;"))); + }); + }); + + describe("empty / DESKTOP-mode-prefix handling (container_output.go:141-147)", () => { + it("returns an empty string for an entirely empty buffer", () => { + expect(legacyProcessPgAdminDiffOutput("")).toEqual(Result.succeed("")); + }); + + it("trims the DESKTOP-mode NOTE prefix from the front of the buffer before parsing", () => { + const payload = LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX + JSON.stringify([entry()]); + expect(legacyProcessPgAdminDiffOutput(payload)).toEqual( + Result.succeed(headerPlus("ALTER TABLE test;")), + ); + }); + + it("returns an empty string when the buffer is only the DESKTOP-mode NOTE prefix", () => { + expect(legacyProcessPgAdminDiffOutput(LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX)).toEqual( + Result.succeed(""), + ); + }); + + it("does not trim the DESKTOP-mode NOTE prefix when it isn't at the very front (Go's bytes.TrimPrefix is front-anchored only)", () => { + const payload = `[]${LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX}`; + expect(Result.isFailure(legacyProcessPgAdminDiffOutput(payload))).toBe(true); + }); + }); + + // Go-acceptance-rules table (`json.Unmarshal` into `[]DiffEntry`, `container_output.go:127-134`), + // verified against Go 1.26 `encoding/json`. + describe("Go encoding/json acceptance rules", () => { + it("treats a top-level JSON null the same as Go's nil-slice no-op", () => { + expect(legacyProcessPgAdminDiffOutput("null")).toEqual(Result.succeed("")); + }); + + it("returns an empty string for an empty array", () => { + expect(legacyProcessPgAdminDiffOutput("[]")).toEqual(Result.succeed("")); + }); + + it("accepts a null array element (Go unmarshals it into the zero-valued struct) and skips it", () => { + expect(legacyProcessPgAdminDiffOutput("[null]")).toEqual(Result.succeed("")); + }); + + it.each(["{}", '"x"', "1", "true"])( + "rejects a non-array top-level JSON value (%s)", + (payload) => { + expect(Result.isFailure(legacyProcessPgAdminDiffOutput(payload))).toBe(true); + }, + ); + + it("rejects an array whose element is neither an object nor null (e.g. a bare number)", () => { + expect(Result.isFailure(legacyProcessPgAdminDiffOutput("[1]"))).toBe(true); + }); + + it("rejects an array whose element is neither an object nor null (e.g. a bare string)", () => { + expect(Result.isFailure(legacyProcessPgAdminDiffOutput('["x"]'))).toBe(true); + }); + + it("rejects an array element that is itself an array", () => { + expect(Result.isFailure(legacyProcessPgAdminDiffOutput("[[]]"))).toBe(true); + }); + + it("accepts an unknown field and treats the entry as if absent, skipping it", () => { + expect(legacyProcessPgAdminDiffOutput('[{"unknown":1}]')).toEqual(Result.succeed("")); + }); + + it("treats a null status field as absent, not as Identical", () => { + const result = legacyProcessPgAdminDiffOutput(JSON.stringify([entry({ status: null })])); + expect(result).toEqual(Result.succeed(headerPlus("ALTER TABLE test;"))); + }); + + it("treats a null dependencies field as absent (no dependency filtering applied)", () => { + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([entry({ dependencies: null })]), + ); + expect(result).toEqual(Result.succeed(headerPlus("ALTER TABLE test;"))); + }); + + it("accepts a null dependency element and does not treat it as an extension dependency", () => { + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([entry({ dependencies: [null] })]), + ); + expect(result).toEqual(Result.succeed(headerPlus("ALTER TABLE test;"))); + }); + + it("rejects a dependencies array whose element is itself an array", () => { + expect(Result.isFailure(legacyProcessPgAdminDiffOutput('[{"dependencies":[[]]}]'))).toBe( + true, + ); + }); + + it("treats a null source_schema_name field as absent (not internal-schema-filtered)", () => { + const result = legacyProcessPgAdminDiffOutput( + JSON.stringify([entry({ source_schema_name: null })]), + ); + expect(result).toEqual(Result.succeed(headerPlus("ALTER TABLE test;"))); + }); + + it("rejects a mistyped type field (number instead of string)", () => { + expect(Result.isFailure(legacyProcessPgAdminDiffOutput('[{"type":123}]'))).toBe(true); + }); + + it("rejects a mistyped status field (number instead of string)", () => { + expect(Result.isFailure(legacyProcessPgAdminDiffOutput('[{"status":123}]'))).toBe(true); + }); + + it("rejects a mistyped diff_ddl field (number instead of string)", () => { + expect(Result.isFailure(legacyProcessPgAdminDiffOutput('[{"diff_ddl":123}]'))).toBe(true); + }); + + it("rejects a mistyped group_name field (number instead of string)", () => { + expect(Result.isFailure(legacyProcessPgAdminDiffOutput('[{"group_name":123}]'))).toBe(true); + }); + + it("rejects a mistyped source_schema_name field (number instead of string)", () => { + expect(Result.isFailure(legacyProcessPgAdminDiffOutput('[{"source_schema_name":123}]'))).toBe( + true, + ); + }); + + it("rejects a dependencies field that isn't an array", () => { + expect(Result.isFailure(legacyProcessPgAdminDiffOutput('[{"dependencies":{}}]'))).toBe(true); + }); + + it("rejects a dependencies array whose element isn't an object or null (e.g. a number)", () => { + expect(Result.isFailure(legacyProcessPgAdminDiffOutput('[{"dependencies":[1]}]'))).toBe(true); + }); + + it("rejects a dependency element with a mistyped type field", () => { + expect( + Result.isFailure(legacyProcessPgAdminDiffOutput('[{"dependencies":[{"type":1}]}]')), + ).toBe(true); + }); + + it("accepts trailing whitespace after the JSON array", () => { + const result = legacyProcessPgAdminDiffOutput(`${JSON.stringify([entry()])}\n \n`); + expect(result).toEqual(Result.succeed(headerPlus("ALTER TABLE test;"))); + }); + + it("rejects two concatenated JSON arrays in a SINGLE buffer (this function's own single-buffer contract — legacyDiffSchemaPgAdmin no longer feeds it a multi-run concatenation; see legacyParsePgAdminDiffEntries below for the per-run parse)", () => { + const payload = `${JSON.stringify([entry()])}${JSON.stringify([entry()])}`; + expect(Result.isFailure(legacyProcessPgAdminDiffOutput(payload))).toBe(true); + }); + }); +}); + +describe("legacyParsePgAdminDiffEntries", () => { + it("returns an empty array for an entirely empty buffer", () => { + expect(legacyParsePgAdminDiffEntries("")).toEqual(Result.succeed([])); + }); + + it("returns an empty array when the buffer is only the DESKTOP-mode NOTE prefix", () => { + expect(legacyParsePgAdminDiffEntries(LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX)).toEqual( + Result.succeed([]), + ); + }); + + it("trims the DESKTOP-mode NOTE prefix from the front of the buffer before parsing", () => { + const payload = LEGACY_PGADMIN_DESKTOP_NOTE_PREFIX + JSON.stringify([entry()]); + expect(legacyParsePgAdminDiffEntries(payload)).toEqual(Result.succeed(["ALTER TABLE test;"])); + }); + + it("returns the ordered, filtered, trimmed DDLs — not the rendered header/join", () => { + const entries = [entry({ diff_ddl: "DDL_1;" }), entry({ diff_ddl: "DDL_2;" })]; + expect(legacyParsePgAdminDiffEntries(JSON.stringify(entries))).toEqual( + Result.succeed(["DDL_1;", "DDL_2;"]), + ); + }); + + it("fails on two concatenated JSON arrays within one buffer, same as legacyProcessPgAdminDiffOutput", () => { + const payload = `${JSON.stringify([entry()])}${JSON.stringify([entry()])}`; + expect(Result.isFailure(legacyParsePgAdminDiffEntries(payload))).toBe(true); + }); +}); + +describe("legacyRenderPgAdminDiff", () => { + it("returns an empty string for an empty DDL list", () => { + expect(legacyRenderPgAdminDiff([])).toBe(""); + }); + + it("renders the pgAdmin header followed by every DDL joined with a blank line", () => { + expect(legacyRenderPgAdminDiff(["DDL_1;", "DDL_2;"])).toBe(headerPlus("DDL_1;\n\nDDL_2;")); + }); +}); + +describe("legacyProcessPgAdminDiffProgress", () => { + it.each([ + ["Comparing Tables 45%", ["Comparing Tables "]], + ["Diffing 100%", ["Diffing 1"]], + // `container_output.go:96`'s real regexp and JS both produce group1="10", + // group2="00" for "1000%" (verified against Go 1.26 `regexp`) — NOT ["1"]. + ["1000%", ["10"]], + ["5%", []], + ["Starting schema diff...", []], + ["some random noise line", []], + ["", []], + ] as const)("%s => %j", (line, expected) => { + expect(legacyProcessPgAdminDiffProgress(line)).toEqual(expected); + }); + + it("scans multiple lines and strips \\r\\n line endings before matching", () => { + const input = + "Starting schema diff...\r\nComparing Tables 45%\r\nnoise line\r\nDiffing 100%\r\n"; + expect(legacyProcessPgAdminDiffProgress(input)).toEqual(["Comparing Tables ", "Diffing 1"]); + }); + + it("still emits a match on the final line even without a trailing newline", () => { + const input = "Starting schema diff...\nDiffing 100%"; + expect(legacyProcessPgAdminDiffProgress(input)).toEqual(["Diffing 1"]); + }); + + it("matches across embedded \\r within a single line (the `s`/dotAll flag, Go's RE2 . matches \\r)", () => { + // A `\r`-driven progress bar overwrites the same terminal line with multiple + // updates, none of them `\n`-terminated, so `legacyScanLines` treats the whole + // thing as ONE line. With the `s` flag, `.` matches `\r` too, so the greedy + // `(.*)` consumes across every embedded `\r` and the match is anchored on the + // LAST `%`-suffixed run, same as Go's RE2 (verified against Go 1.26 `regexp`) + // — not the first, which is what this pattern would wrongly match without `s` + // (JS's `.` excludes `\r` by default). + const input = "Comparing 10%\rComparing 20%\rComparing 30%"; + expect(legacyProcessPgAdminDiffProgress(input)).toEqual([ + "Comparing 10%\rComparing 20%\rComparing ", + ]); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-go-string.ts b/apps/cli/src/legacy/commands/db/shared/legacy-go-string.ts new file mode 100644 index 0000000000..5c9dfabba6 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-go-string.ts @@ -0,0 +1,18 @@ +/** + * Go string-primitive helpers shared across the `db` command family. Currently + * just `strings.TrimSpace`/`bytes.TrimSpace` — hoisted here (per the repo's + * "hoist before you duplicate" rule, AGENTS.md) once a second `db`-family caller + * needed the exact same primitive: `legacy-pgdelta.apply.ts` (CLI-1956, apply + * error-detail trimming) and `legacy-pgadmin-diff.ts` (CLI-1968, `diff_ddl` + * trimming) each carried their own private, verbatim copy before this move. + */ + +/** + * Go's `strings.TrimSpace`/`bytes.TrimSpace` trim exactly the Unicode + * `White_Space` set — which, unlike JS's `String.prototype.trim`, does NOT + * include U+FEFF (BOM/ZWNBSP). A BOM-prefixed payload must therefore fail to + * parse (or render un-trimmed) here exactly like it does in Go, and + * BOM-adjacent fields must render it, not eat it. + */ +export const legacyTrimGoSpace = (value: string): string => + value.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, ""); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.ts index 16e3a06dec..f9092b4b57 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.ts @@ -21,6 +21,7 @@ import { } from "../../../../shared/telemetry/error-actionability.ts"; import { LegacyEdgeRuntimeScript } from "../../../shared/legacy-edge-runtime-script.service.ts"; import { legacyGoQuote } from "../../../shared/legacy-go-quote.ts"; +import { legacyTrimGoSpace } from "./legacy-go-string.ts"; import { legacyInterpolatePgDeltaScript, legacyPgDeltaDeclarativeApplyScript, @@ -36,16 +37,6 @@ const errMessage = (e: unknown): string => ? e.message : String(e); -/** - * Go's `strings.TrimSpace`/`bytes.TrimSpace` trim exactly the Unicode - * `White_Space` set — which, unlike JS `String.prototype.trim`, does NOT - * include U+FEFF (BOM/ZWNBSP). A BOM-prefixed pg-delta payload must therefore - * fail to parse here exactly like it does in Go, and BOM-adjacent - * detail/hint/path fields must render it, not eat it. - */ -const legacyTrimGoSpace = (value: string): string => - value.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, ""); - /** * `pgdelta.ApplyDeclarative` failed — Go's own error messages at each step (see call sites * below). `reason` narrows the actionability classification below beyond the "user's own diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts index e9f4c01ba6..ddde6e91b8 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts @@ -15,11 +15,11 @@ * Go itself has are NOT all the same: `migration squash` (a future port, CLI-1969) only ever * needs create -> health-wait -> connect -> `SetupDatabase` (no `CREATE_TEMPLATE`, no * migrations at that point — `apps/cli-go/internal/migration/squash/squash.go:83-96`), while - * `db diff --use-pgadmin` (CLI-1968) needs create -> health-wait -> `MigrateShadowDatabase` - * (`apps/cli-go/internal/db/diff/pgadmin.go:70-78`). Exposing every primitive individually - * lets each future caller compose exactly the subset it needs, matching Go's own module shape - * 1:1 rather than forcing every caller through one shape only `db diff`/`db pull` happen to - * need. + * `db diff --use-pgadmin` (CLI-1968, realized: see `diff.handler.ts`'s pgadmin branch) needs + * create -> health-wait -> `MigrateShadowDatabase` (`apps/cli-go/internal/db/diff/ + * pgadmin.go:70-78`). Exposing every primitive individually lets each future caller compose + * exactly the subset it needs, matching Go's own module shape 1:1 rather than forcing every + * caller through one shape only `db diff`/`db pull` happen to need. * * A note on the shadow container's own addressing, since it's the one genuinely surprising * empirical fact this whole module depends on: the shadow container is created with NO name diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts index b391a629b9..5bf87861de 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts @@ -80,9 +80,11 @@ export const legacyEdgeRuntimeScriptLayer = Layer.effect( // config read happens here, not at layer acquisition, so merely composing // the db diff/pull runtime never validates the base config before the // linked ref is known (Go validates the `[remotes.]`-merged config, - // and even `db diff --use-pgadmin --linked` must not fail at layer build). - // Every pg-delta/migra caller passes `opts.denoVersion`, so the base read - // is a defensive fallback that does not run for them. + // and even `db diff --use-pgadmin --linked` — a native path since CLI-1968, + // reading config directly rather than exec'ing a Go child, and never calling + // this layer's `run` at all — must not fail at layer build). Every pg-delta/ + // migra caller passes `opts.denoVersion`, so the base read is a defensive + // fallback that does not run for them. // // Same per-run override for `workdir`: `cliConfig.workdir` is fixed at // layer-build time, before a command's own `process.chdir` (bootstrap's diff --git a/apps/cli/tests/helpers/legacy-mocks.ts b/apps/cli/tests/helpers/legacy-mocks.ts index 3e47545d79..935597cf21 100644 --- a/apps/cli/tests/helpers/legacy-mocks.ts +++ b/apps/cli/tests/helpers/legacy-mocks.ts @@ -761,13 +761,32 @@ const LEGACY_SHADOW_STARTING_STATE = * PRRT_kwDOErm0O86XMrID): with the default healthy-immediately response, a forked fiber can run * the ENTIRE shadow-provisioning sequence to completion synchronously before a test's own * polling loop is even scheduled, making `Fiber.interrupt` a no-op on an already-finished fiber. + * + * `dbNotRunning`/`dbInspectFailsWith` (CLI-1968) fake the SEPARATE `docker container inspect + * supabase_db_` probe `legacyIsLocalDbRunning` issues before `--use-pgadmin` + * provisions anything — distinguished from the shadow's own `container inspect <64-hex-id>` + * health probe by the target id's `supabase_db_` prefix, so both options leave the shadow's + * own health check on its normal (healthy/never-healthy) path. `dbNotRunning` reports the + * Go/Docker "container doesn't exist" shape (`legacyIsContainerNotFoundMessage`); mutually + * exclusive with `dbInspectFailsWith`, which instead reports a daemon-unreachable failure + * (`legacyIsDockerDaemonUnreachable`) with the given stderr text — enforced below (a test + * that sets both throws immediately, rather than one option silently winning). */ export function mockLegacyShadowContainerCliSpawner( - opts: { readonly neverHealthy?: boolean } = {}, + opts: { + readonly neverHealthy?: boolean; + readonly dbNotRunning?: boolean; + readonly dbInspectFailsWith?: string; + } = {}, ): { readonly layer: Layer.Layer; readonly spawned: ReadonlyArray<{ readonly args: ReadonlyArray }>; } { + if (opts.dbNotRunning === true && opts.dbInspectFailsWith !== undefined) { + throw new Error( + "mockLegacyShadowContainerCliSpawner: dbNotRunning and dbInspectFailsWith are mutually exclusive", + ); + } const neverHealthy = opts.neverHealthy ?? false; const spawned: Array<{ readonly args: ReadonlyArray }> = []; const encoder = new TextEncoder(); @@ -790,6 +809,30 @@ export function mockLegacyShadowContainerCliSpawner( ), ); } + const isLocalDbInspect = + args[0] === "container" && + args[1] === "inspect" && + (args[2] ?? "").startsWith("supabase_db_"); + if ( + isLocalDbInspect && + (opts.dbNotRunning === true || opts.dbInspectFailsWith !== undefined) + ) { + const stderrText = + opts.dbInspectFailsWith ?? `Error response from daemon: No such container: ${args[2]}`; + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(7000 + spawned.length), + stdout: Stream.empty, + stderr: Stream.fromIterable([encoder.encode(stderrText)]), + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + } let stdoutLines: ReadonlyArray = []; if (args[0] === "create") { stdoutLines = [LEGACY_FAKE_SHADOW_CONTAINER_ID];